Compare commits
	
		
			32 Commits
		
	
	
		
			version/0.
			...
			version/0.
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 82eade3eb1 | |||
| 56a9dcc88d | |||
| fe70d80189 | |||
| e97e22c58a | |||
| bb4e39aab6 | |||
| a8744f443c | |||
| 7fe9b8f0b4 | |||
| 696aa7e5f6 | |||
| e1d82aee1d | |||
| 151374f565 | |||
| bebeff9f7f | |||
| 8b99afa34d | |||
| b317852e8a | |||
| 24ae35c35a | |||
| 8e6bb48227 | |||
| 7a4e8af1ae | |||
| 0161205c82 | |||
| ca0ba85023 | |||
| c2ebaa7f64 | |||
| 23cccebb96 | |||
| 3f5d30e6fe | |||
| ca735349f9 | |||
| 25ce8c6dc7 | |||
| 081ac0bcdb | |||
| 8a07b349ee | |||
| b3468bc265 | |||
| 4edfad869f | |||
| 404f5d7912 | |||
| 8bea99a953 | |||
| 0b0ba33dce | |||
| e3627b2cd9 | |||
| 37fac3ae00 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 0.10.0-rc4 | current_version = 0.10.0-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>.*) | ||||||
| @ -19,6 +19,8 @@ values = | |||||||
|  |  | ||||||
| [bumpversion:file:docs/installation/docker-compose.md] | [bumpversion:file:docs/installation/docker-compose.md] | ||||||
|  |  | ||||||
|  | [bumpversion:file:docs/installation/kubernetes.md] | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:helm/values.yaml] | [bumpversion:file:helm/values.yaml] | ||||||
|  | |||||||
							
								
								
									
										31
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,6 +1,8 @@ | |||||||
| name: passbook-release | name: passbook-on-release | ||||||
|  |  | ||||||
| on: | on: | ||||||
|   release |   release: | ||||||
|  |     types: [published, created] | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   # Build |   # Build | ||||||
| @ -16,17 +18,26 @@ jobs: | |||||||
|       - name: Building Docker Image |       - name: Building Docker Image | ||||||
|         run: docker build |         run: docker build | ||||||
|           --no-cache |           --no-cache | ||||||
|           -t beryju/passbook:0.10.0-rc4 |           -t beryju/passbook:0.10.0-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.0-rc4 |         run: docker push beryju/passbook:0.10.0-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: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v1 |       - uses: actions/checkout@v1 | ||||||
|  |       - uses: actions/setup-go@v2 | ||||||
|  |         with: | ||||||
|  |           go-version: "^1.15" | ||||||
|  |       - name: prepare go api client | ||||||
|  |         run: | | ||||||
|  |           cd proxy | ||||||
|  |           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||||
|  |           swagger generate client -f ../swagger.yaml -A passbook -t pkg/ | ||||||
|  |           go build -v . | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         env: |         env: | ||||||
|           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} |           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||||
| @ -37,11 +48,11 @@ jobs: | |||||||
|           cd proxy |           cd proxy | ||||||
|           docker build \ |           docker build \ | ||||||
|           --no-cache \ |           --no-cache \ | ||||||
|           -t beryju/passbook-proxy:0.10.0-rc4 \ |           -t beryju/passbook-proxy:0.10.0-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.0-rc4 |         run: docker push beryju/passbook-proxy:0.10.0-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: | ||||||
| @ -66,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.0-rc4 |           -t beryju/passbook-static:0.10.0-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.0-rc4 |         run: docker push beryju/passbook-static:0.10.0-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: | ||||||
| @ -85,7 +96,7 @@ jobs: | |||||||
|           docker-compose pull -q |           docker-compose pull -q | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root server bash -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test" |           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook" | ||||||
|   sentry-release: |   sentry-release: | ||||||
|     needs: |     needs: | ||||||
|       - test-release |       - test-release | ||||||
| @ -100,5 +111,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.0-rc4 |           tagName: 0.10.0-stable | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,10 +1,10 @@ | |||||||
|  | name: passbook-on-tag | ||||||
|  |  | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     tags: |     tags: | ||||||
|     - 'version/*' |     - 'version/*' | ||||||
|  |  | ||||||
| name: passbook-version-tag |  | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   build: |   build: | ||||||
|     name: Create Release from Tag |     name: Create Release from Tag | ||||||
| @ -21,7 +21,7 @@ jobs: | |||||||
|             -f Dockerfile . |             -f Dockerfile . | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test" |           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook" | ||||||
|       - name: Install Helm |       - name: Install Helm | ||||||
|         run: | |         run: | | ||||||
|           apt update && apt install -y curl |           apt update && apt install -y curl | ||||||
| @ -31,7 +31,7 @@ jobs: | |||||||
|           helm dependency update helm/ |           helm dependency update helm/ | ||||||
|           helm package helm/ |           helm package helm/ | ||||||
|           mv passbook-*.tgz passbook-chart.tgz |           mv passbook-*.tgz passbook-chart.tgz | ||||||
|       - name: Extract verison number |       - name: Extract version number | ||||||
|         id: get_version |         id: get_version | ||||||
|         uses: actions/github-script@0.2.0 |         uses: actions/github-script@0.2.0 | ||||||
|         with: |         with: | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| all: lint-fix lint coverage gen | all: lint-fix lint coverage gen | ||||||
|  |  | ||||||
| coverage: | coverage: | ||||||
| 	coverage run --concurrency=multiprocessing manage.py test passbook --failfast | 	coverage run --concurrency=multiprocessing manage.py test --failfast | ||||||
| 	coverage combine | 	coverage combine | ||||||
| 	coverage html | 	coverage html | ||||||
| 	coverage report | 	coverage report | ||||||
| @ -18,3 +18,9 @@ lint: | |||||||
|  |  | ||||||
| gen: coverage | gen: coverage | ||||||
| 	./manage.py generate_swagger -o swagger.yaml -f yaml | 	./manage.py generate_swagger -o swagger.yaml -f yaml | ||||||
|  |  | ||||||
|  | local-stack: | ||||||
|  | 	export PASSBOOK_TAG=testing | ||||||
|  | 	docker build -t beryju/passbook:testng . | ||||||
|  | 	docker-compose up -d | ||||||
|  | 	docker-compose run --rm server migrate | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							| @ -59,5 +59,6 @@ docker = "*" | |||||||
| pylint = "*" | pylint = "*" | ||||||
| pylint-django = "*" | pylint-django = "*" | ||||||
| selenium = "*" | selenium = "*" | ||||||
| unittest-xml-reporting = "*" |  | ||||||
| prospector = "*" | prospector = "*" | ||||||
|  | pytest = "*" | ||||||
|  | pytest-django = "*" | ||||||
|  | |||||||
							
								
								
									
										87
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										87
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "a798bbd0b97857cac136c1743b8d6ad8bf8c3d95e2760c71d324bb2a7f47f678" |             "sha256": "80570636236962f4b934a884817292de9f7bb48520aa964afc2959b0f795fb57" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": { |         "requires": { | ||||||
| @ -74,18 +74,18 @@ | |||||||
|         }, |         }, | ||||||
|         "boto3": { |         "boto3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2ab73b0c400ab8c7df84bee7564ef8a0813021da28dd7a05fcbffb77a8ae9de9", |                 "sha256:20edd03ae4c4e141b0d8a9a9afc773af4345d54b68202b6aa502956b57b18b3f", | ||||||
|                 "sha256:bb2222fa02fcd09b39e581e532d4f013ea850742d8cd46e9c10a21028b6d2ef5" |                 "sha256:b596a80181fecd775ccc009286400f4d785136f250967895cb34beeeef65eb1f" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.14.56" |             "version": "==1.14.59" | ||||||
|         }, |         }, | ||||||
|         "botocore": { |         "botocore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:37cc3f1013c00dc0f061582198d6b785dadf147bd99307d41c5c0e47debca65c", |                 "sha256:193f193a66ac79106725e14dd73e28ed36bcec99b37156538a2202d061056a58", | ||||||
|                 "sha256:acd2df778a5e12b2a16ac040ce6e91a6c6f2d7ac67bd4f966472ce5c68b5b62d" |                 "sha256:e55a4fc652537f5ccb2362133f3928ebeafb04ee9fe15ea11c2df80ba4ef8a12" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.17.58" |             "version": "==1.17.60" | ||||||
|         }, |         }, | ||||||
|         "cachetools": { |         "cachetools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1373,6 +1373,13 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==2.10" |             "version": "==2.10" | ||||||
|         }, |         }, | ||||||
|  |         "iniconfig": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", | ||||||
|  |                 "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" | ||||||
|  |             ], | ||||||
|  |             "version": "==1.0.1" | ||||||
|  |         }, | ||||||
|         "isort": { |         "isort": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", |                 "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", | ||||||
| @ -1413,6 +1420,21 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.6.1" |             "version": "==0.6.1" | ||||||
|         }, |         }, | ||||||
|  |         "more-itertools": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", | ||||||
|  |                 "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" | ||||||
|  |             ], | ||||||
|  |             "version": "==8.5.0" | ||||||
|  |         }, | ||||||
|  |         "packaging": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", | ||||||
|  |                 "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==20.4" | ||||||
|  |         }, | ||||||
|         "pathspec": { |         "pathspec": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", |                 "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", | ||||||
| @ -1434,6 +1456,13 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.10.0" |             "version": "==0.10.0" | ||||||
|         }, |         }, | ||||||
|  |         "pluggy": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", | ||||||
|  |                 "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" | ||||||
|  |             ], | ||||||
|  |             "version": "==0.13.1" | ||||||
|  |         }, | ||||||
|         "prospector": { |         "prospector": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f" |                 "sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f" | ||||||
| @ -1441,6 +1470,13 @@ | |||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.3.0" |             "version": "==1.3.0" | ||||||
|         }, |         }, | ||||||
|  |         "py": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", | ||||||
|  |                 "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" | ||||||
|  |             ], | ||||||
|  |             "version": "==1.9.0" | ||||||
|  |         }, | ||||||
|         "pycodestyle": { |         "pycodestyle": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", |                 "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", | ||||||
| @ -1497,6 +1533,29 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.6" |             "version": "==0.6" | ||||||
|         }, |         }, | ||||||
|  |         "pyparsing": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", | ||||||
|  |                 "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" | ||||||
|  |             ], | ||||||
|  |             "version": "==2.4.7" | ||||||
|  |         }, | ||||||
|  |         "pytest": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40", | ||||||
|  |                 "sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==6.0.2" | ||||||
|  |         }, | ||||||
|  |         "pytest-django": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:64f99d565dd9497af412fcab2989fe40982c1282d4118ff422b407f3f7275ca5", | ||||||
|  |                 "sha256:664e5f42242e5e182519388f01b9f25d824a9feb7cd17d8f863c8d776f38baf9" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==3.9.0" | ||||||
|  |         }, | ||||||
|         "pytz": { |         "pytz": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", |                 "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", | ||||||
| @ -1604,10 +1663,10 @@ | |||||||
|         }, |         }, | ||||||
|         "stevedore": { |         "stevedore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e", |                 "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62", | ||||||
|                 "sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee" |                 "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.2.1" |             "version": "==3.2.2" | ||||||
|         }, |         }, | ||||||
|         "toml": { |         "toml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1642,14 +1701,6 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==1.4.1" |             "version": "==1.4.1" | ||||||
|         }, |         }, | ||||||
|         "unittest-xml-reporting": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", |  | ||||||
|                 "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695" |  | ||||||
|             ], |  | ||||||
|             "index": "pypi", |  | ||||||
|             "version": "==3.0.4" |  | ||||||
|         }, |  | ||||||
|         "urllib3": { |         "urllib3": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
|                 "secure" |                 "secure" | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| <img src="passbook/static/static/passbook/logo.svg" height="50" alt="passbook logo"><img src="passbook/static/static/passbook/brand_inverted.svg" height="50" alt="passbook"> | <img src="docs/images/logo.svg" height="50" alt="passbook logo"><img src="docs/images/brand_inverted.svg" height="50" alt="passbook"> | ||||||
|  |  | ||||||
| [](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1) | [](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1) | ||||||
|  |  | ||||||
| @ -20,7 +20,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml | |||||||
| # Optionally enable Error-reporting | # Optionally enable Error-reporting | ||||||
| # export PASSBOOK_ERROR_REPORTING=true | # export PASSBOOK_ERROR_REPORTING=true | ||||||
| # Optionally deploy a different version | # Optionally deploy a different version | ||||||
| # export PASSBOOK_TAG=0.10.0-rc4 | # export PASSBOOK_TAG=0.10.0-stable | ||||||
| # If this is a productive installation, set a different PostgreSQL Password | # If this is a productive installation, set a different PostgreSQL Password | ||||||
| # export PG_PASS=$(pwgen 40 1) | # export PG_PASS=$(pwgen 40 1) | ||||||
| docker-compose pull | docker-compose pull | ||||||
|  | |||||||
| @ -150,7 +150,7 @@ stages: | |||||||
|               publishLocation: 'pipeline' |               publishLocation: 'pipeline' | ||||||
|       - job: coverage_e2e |       - job: coverage_e2e | ||||||
|         pool: |         pool: | ||||||
|           vmImage: 'ubuntu-latest' |           name: coventry | ||||||
|         steps: |         steps: | ||||||
|           - task: UsePythonVersion@0 |           - task: UsePythonVersion@0 | ||||||
|             inputs: |             inputs: | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ services: | |||||||
|     labels: |     labels: | ||||||
|       - traefik.enable=false |       - traefik.enable=false | ||||||
|   server: |   server: | ||||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc4} |     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-stable} | ||||||
|     command: server |     command: server | ||||||
|     environment: |     environment: | ||||||
|       PASSBOOK_REDIS__HOST: redis |       PASSBOOK_REDIS__HOST: redis | ||||||
| @ -38,7 +38,7 @@ services: | |||||||
|       - traefik.docker.network=internal |       - traefik.docker.network=internal | ||||||
|       - traefik.frontend.rule=PathPrefix:/ |       - traefik.frontend.rule=PathPrefix:/ | ||||||
|   worker: |   worker: | ||||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc4} |     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-stable} | ||||||
|     command: worker |     command: worker | ||||||
|     networks: |     networks: | ||||||
|       - internal |       - internal | ||||||
| @ -51,7 +51,7 @@ services: | |||||||
|       PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword} |       PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword} | ||||||
|       PASSBOOK_LOG_LEVEL: debug |       PASSBOOK_LOG_LEVEL: debug | ||||||
|   static: |   static: | ||||||
|     image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.0-rc4} |     image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.0-stable} | ||||||
|     networks: |     networks: | ||||||
|       - internal |       - internal | ||||||
|     labels: |     labels: | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml | |||||||
| # Optionally enable Error-reporting | # Optionally enable Error-reporting | ||||||
| # export PASSBOOK_ERROR_REPORTING=true | # export PASSBOOK_ERROR_REPORTING=true | ||||||
| # Optionally deploy a different version | # Optionally deploy a different version | ||||||
| # export PASSBOOK_TAG=0.10.0-rc4 | # export PASSBOOK_TAG=0.10.0-stable | ||||||
| # If this is a productive installation, set a different PostgreSQL Password | # If this is a productive installation, set a different PostgreSQL Password | ||||||
| # export PG_PASS=$(pwgen 40 1) | # export PG_PASS=$(pwgen 40 1) | ||||||
| docker-compose pull | docker-compose pull | ||||||
|  | |||||||
| @ -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.9.0-stable |   tag: 0.10.0-stable | ||||||
|  |  | ||||||
| nameOverride: "" | nameOverride: "" | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								docs/outposts/deploy-docker-compose.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/outposts/deploy-docker-compose.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | # Outpost deployment in docker-compose | ||||||
|  |  | ||||||
|  | To deploy an outpost with docker-compose, use  this snippet in your docker-compose file. | ||||||
|  |  | ||||||
|  | You can also run the outpost in a separate docker-compose project, you just have to ensure that the outpost container can reach your application container. | ||||||
|  |  | ||||||
|  | ```yaml | ||||||
|  | version: 3.5 | ||||||
|  |  | ||||||
|  | services: | ||||||
|  |   passbook_proxy: | ||||||
|  |     image: beryju/passbook-proxy:0.10.0-stable | ||||||
|  |     ports: | ||||||
|  |       - 4180:4180 | ||||||
|  |       - 4443:4443 | ||||||
|  |     environment: | ||||||
|  |       PASSBOOK_HOST: https://your-passbook.tld | ||||||
|  |       PASSBOOK_INSECURE: 'true' | ||||||
|  |       PASSBOOK_TOKEN: token-generated-by-passbook | ||||||
|  | ``` | ||||||
							
								
								
									
										99
									
								
								docs/outposts/deploy-kubernetes.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								docs/outposts/deploy-kubernetes.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,99 @@ | |||||||
|  | # Outpost deployment on Kubernetes | ||||||
|  |  | ||||||
|  | Use the following manifest, replacing all values surrounded with `__`. | ||||||
|  |  | ||||||
|  | Afterwards, configure the proxy provider to connect to `<service name>.<namespace>.svc.cluster.local`, and update your Ingress to connect to the `passbook-outpost` service. | ||||||
|  |  | ||||||
|  | ```yaml | ||||||
|  | api_version: v1 | ||||||
|  | kind: secret | ||||||
|  | metadata: | ||||||
|  |   labels: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   name: passbook-outpost-api | ||||||
|  | string_data: | ||||||
|  |   passbook_host: '__PASSBOOK_URL__' | ||||||
|  |   passbook_host_insecure: 'true' | ||||||
|  |   token: '__PASSBOOK_TOKEN__' | ||||||
|  | type: Opaque | ||||||
|  | --- | ||||||
|  | api_version: apps/v1 | ||||||
|  | kind: deployment | ||||||
|  | metadata: | ||||||
|  |   labels: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   name: passbook-outpost | ||||||
|  | spec: | ||||||
|  |   selector: | ||||||
|  |     match_labels: | ||||||
|  |       app.kubernetes.io/instance: test | ||||||
|  |       app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |       app.kubernetes.io/name: passbook-proxy | ||||||
|  |       app.kubernetes.io/version: 0.10.0 | ||||||
|  |     template: | ||||||
|  |     metadata: | ||||||
|  |       labels: | ||||||
|  |         app.kubernetes.io/instance: test | ||||||
|  |         app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |         app.kubernetes.io/name: passbook-proxy | ||||||
|  |         app.kubernetes.io/version: 0.10.0 | ||||||
|  |         spec: | ||||||
|  |       containers: | ||||||
|  |       -  env: | ||||||
|  |         - name: PASSBOOK_HOST | ||||||
|  |           value_from: | ||||||
|  |             secret_key_ref: | ||||||
|  |               key: passbook_host | ||||||
|  |               name: passbook-outpost-api | ||||||
|  |         - name: PASSBOOK_TOKEN | ||||||
|  |           value_from: | ||||||
|  |             secret_key_ref: | ||||||
|  |               key: token | ||||||
|  |               name: passbook-outpost-api | ||||||
|  |         - name: PASSBOOK_INSECURE | ||||||
|  |           value_from: | ||||||
|  |             secret_key_ref: | ||||||
|  |               key: passbook_host_insecure | ||||||
|  |               name: passbook-outpost-api | ||||||
|  |         image: beryju/passbook-proxy:0.10.0 | ||||||
|  |         name: proxy | ||||||
|  |         ports: | ||||||
|  |         - containerPort: 4180 | ||||||
|  |           name: http | ||||||
|  |           protocol: TCP | ||||||
|  |         - containerPort: 4443 | ||||||
|  |           name: http | ||||||
|  |           protocol: TCP | ||||||
|  | --- | ||||||
|  | api_version: v1 | ||||||
|  | kind: service | ||||||
|  | metadata: | ||||||
|  |   labels: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   name: passbook-outpost | ||||||
|  | spec: | ||||||
|  |   ports: | ||||||
|  |   - name: http | ||||||
|  |     port: 4180 | ||||||
|  |     protocol: TCP | ||||||
|  |     targetPort: http | ||||||
|  |   - name: https | ||||||
|  |     port: 4443 | ||||||
|  |     protocol: TCP | ||||||
|  |     targetPort: https | ||||||
|  |   selector: | ||||||
|  |     app.kubernetes.io/instance: test | ||||||
|  |     app.kubernetes.io/managed-by: passbook.beryju.org | ||||||
|  |     app.kubernetes.io/name: passbook-proxy | ||||||
|  |     app.kubernetes.io/version: 0.10.0 | ||||||
|  |   type: ClusterIP | ||||||
|  | ``` | ||||||
| @ -6,21 +6,9 @@ An outpost is a single deployment of a passbook component, which can be deployed | |||||||
|  |  | ||||||
| Upon creation, a service account and a token is generated. The service account only has permissions to read the outpost and provider configuration. This token is used by the Outpost to connect to passbook. | Upon creation, a service account and a token is generated. The service account only has permissions to read the outpost and provider configuration. This token is used by the Outpost to connect to passbook. | ||||||
|  |  | ||||||
| To deploy an outpost, you can for example use this docker-compose snippet: | To deploy an outpost, see: <a name="deploy"> | ||||||
|  |  | ||||||
| ```yaml | - [Kubernetes](deploy-kubernetes.md) | ||||||
| version: 3.5 | - [docker-compose](deploy-docker-compose.md) | ||||||
|  |  | ||||||
| services: | In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster. | ||||||
|   passbook_proxy: |  | ||||||
|     image: beryju/passbook-proxy:0.10.0-stable |  | ||||||
|     ports: |  | ||||||
|       - 4180:4180 |  | ||||||
|       - 4443:4443 |  | ||||||
|     environment: |  | ||||||
|       PASSBOOK_HOST: https://your-passbook.tld |  | ||||||
|       PASSBOOK_INSECURE: 'true' |  | ||||||
|       PASSBOOK_TOKEN: token-generated-by-passbook |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster.w |  | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ version: '3.7' | |||||||
|  |  | ||||||
| services: | services: | ||||||
|   chrome: |   chrome: | ||||||
|     image: selenium/standalone-chrome-debug:3.141.59-20200525 |     image: selenium/standalone-chrome-debug:3.141.59-20200719 | ||||||
|     volumes: |     volumes: | ||||||
|       - /dev/shm:/dev/shm |       - /dev/shm:/dev/shm | ||||||
|     network_mode: host |     network_mode: host | ||||||
|  | |||||||
| @ -1,13 +1,12 @@ | |||||||
| """Test Enroll flow""" | """Test Enroll flow""" | ||||||
| from time import sleep | from sys import platform | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from django.test import override_settings | from django.test import override_settings | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.support import expected_conditions as ec | from selenium.webdriver.support import expected_conditions as ec | ||||||
| from structlog import get_logger |  | ||||||
|  |  | ||||||
| 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 | ||||||
| @ -18,41 +17,23 @@ from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage | |||||||
| from passbook.stages.user_login.models import UserLoginStage | from passbook.stages.user_login.models import UserLoginStage | ||||||
| from passbook.stages.user_write.models import UserWriteStage | from passbook.stages.user_write.models import UserWriteStage | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestFlowsEnroll(SeleniumTestCase): | class TestFlowsEnroll(SeleniumTestCase): | ||||||
|     """Test Enroll flow""" |     """Test Enroll flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         self.container = self.setup_client() |         return { | ||||||
|         super().setUp() |             "image": "mailhog/mailhog:v1.0.1", | ||||||
|  |             "detach": True, | ||||||
|     def setup_client(self) -> Container: |             "network_mode": "host", | ||||||
|         """Setup test IdP container""" |             "auto_remove": True, | ||||||
|         client: DockerClient = from_env() |             "healthcheck": Healthcheck( | ||||||
|         container = client.containers.run( |  | ||||||
|             image="mailhog/mailhog:v1.0.1", |  | ||||||
|             detach=True, |  | ||||||
|             network_mode="host", |  | ||||||
|             auto_remove=True, |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:8025"], |                 test=["CMD", "wget", "--spider", "http://localhost:8025"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_enroll_2_step(self): |     def test_enroll_2_step(self): | ||||||
|         """Test 2-step enroll flow""" |         """Test 2-step enroll flow""" | ||||||
| @ -220,21 +201,25 @@ class TestFlowsEnroll(SeleniumTestCase): | |||||||
|         self.driver.find_element(By.ID, "id_name").send_keys("some name") |         self.driver.find_element(By.ID, "id_name").send_keys("some name") | ||||||
|         self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") |         self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() |         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() | ||||||
|         sleep(3) |         # Wait for the success message so we know the email is sent | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-form > p")) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         # Open Mailhog |         # Open Mailhog | ||||||
|         self.driver.get("http://localhost:8025") |         self.driver.get("http://localhost:8025") | ||||||
|  |  | ||||||
|         # Click on first message |         # Click on first message | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CLASS_NAME, "msglist-message")) | ||||||
|  |         ) | ||||||
|         self.driver.find_element(By.CLASS_NAME, "msglist-message").click() |         self.driver.find_element(By.CLASS_NAME, "msglist-message").click() | ||||||
|         sleep(3) |  | ||||||
|         self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane")) |         self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane")) | ||||||
|         self.driver.find_element(By.ID, "confirm").click() |         self.driver.find_element(By.ID, "confirm").click() | ||||||
|         self.driver.close() |         self.driver.close() | ||||||
|         self.driver.switch_to.window(self.driver.window_handles[0]) |         self.driver.switch_to.window(self.driver.window_handles[0]) | ||||||
|  |  | ||||||
|         # We're now logged in |         # We're now logged in | ||||||
|         sleep(3) |  | ||||||
|         self.wait.until( |         self.wait.until( | ||||||
|             ec.presence_of_element_located( |             ec.presence_of_element_located( | ||||||
|                 (By.XPATH, "//a[contains(@href, '/-/user/')]") |                 (By.XPATH, "//a[contains(@href, '/-/user/')]") | ||||||
|  | |||||||
| @ -1,10 +1,14 @@ | |||||||
| """test default login flow""" | """test default login flow""" | ||||||
|  | from sys import platform | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
|  |  | ||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestFlowsLogin(SeleniumTestCase): | class TestFlowsLogin(SeleniumTestCase): | ||||||
|     """test default login flow""" |     """test default login flow""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """test stage setup flows (password change)""" | """test stage setup flows (password change)""" | ||||||
| import string | from sys import platform | ||||||
| from random import SystemRandom | from unittest.case import skipUnless | ||||||
| from time import sleep |  | ||||||
|  |  | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| @ -9,9 +8,11 @@ from selenium.webdriver.common.keys import Keys | |||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.models import Flow, FlowDesignation | from passbook.flows.models import Flow, FlowDesignation | ||||||
|  | from passbook.providers.oauth2.generators import generate_client_secret | ||||||
| from passbook.stages.password.models import PasswordStage | from passbook.stages.password.models import PasswordStage | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestFlowsStageSetup(SeleniumTestCase): | class TestFlowsStageSetup(SeleniumTestCase): | ||||||
|     """test stage setup flows""" |     """test stage setup flows""" | ||||||
|  |  | ||||||
| @ -27,10 +28,7 @@ class TestFlowsStageSetup(SeleniumTestCase): | |||||||
|         stage.change_flow = flow |         stage.change_flow = flow | ||||||
|         stage.save() |         stage.save() | ||||||
|  |  | ||||||
|         new_password = "".join( |         new_password = generate_client_secret() | ||||||
|             SystemRandom().choice(string.ascii_uppercase + string.digits) |  | ||||||
|             for _ in range(8) |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         self.driver.get( |         self.driver.get( | ||||||
|             f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F" |             f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F" | ||||||
| @ -48,7 +46,7 @@ class TestFlowsStageSetup(SeleniumTestCase): | |||||||
|         self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password) |         self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password) | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() |         self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() | ||||||
|  |  | ||||||
|         sleep(2) |         self.wait_for_url(self.url("passbook_core:user-settings")) | ||||||
|         # Because USER() is cached, we need to get the user manually here |         # Because USER() is cached, we need to get the user manually here | ||||||
|         user = User.objects.get(username=USER().username) |         user = User.objects.get(username=USER().username) | ||||||
|         self.assertTrue(user.check_password(new_password)) |         self.assertTrue(user.check_password(new_password)) | ||||||
|  | |||||||
| @ -1,12 +1,11 @@ | |||||||
| """test OAuth Provider flow""" | """test OAuth Provider flow""" | ||||||
| from time import sleep | from sys import platform | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| from structlog import get_logger |  | ||||||
|  |  | ||||||
| from e2e.utils import USER, SeleniumTestCase | from e2e.utils import USER, SeleniumTestCase | ||||||
| from passbook.core.models import Application | from passbook.core.models import Application | ||||||
| @ -19,32 +18,29 @@ from passbook.providers.oauth2.generators import ( | |||||||
| ) | ) | ||||||
| from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes | from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes | ||||||
|  |  | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestProviderOAuth2Github(SeleniumTestCase): | class TestProviderOAuth2Github(SeleniumTestCase): | ||||||
|     """test OAuth Provider flow""" |     """test OAuth Provider flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.client_id = generate_client_id() |         self.client_id = generate_client_id() | ||||||
|         self.client_secret = generate_client_secret() |         self.client_secret = generate_client_secret() | ||||||
|         self.container = self.setup_client() |  | ||||||
|         super().setUp() |         super().setUp() | ||||||
|  |  | ||||||
|     def setup_client(self) -> Container: |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         """Setup client grafana container which we test OAuth against""" |         """Setup client grafana container which we test OAuth against""" | ||||||
|         client: DockerClient = from_env() |         return { | ||||||
|         container = client.containers.run( |             "image": "grafana/grafana:7.1.0", | ||||||
|             image="grafana/grafana:7.1.0", |             "detach": True, | ||||||
|             detach=True, |             "network_mode": "host", | ||||||
|             network_mode="host", |             "auto_remove": True, | ||||||
|             auto_remove=True, |             "healthcheck": Healthcheck( | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:3000"], |                 test=["CMD", "wget", "--spider", "http://localhost:3000"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             environment={ |             "environment": { | ||||||
|                 "GF_AUTH_GITHUB_ENABLED": "true", |                 "GF_AUTH_GITHUB_ENABLED": "true", | ||||||
|                 "GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true", |                 "GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true", | ||||||
|                 "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, |                 "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, | ||||||
| @ -61,22 +57,10 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|                 ), |                 ), | ||||||
|                 "GF_LOG_LEVEL": "debug", |                 "GF_LOG_LEVEL": "debug", | ||||||
|             }, |             }, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_authorization_consent_implied(self): |     def test_authorization_consent_implied(self): | ||||||
|         """test OAuth Provider flow (default authorization flow with implied consent)""" |         """test OAuth Provider flow (default authorization flow with implied consent)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authorization_flow = Flow.objects.get( |         authorization_flow = Flow.objects.get( | ||||||
|             slug="default-provider-authorization-implicit-consent" |             slug="default-provider-authorization-implicit-consent" | ||||||
| @ -129,7 +113,6 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_authorization_consent_explicit(self): |     def test_authorization_consent_explicit(self): | ||||||
|         """test OAuth Provider flow (default authorization flow with explicit consent)""" |         """test OAuth Provider flow (default authorization flow with explicit consent)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authorization_flow = Flow.objects.get( |         authorization_flow = Flow.objects.get( | ||||||
|             slug="default-provider-authorization-explicit-consent" |             slug="default-provider-authorization-explicit-consent" | ||||||
| @ -167,8 +150,13 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|                 By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]" |                 By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]" | ||||||
|             ).text, |             ).text, | ||||||
|         ) |         ) | ||||||
|         sleep(1) |         self.driver.find_element( | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() |             By.CSS_SELECTOR, | ||||||
|  |             ( | ||||||
|  |                 "form[action='/flows/b/default-provider-authorization-explicit-consent/'] " | ||||||
|  |                 "[type=submit]" | ||||||
|  |             ), | ||||||
|  |         ).click() | ||||||
|  |  | ||||||
|         self.wait_for_url("http://localhost:3000/?orgId=1") |         self.wait_for_url("http://localhost:3000/?orgId=1") | ||||||
|         self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() |         self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() | ||||||
| @ -197,7 +185,6 @@ class TestProviderOAuth2Github(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_denied(self): |     def test_denied(self): | ||||||
|         """test OAuth Provider flow (default authorization flow, denied)""" |         """test OAuth Provider flow (default authorization flow, denied)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         authorization_flow = Flow.objects.get( |         authorization_flow = Flow.objects.get( | ||||||
|             slug="default-provider-authorization-explicit-consent" |             slug="default-provider-authorization-explicit-consent" | ||||||
|  | |||||||
| @ -1,8 +1,9 @@ | |||||||
| """test OAuth2 OpenID Provider flow""" | """test OAuth2 OpenID Provider flow""" | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| @ -34,29 +35,27 @@ from passbook.providers.oauth2.models import ( | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestProviderOAuth2OIDC(SeleniumTestCase): | class TestProviderOAuth2OIDC(SeleniumTestCase): | ||||||
|     """test OAuth with OpenID Provider flow""" |     """test OAuth with OpenID Provider flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.client_id = generate_client_id() |         self.client_id = generate_client_id() | ||||||
|         self.client_secret = generate_client_secret() |         self.client_secret = generate_client_secret() | ||||||
|         self.container = self.setup_client() |  | ||||||
|         super().setUp() |         super().setUp() | ||||||
|  |  | ||||||
|     def setup_client(self) -> Container: |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         """Setup client grafana container which we test OIDC against""" |         return { | ||||||
|         client: DockerClient = from_env() |             "image": "grafana/grafana:7.1.0", | ||||||
|         container = client.containers.run( |             "detach": True, | ||||||
|             image="grafana/grafana:7.1.0", |             "network_mode": "host", | ||||||
|             detach=True, |             "auto_remove": True, | ||||||
|             network_mode="host", |             "healthcheck": Healthcheck( | ||||||
|             auto_remove=True, |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:3000"], |                 test=["CMD", "wget", "--spider", "http://localhost:3000"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             environment={ |             "environment": { | ||||||
|                 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", |                 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", | ||||||
|                 "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id, |                 "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id, | ||||||
|                 "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, |                 "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, | ||||||
| @ -72,18 +71,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): | |||||||
|                 ), |                 ), | ||||||
|                 "GF_LOG_LEVEL": "debug", |                 "GF_LOG_LEVEL": "debug", | ||||||
|             }, |             }, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_redirect_uri_error(self): |     def test_redirect_uri_error(self): | ||||||
|         """test OpenID Provider flow (invalid redirect URI, check error message)""" |         """test OpenID Provider flow (invalid redirect URI, check error message)""" | ||||||
|  | |||||||
| @ -1,5 +1,7 @@ | |||||||
| """test SAML Provider flow""" | """test SAML Provider flow""" | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env | from docker import DockerClient, from_env | ||||||
| from docker.models.containers import Container | from docker.models.containers import Container | ||||||
| @ -23,6 +25,7 @@ from passbook.providers.saml.models import ( | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestProviderSAML(SeleniumTestCase): | class TestProviderSAML(SeleniumTestCase): | ||||||
|     """test SAML Provider flow""" |     """test SAML Provider flow""" | ||||||
|  |  | ||||||
| @ -60,10 +63,6 @@ class TestProviderSAML(SeleniumTestCase): | |||||||
|             LOGGER.info("Container failed healthcheck") |             LOGGER.info("Container failed healthcheck") | ||||||
|             sleep(1) |             sleep(1) | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_sp_initiated_implicit(self): |     def test_sp_initiated_implicit(self): | ||||||
|         """test SAML Provider flow SP-initiated flow (implicit consent)""" |         """test SAML Provider flow SP-initiated flow (implicit consent)""" | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| """test OAuth Source""" | """test OAuth Source""" | ||||||
| from os.path import abspath | from os.path import abspath | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
| 
 | 
 | ||||||
| from docker import DockerClient, from_env | from django.test import override_settings | ||||||
| from docker.models.containers import Container | from docker.models.containers import Container | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| @ -21,6 +24,7 @@ CONFIG_PATH = "/tmp/dex.yml" | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestSourceOAuth(SeleniumTestCase): | class TestSourceOAuth(SeleniumTestCase): | ||||||
|     """test OAuth Source flow""" |     """test OAuth Source flow""" | ||||||
| 
 | 
 | ||||||
| @ -28,7 +32,7 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
| 
 | 
 | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.client_secret = generate_client_secret() |         self.client_secret = generate_client_secret() | ||||||
|         self.container = self.setup_client() |         self.prepare_dex_config() | ||||||
|         super().setUp() |         super().setUp() | ||||||
| 
 | 
 | ||||||
|     def prepare_dex_config(self): |     def prepare_dex_config(self): | ||||||
| @ -66,34 +70,23 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|         with open(CONFIG_PATH, "w+") as _file: |         with open(CONFIG_PATH, "w+") as _file: | ||||||
|             safe_dump(config, _file) |             safe_dump(config, _file) | ||||||
| 
 | 
 | ||||||
|     def setup_client(self) -> Container: |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         """Setup test Dex container""" |         return { | ||||||
|         self.prepare_dex_config() |             "image": "quay.io/dexidp/dex:v2.24.0", | ||||||
|         client: DockerClient = from_env() |             "detach": True, | ||||||
|         container = client.containers.run( |             "network_mode": "host", | ||||||
|             image="quay.io/dexidp/dex:v2.24.0", |             "auto_remove": True, | ||||||
|             detach=True, |             "command": "serve /config.yml", | ||||||
|             network_mode="host", |             "healthcheck": Healthcheck( | ||||||
|             auto_remove=True, |  | ||||||
|             command="serve /config.yml", |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"], |                 test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             volumes={abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}}, |             "volumes": {abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}}, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
| 
 | 
 | ||||||
|     def create_objects(self): |     def create_objects(self): | ||||||
|         """Create required objects""" |         """Create required objects""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         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") | ||||||
| @ -111,10 +104,6 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|             consumer_secret=self.client_secret, |             consumer_secret=self.client_secret, | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
| 
 |  | ||||||
|     def test_oauth_enroll(self): |     def test_oauth_enroll(self): | ||||||
|         """test OAuth Source With With OIDC""" |         """test OAuth Source With With OIDC""" | ||||||
|         self.create_objects() |         self.create_objects() | ||||||
| @ -141,6 +130,7 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|         ) |         ) | ||||||
|         self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() |         self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() | ||||||
| 
 | 
 | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.NAME, "username"))) | ||||||
|         # At this point we've been redirected back |         # At this point we've been redirected back | ||||||
|         # and we're asked for the username |         # and we're asked for the username | ||||||
|         self.driver.find_element(By.NAME, "username").click() |         self.driver.find_element(By.NAME, "username").click() | ||||||
| @ -167,6 +157,42 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|             "admin@example.com", |             "admin@example.com", | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |     @override_settings(SESSION_COOKIE_SAMESITE="strict") | ||||||
|  |     def test_oauth_samesite_strict(self): | ||||||
|  |         """test OAuth Source With SameSite set to strict | ||||||
|  |         (=will fail because session is not carried over)""" | ||||||
|  |         self.create_objects() | ||||||
|  |         self.driver.get(self.live_server_url) | ||||||
|  | 
 | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located( | ||||||
|  |                 (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.driver.find_element( | ||||||
|  |             By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" | ||||||
|  |         ).click() | ||||||
|  | 
 | ||||||
|  |         # Now we should be at the IDP, wait for the login field | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.ID, "login"))) | ||||||
|  |         self.driver.find_element(By.ID, "login").send_keys("admin@example.com") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys("password") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) | ||||||
|  | 
 | ||||||
|  |         # Wait until we're logged in | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]")) | ||||||
|  |         ) | ||||||
|  |         self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() | ||||||
|  | 
 | ||||||
|  |         self.wait.until( | ||||||
|  |             ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-alert__title")) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual( | ||||||
|  |             self.driver.find_element(By.CSS_SELECTOR, ".pf-c-alert__title").text, | ||||||
|  |             "Authentication Failed.", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|     def test_oauth_enroll_auth(self): |     def test_oauth_enroll_auth(self): | ||||||
|         """test OAuth Source With With OIDC (enroll and authenticate again)""" |         """test OAuth Source With With OIDC (enroll and authenticate again)""" | ||||||
|         self.test_oauth_enroll() |         self.test_oauth_enroll() | ||||||
| @ -178,10 +204,11 @@ class TestSourceOAuth(SeleniumTestCase): | |||||||
|                 (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") |                 (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|  |         sleep(1) | ||||||
|         self.driver.find_element( |         self.driver.find_element( | ||||||
|             By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" |             By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" | ||||||
|         ).click() |         ).click() | ||||||
| 
 |         sleep(1) | ||||||
|         # Now we should be at the IDP, wait for the login field |         # Now we should be at the IDP, wait for the login field | ||||||
|         self.wait.until(ec.presence_of_element_located((By.ID, "login"))) |         self.wait.until(ec.presence_of_element_located((By.ID, "login"))) | ||||||
|         self.driver.find_element(By.ID, "login").send_keys("admin@example.com") |         self.driver.find_element(By.ID, "login").send_keys("admin@example.com") | ||||||
| @ -1,8 +1,9 @@ | |||||||
| """test SAML Source""" | """test SAML Source""" | ||||||
|  | from sys import platform | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  | from unittest.case import skipUnless | ||||||
|  |  | ||||||
| from docker import DockerClient, from_env |  | ||||||
| from docker.models.containers import Container |  | ||||||
| from docker.types import Healthcheck | from docker.types import Healthcheck | ||||||
| from selenium.webdriver.common.by import By | from selenium.webdriver.common.by import By | ||||||
| from selenium.webdriver.common.keys import Keys | from selenium.webdriver.common.keys import Keys | ||||||
| @ -68,48 +69,31 @@ Sm75WXsflOxuTn08LbgGc4s= | |||||||
| -----END PRIVATE KEY-----""" | -----END PRIVATE KEY-----""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @skipUnless(platform.startswith("linux"), "requires local docker") | ||||||
| class TestSourceSAML(SeleniumTestCase): | class TestSourceSAML(SeleniumTestCase): | ||||||
|     """test SAML Source flow""" |     """test SAML Source flow""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|         self.container = self.setup_client() |         return { | ||||||
|         super().setUp() |             "image": "kristophjunge/test-saml-idp:1.15", | ||||||
|  |             "detach": True, | ||||||
|     def setup_client(self) -> Container: |             "network_mode": "host", | ||||||
|         """Setup test IdP container""" |             "auto_remove": True, | ||||||
|         client: DockerClient = from_env() |             "healthcheck": Healthcheck( | ||||||
|         container = client.containers.run( |  | ||||||
|             image="kristophjunge/test-saml-idp:1.15", |  | ||||||
|             detach=True, |  | ||||||
|             network_mode="host", |  | ||||||
|             auto_remove=True, |  | ||||||
|             healthcheck=Healthcheck( |  | ||||||
|                 test=["CMD", "curl", "http://localhost:8080"], |                 test=["CMD", "curl", "http://localhost:8080"], | ||||||
|                 interval=5 * 100 * 1000000, |                 interval=5 * 100 * 1000000, | ||||||
|                 start_period=1 * 100 * 1000000, |                 start_period=1 * 100 * 1000000, | ||||||
|             ), |             ), | ||||||
|             environment={ |             "environment": { | ||||||
|                 "SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id", |                 "SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id", | ||||||
|                 "SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": ( |                 "SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": ( | ||||||
|                     f"{self.live_server_url}/source/saml/saml-idp-test/acs/" |                     f"{self.live_server_url}/source/saml/saml-idp-test/acs/" | ||||||
|                 ), |                 ), | ||||||
|             }, |             }, | ||||||
|         ) |         } | ||||||
|         while True: |  | ||||||
|             container.reload() |  | ||||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") |  | ||||||
|             if status == "healthy": |  | ||||||
|                 return container |  | ||||||
|             LOGGER.info("Container failed healthcheck") |  | ||||||
|             sleep(1) |  | ||||||
|  |  | ||||||
|     def tearDown(self): |  | ||||||
|         self.container.kill() |  | ||||||
|         super().tearDown() |  | ||||||
|  |  | ||||||
|     def test_idp_redirect(self): |     def test_idp_redirect(self): | ||||||
|         """test SAML Source With redirect binding""" |         """test SAML Source With redirect binding""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         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") | ||||||
| @ -161,7 +145,6 @@ class TestSourceSAML(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_idp_post(self): |     def test_idp_post(self): | ||||||
|         """test SAML Source With post binding""" |         """test SAML Source With post binding""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         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") | ||||||
| @ -215,7 +198,6 @@ class TestSourceSAML(SeleniumTestCase): | |||||||
|  |  | ||||||
|     def test_idp_post_auto(self): |     def test_idp_post_auto(self): | ||||||
|         """test SAML Source With post binding (auto redirect)""" |         """test SAML Source With post binding (auto redirect)""" | ||||||
|         sleep(1) |  | ||||||
|         # Bootstrap all needed objects |         # Bootstrap all needed objects | ||||||
|         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") | ||||||
|  | |||||||
							
								
								
									
										31
									
								
								e2e/utils.py
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								e2e/utils.py
									
									
									
									
									
								
							| @ -4,13 +4,16 @@ 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 | ||||||
| from os import environ, makedirs | from os import environ, makedirs | ||||||
| from time import time | from time import sleep, time | ||||||
|  | from typing import Any, Dict, Optional | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.contrib.staticfiles.testing import StaticLiveServerTestCase | from django.contrib.staticfiles.testing import StaticLiveServerTestCase | ||||||
| from django.db import connection, transaction | from django.db import connection, transaction | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
|  | from docker import DockerClient, from_env | ||||||
|  | from docker.models.containers import Container | ||||||
| from selenium import webdriver | from selenium import webdriver | ||||||
| from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | ||||||
| from selenium.webdriver.remote.webdriver import WebDriver | from selenium.webdriver.remote.webdriver import WebDriver | ||||||
| @ -30,15 +33,35 @@ def USER() -> User:  # noqa | |||||||
| class SeleniumTestCase(StaticLiveServerTestCase): | class SeleniumTestCase(StaticLiveServerTestCase): | ||||||
|     """StaticLiveServerTestCase which automatically creates a Webdriver instance""" |     """StaticLiveServerTestCase which automatically creates a Webdriver instance""" | ||||||
|  |  | ||||||
|  |     container: Optional[Container] = None | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         makedirs("selenium_screenshots/", exist_ok=True) |         makedirs("selenium_screenshots/", exist_ok=True) | ||||||
|         self.driver = self._get_driver() |         self.driver = self._get_driver() | ||||||
|         self.driver.maximize_window() |         self.driver.maximize_window() | ||||||
|         self.driver.implicitly_wait(30) |         self.driver.implicitly_wait(10) | ||||||
|         self.wait = WebDriverWait(self.driver, 50) |         self.wait = WebDriverWait(self.driver, 30) | ||||||
|         self.apply_default_data() |         self.apply_default_data() | ||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
|  |         if specs := self.get_container_specs(): | ||||||
|  |             self.container = self._start_container(specs) | ||||||
|  |  | ||||||
|  |     def _start_container(self, specs: Dict[str, Any]) -> Container: | ||||||
|  |         client: DockerClient = from_env() | ||||||
|  |         container = client.containers.run(**specs) | ||||||
|  |         while True: | ||||||
|  |             container.reload() | ||||||
|  |             status = container.attrs.get("State", {}).get("Health", {}).get("Status") | ||||||
|  |             if status == "healthy": | ||||||
|  |                 return container | ||||||
|  |             self.logger.info("Container failed healthcheck") | ||||||
|  |             sleep(1) | ||||||
|  |  | ||||||
|  |     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||||
|  |         """Optionally get container specs which will launched on setup, wait for the container to | ||||||
|  |         be healthy, and deleted again on tearDown""" | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def _get_driver(self) -> WebDriver: |     def _get_driver(self) -> WebDriver: | ||||||
|         return webdriver.Remote( |         return webdriver.Remote( | ||||||
| @ -57,6 +80,8 @@ class SeleniumTestCase(StaticLiveServerTestCase): | |||||||
|             self.logger.warning( |             self.logger.warning( | ||||||
|                 line["message"], source=line["source"], level=line["level"] |                 line["message"], source=line["source"], level=line["level"] | ||||||
|             ) |             ) | ||||||
|  |         if self.container: | ||||||
|  |             self.container.kill() | ||||||
|         self.driver.quit() |         self.driver.quit() | ||||||
|         super().tearDown() |         super().tearDown() | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,9 +1,9 @@ | |||||||
| apiVersion: v2 | apiVersion: v2 | ||||||
| appVersion: "0.10.0-rc4" | appVersion: "0.10.0-stable" | ||||||
| description: A Helm chart for passbook. | description: A Helm chart for passbook. | ||||||
| name: passbook | name: passbook | ||||||
| version: "0.10.0-rc4" | version: "0.10.0-stable" | ||||||
| icon: https://github.com/BeryJu/passbook/blob/master/passbook/static/static/passbook/logo.svg | icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg | ||||||
| dependencies: | dependencies: | ||||||
|   - name: postgresql |   - name: postgresql | ||||||
|     version: 9.3.2 |     version: 9.3.2 | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ metadata: | |||||||
|     app.kubernetes.io/managed-by: {{ .Release.Service }} |     app.kubernetes.io/managed-by: {{ .Release.Service }} | ||||||
|     k8s.passbook.beryju.org/component: web |     k8s.passbook.beryju.org/component: web | ||||||
| spec: | spec: | ||||||
|   replicas: {{ serverReplicas }} |   replicas: {{ .Values.serverReplicas }} | ||||||
|   selector: |   selector: | ||||||
|     matchLabels: |     matchLabels: | ||||||
|       app.kubernetes.io/name: {{ include "passbook.name" . }} |       app.kubernetes.io/name: {{ include "passbook.name" . }} | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ metadata: | |||||||
|     app.kubernetes.io/managed-by: {{ .Release.Service }} |     app.kubernetes.io/managed-by: {{ .Release.Service }} | ||||||
|     k8s.passbook.beryju.org/component: worker |     k8s.passbook.beryju.org/component: worker | ||||||
| spec: | spec: | ||||||
|   replicas: {{ workerReplicas }} |   replicas: {{ .Values.workerReplicas }} | ||||||
|   selector: |   selector: | ||||||
|     matchLabels: |     matchLabels: | ||||||
|       app.kubernetes.io/name: {{ include "passbook.name" . }} |       app.kubernetes.io/name: {{ include "passbook.name" . }} | ||||||
|  | |||||||
| @ -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.0-rc4 |   tag: 0.10.0-stable | ||||||
|  |  | ||||||
| nameOverride: "" | nameOverride: "" | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| """Gunicorn config""" | """Gunicorn config""" | ||||||
|  | from multiprocessing import cpu_count | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
| import structlog | import structlog | ||||||
|  |  | ||||||
| bind = "0.0.0.0:8000" | bind = "0.0.0.0:8000" | ||||||
| workers = 2 |  | ||||||
| threads = 4 |  | ||||||
|  |  | ||||||
| user = "passbook" | user = "passbook" | ||||||
| group = "passbook" | group = "passbook" | ||||||
| @ -40,3 +41,11 @@ logconfig_dict = { | |||||||
|         "gunicorn": {"handlers": ["console"], "level": "INFO", "propagate": False}, |         "gunicorn": {"handlers": ["console"], "level": "INFO", "propagate": False}, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | # if we're running in kubernetes, use fixed workers because we can scale with more pods | ||||||
|  | # otherwise (assume docker-compose), use as much as we can | ||||||
|  | if Path("/var/run/secrets/kubernetes.io").exists(): | ||||||
|  |     workers = 2 | ||||||
|  | else: | ||||||
|  |     worker = cpu_count() | ||||||
|  | threads = 4 | ||||||
|  | |||||||
| @ -30,7 +30,10 @@ nav: | |||||||
|     - OAuth2: providers/oauth2.md |     - OAuth2: providers/oauth2.md | ||||||
|     - SAML: providers/saml.md |     - SAML: providers/saml.md | ||||||
|     - Proxy: providers/proxy.md |     - Proxy: providers/proxy.md | ||||||
|   - Outposts: outposts/outposts.md |   - Outposts: | ||||||
|  |     - Overview: outposts/outposts.md | ||||||
|  |     - Deploy on docker-compose: outposts/deploy-docker-compose.md | ||||||
|  |     - Deploy on Kubernetes: outposts/deploy-kubernetes.md | ||||||
|   - Expressions: |   - Expressions: | ||||||
|     - Overview: expressions/index.md |     - Overview: expressions/index.md | ||||||
|     - Reference: |     - Reference: | ||||||
|  | |||||||
| @ -1,2 +1,2 @@ | |||||||
| """passbook""" | """passbook""" | ||||||
| __version__ = "0.10.0-rc4" | __version__ = "0.10.0-stable" | ||||||
|  | |||||||
| @ -69,6 +69,7 @@ | |||||||
|                     <td> |                     <td> | ||||||
|                         <a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> |                         <a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a> | ||||||
|                         <a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a> |                         <a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a> | ||||||
|  |                         <a href="https://passbook.beryju.org/outposts/outposts/#deploy">{% trans 'Deploy' %}</a> | ||||||
|                     </td> |                     </td> | ||||||
|                 </tr> |                 </tr> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ from django.contrib.auth.mixins import ( | |||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.http import HttpRequest, HttpResponse, JsonResponse | from django.http import HttpRequest, HttpResponse, JsonResponse | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ from django.contrib.messages.views import SuccessMessageMixin | |||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.http import HttpResponse | from django.http import HttpResponse | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import FormView | from django.views.generic import FormView | ||||||
| from django.views.generic.detail import DetailView | from django.views.generic.detail import DetailView | ||||||
| from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | from guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from django.contrib.auth.mixins import ( | |||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 ( | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from django.contrib.auth.mixins import ( | |||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.http import HttpResponseRedirect | from django.http import HttpResponseRedirect | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | |||||||
| ) | ) | ||||||
| from django.contrib.messages.views import SuccessMessageMixin | from django.contrib.messages.views import SuccessMessageMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """passbook Token administration""" | """passbook Token administration""" | ||||||
| from django.contrib.auth.mixins import LoginRequiredMixin | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.translation import ugettext as _ | 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 | ||||||
|  |  | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ from django.http import HttpRequest, HttpResponse | |||||||
| from django.shortcuts import redirect | from django.shortcuts import redirect | ||||||
| from django.urls import reverse, reverse_lazy | from django.urls import reverse, reverse_lazy | ||||||
| from django.utils.http import urlencode | from django.utils.http import urlencode | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import DetailView, ListView, UpdateView | from django.views.generic import DetailView, ListView, UpdateView | ||||||
| from guardian.mixins import ( | from guardian.mixins import ( | ||||||
|     PermissionListMixin, |     PermissionListMixin, | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """api v2 urls""" | """api v2 urls""" | ||||||
| from django.conf.urls import url | from django.urls import path, re_path | ||||||
| from django.urls import path |  | ||||||
| from drf_yasg import openapi | from drf_yasg import openapi | ||||||
| from drf_yasg.views import get_schema_view | from drf_yasg.views import get_schema_view | ||||||
| from rest_framework import routers | from rest_framework import routers | ||||||
| @ -119,7 +118,7 @@ SchemaView = get_schema_view( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     url( |     re_path( | ||||||
|         r"^swagger(?P<format>\.json|\.yaml)$", |         r"^swagger(?P<format>\.json|\.yaml)$", | ||||||
|         SchemaView.without_ui(cache_timeout=0), |         SchemaView.without_ui(cache_timeout=0), | ||||||
|         name="schema-json", |         name="schema-json", | ||||||
|  | |||||||
| @ -20,8 +20,12 @@ | |||||||
|                 </button> |                 </button> | ||||||
|             </div> |             </div> | ||||||
|             <a class="pf-c-page__header-brand-link"> |             <a class="pf-c-page__header-brand-link"> | ||||||
|                 <img class="pf-c-brand" src="{% static 'passbook/logo.png' %}" alt="" /> |                 <div class="pf-c-brand pb-brand"> | ||||||
|                 <img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" alt="passbook" /> |                     <img src="{{ config.passbook.branding.logo }}" alt="passbook icon"> | ||||||
|  |                     {% if config.passbook.branding.title_show %} | ||||||
|  |                     <small><small>{{ config.passbook.branding.title }}</small></small> | ||||||
|  |                     {% endif %} | ||||||
|  |                 </div> | ||||||
|             </a> |             </a> | ||||||
|         </div> |         </div> | ||||||
|         <div class="pf-c-page__header-nav"> |         <div class="pf-c-page__header-nav"> | ||||||
|  | |||||||
| @ -6,15 +6,17 @@ | |||||||
|  |  | ||||||
| <html lang="en"> | <html lang="en"> | ||||||
|     <head> |     <head> | ||||||
|  |         <link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff2' %}" as="font" type="font/woff2"> | ||||||
|  |         <link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff' %}" as="font" type="font/woff"> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|         <title>{% block title %}{% trans title|default:"passbook" %}{% endblock %}</title> |         <title>{% block title %}{% trans title|default:config.passbook.branding.title %}{% endblock %}</title> | ||||||
|         <link rel="icon" type="image/png" href="{% static 'passbook/logo.png' %}"> |         <link rel="icon" type="image/png" href="{% static 'passbook/logo.png' %}"> | ||||||
|         <link rel="shortcut icon" type="image/png" href="{% static 'passbook/logo.png' %}"> |         <link rel="shortcut icon" type="image/png" href="{% static 'passbook/logo.png' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly-addons.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly-addons.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css' %}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'passbook/pf.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'passbook/passbook.css' %}"> | ||||||
|         {% block head %} |         {% block head %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|     </head> |     </head> | ||||||
| @ -35,6 +37,6 @@ | |||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         {% block scripts %} |         {% block scripts %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <script src="{% static 'passbook/pf.js' %}"></script> |         <script src="{% static 'passbook/passbook.js' %}"></script> | ||||||
|     </body> |     </body> | ||||||
| </html> | </html> | ||||||
|  | |||||||
| @ -22,8 +22,12 @@ | |||||||
| <div class="pf-c-login"> | <div class="pf-c-login"> | ||||||
|     <div class="pf-c-login__container"> |     <div class="pf-c-login__container"> | ||||||
|         <header class="pf-c-login__header"> |         <header class="pf-c-login__header"> | ||||||
|             <img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" /> |             <div class="pf-c-brand pb-brand"> | ||||||
|             <img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" /> |                 <img src="{{ config.passbook.branding.logo }}" alt="passbook icon" /> | ||||||
|  |                 {% if config.passbook.branding.title_show %} | ||||||
|  |                 <p>{{ config.passbook.branding.title }}</p> | ||||||
|  |                 {% endif %} | ||||||
|  |             </div> | ||||||
|         </header> |         </header> | ||||||
|         {% block main_container %} |         {% block main_container %} | ||||||
|         <main class="pf-c-login__main"> |         <main class="pf-c-login__main"> | ||||||
| @ -47,6 +51,13 @@ | |||||||
|                     <a href="{{ link.href }}">{{ link.name }}</a> |                     <a href="{{ link.href }}">{{ link.name }}</a> | ||||||
|                 </li> |                 </li> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|  |                 {% if config.passbook.branding.title != "passbook" %} | ||||||
|  |                 <li> | ||||||
|  |                     <a href="https://github.com/beryju/passbook"> | ||||||
|  |                         {% trans 'Powered by passbook' %} | ||||||
|  |                     </a> | ||||||
|  |                 </li> | ||||||
|  |                 {% endif %} | ||||||
|             </ul> |             </ul> | ||||||
|         </footer> |         </footer> | ||||||
|     </div> |     </div> | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """passbook core utils view""" | """passbook core utils view""" | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import TemplateView | from django.views.generic import TemplateView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -52,7 +52,7 @@ def create_default_source_enrollment_flow( | |||||||
|  |  | ||||||
|     # PromptStage to ask user for their username |     # PromptStage to ask user for their username | ||||||
|     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( |     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( | ||||||
|         name="default-source-enrollment-username-prompt", |         name="Welcome to passbook! Please select a username.", | ||||||
|     ) |     ) | ||||||
|     prompt, _ = Prompt.objects.using(db_alias).update_or_create( |     prompt, _ = Prompt.objects.using(db_alias).update_or_create( | ||||||
|         field_key="username", |         field_key="username", | ||||||
|  | |||||||
| @ -115,11 +115,12 @@ const updateFormAction = (form) => { | |||||||
|     for (let index = 0; index < form.elements.length; index++) { |     for (let index = 0; index < form.elements.length; index++) { | ||||||
|         const element = form.elements[index]; |         const element = form.elements[index]; | ||||||
|         if (element.value === form.action) { |         if (element.value === form.action) { | ||||||
|             console.log("Found Form action URL in form elements, not changing form action."); |             console.log("pb-flow: Found Form action URL in form elements, not changing form action."); | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     form.action = flowBodyUrl; |     form.action = flowBodyUrl; | ||||||
|  |     console.log(`pb-flow: updated form.action ${flowBodyUrl}`); | ||||||
|     return true; |     return true; | ||||||
| }; | }; | ||||||
| const checkAutosubmit = (form) => { | const checkAutosubmit = (form) => { | ||||||
| @ -129,11 +130,11 @@ const checkAutosubmit = (form) => { | |||||||
| }; | }; | ||||||
| const setFormSubmitHandlers = () => { | const setFormSubmitHandlers = () => { | ||||||
|     document.querySelectorAll("#flow-body form").forEach(form => { |     document.querySelectorAll("#flow-body form").forEach(form => { | ||||||
|         console.log(`Checking for autosubmit attribute ${form}`); |         console.log(`pb-flow: Checking for autosubmit attribute ${form}`); | ||||||
|         checkAutosubmit(form); |         checkAutosubmit(form); | ||||||
|         console.log(`Setting action for form ${form}`); |         console.log(`pb-flow: Setting action for form ${form}`); | ||||||
|         updateFormAction(form); |         updateFormAction(form); | ||||||
|         console.log(`Adding handler for form ${form}`); |         console.log(`pb-flow: Adding handler for form ${form}`); | ||||||
|         form.addEventListener('submit', (e) => { |         form.addEventListener('submit', (e) => { | ||||||
|             e.preventDefault(); |             e.preventDefault(); | ||||||
|             let formData = new FormData(form); |             let formData = new FormData(form); | ||||||
| @ -145,6 +146,7 @@ const setFormSubmitHandlers = () => { | |||||||
|                 updateCard(data); |                 updateCard(data); | ||||||
|             }); |             }); | ||||||
|         }); |         }); | ||||||
|  |         form.classList.add("pb-flow-wrapped"); | ||||||
|     }); |     }); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ from unittest.mock import MagicMock, PropertyMock, patch | |||||||
|  |  | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from passbook.flows.markers import ReevaluateMarker, StageMarker | from passbook.flows.markers import ReevaluateMarker, StageMarker | ||||||
| @ -247,7 +247,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -293,7 +293,7 @@ class TestFlowExecutor(TestCase): | |||||||
|             # First request, run the planner |             # First request, run the planner | ||||||
|             response = self.client.get(exec_url) |             response = self.client.get(exec_url) | ||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             self.assertIn("dummy1", force_text(response.content)) |             self.assertIn("dummy1", force_str(response.content)) | ||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
| @ -316,13 +316,13 @@ class TestFlowExecutor(TestCase): | |||||||
|         # but it won't save it, hence we cant' check the plan |         # but it won't save it, hence we cant' check the plan | ||||||
|         response = self.client.get(exec_url) |         response = self.client.get(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertIn("dummy4", force_text(response.content)) |         self.assertIn("dummy4", force_str(response.content)) | ||||||
|  |  | ||||||
|         # fourth request, this confirms the last stage (dummy4) |         # fourth request, this confirms the last stage (dummy4) | ||||||
|         # We do this request without the patch, so the policy results in false |         # We do this request without the patch, so the policy results in false | ||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -21,6 +21,10 @@ error_reporting: | |||||||
|   send_pii: false |   send_pii: false | ||||||
|  |  | ||||||
| passbook: | passbook: | ||||||
|  |   branding: | ||||||
|  |     title: passbook | ||||||
|  |     title_show: true | ||||||
|  |     logo: /static/passbook/logo.svg | ||||||
|   # Optionally add links to the footer on the login page |   # Optionally add links to the footer on the login page | ||||||
|   footer_links: |   footer_links: | ||||||
|     - name: Documentation |     - name: Documentation | ||||||
|  | |||||||
| @ -1,5 +1,9 @@ | |||||||
| """Generic models""" | """Generic models""" | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | from django.core.validators import URLValidator | ||||||
| from django.db import models | from django.db import models | ||||||
|  | from django.utils.regex_helper import _lazy_re_compile | ||||||
| from model_utils.managers import InheritanceManager | from model_utils.managers import InheritanceManager | ||||||
| from rest_framework.serializers import BaseSerializer | from rest_framework.serializers import BaseSerializer | ||||||
|  |  | ||||||
| @ -48,3 +52,21 @@ class InheritanceForeignKey(models.ForeignKey): | |||||||
|     """Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor""" |     """Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor""" | ||||||
|  |  | ||||||
|     forward_related_accessor_class = InheritanceForwardManyToOneDescriptor |     forward_related_accessor_class = InheritanceForwardManyToOneDescriptor | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DomainlessURLValidator(URLValidator): | ||||||
|  |     """Subclass of URLValidator which doesn't check the domain | ||||||
|  |     (to allow hostnames without domain)""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs) -> None: | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self.host_re = "(" + self.hostname_re + self.domain_re + "|localhost)" | ||||||
|  |         self.regex = _lazy_re_compile( | ||||||
|  |             r"^(?:[a-z0-9.+-]*)://"  # scheme is validated separately | ||||||
|  |             r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?"  # user:pass authentication | ||||||
|  |             r"(?:" + self.ipv4_re + "|" + self.ipv6_re + "|" + self.host_re + ")" | ||||||
|  |             r"(?::\d{2,5})?"  # port | ||||||
|  |             r"(?:[/?#][^\s]*)?"  # resource path | ||||||
|  |             r"\Z", | ||||||
|  |             re.IGNORECASE, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """Kubernetes deployment controller""" | """Kubernetes deployment controller""" | ||||||
|  | from base64 import b64encode | ||||||
| from io import StringIO | from io import StringIO | ||||||
|  |  | ||||||
| from kubernetes.client import ( | from kubernetes.client import ( | ||||||
| @ -24,6 +25,11 @@ from passbook import __version__ | |||||||
| from passbook.outposts.controllers.base import BaseController | from passbook.outposts.controllers.base import BaseController | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def b64encode_str(input_string: str) -> str: | ||||||
|  |     """base64 encode string""" | ||||||
|  |     return b64encode(input_string.encode()).decode() | ||||||
|  |  | ||||||
|  |  | ||||||
| class KubernetesController(BaseController): | class KubernetesController(BaseController): | ||||||
|     """Manage deployment of outpost in kubernetes""" |     """Manage deployment of outpost in kubernetes""" | ||||||
|  |  | ||||||
| @ -37,9 +43,9 @@ class KubernetesController(BaseController): | |||||||
|         with StringIO() as _str: |         with StringIO() as _str: | ||||||
|             dump_all( |             dump_all( | ||||||
|                 [ |                 [ | ||||||
|                     self.get_deployment_secret(), |                     self.get_deployment_secret().to_dict(), | ||||||
|                     self.get_deployment(), |                     self.get_deployment().to_dict(), | ||||||
|                     self.get_service(), |                     self.get_service().to_dict(), | ||||||
|                 ], |                 ], | ||||||
|                 stream=_str, |                 stream=_str, | ||||||
|                 default_flow_style=False, |                 default_flow_style=False, | ||||||
| @ -63,15 +69,18 @@ class KubernetesController(BaseController): | |||||||
|     def get_deployment_secret(self) -> V1Secret: |     def get_deployment_secret(self) -> V1Secret: | ||||||
|         """Get secret with token and passbook host""" |         """Get secret with token and passbook host""" | ||||||
|         return V1Secret( |         return V1Secret( | ||||||
|  |             api_version="v1", | ||||||
|  |             kind="secret", | ||||||
|  |             type="Opaque", | ||||||
|             metadata=self.get_object_meta( |             metadata=self.get_object_meta( | ||||||
|                 name=f"passbook-outpost-{self.outpost.name}-api" |                 name=f"passbook-outpost-{self.outpost.name}-api" | ||||||
|             ), |             ), | ||||||
|             data={ |             data={ | ||||||
|                 "passbook_host": self.outpost.config.passbook_host, |                 "passbook_host": b64encode_str(self.outpost.config.passbook_host), | ||||||
|                 "passbook_host_insecure": str( |                 "passbook_host_insecure": b64encode_str( | ||||||
|                     self.outpost.config.passbook_host_insecure |                     str(self.outpost.config.passbook_host_insecure) | ||||||
|                 ), |                 ), | ||||||
|                 "token": self.outpost.token.token_uuid.hex, |                 "token": b64encode_str(self.outpost.token.token_uuid.hex), | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -82,6 +91,8 @@ class KubernetesController(BaseController): | |||||||
|         for port_name, port in self.deployment_ports.items(): |         for port_name, port in self.deployment_ports.items(): | ||||||
|             ports.append(V1ServicePort(name=port_name, port=port)) |             ports.append(V1ServicePort(name=port_name, port=port)) | ||||||
|         return V1Service( |         return V1Service( | ||||||
|  |             api_version="v1", | ||||||
|  |             kind="service", | ||||||
|             metadata=meta, |             metadata=meta, | ||||||
|             spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"), |             spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"), | ||||||
|         ) |         ) | ||||||
| @ -94,6 +105,8 @@ class KubernetesController(BaseController): | |||||||
|             container_ports.append(V1ContainerPort(container_port=port, name=port_name)) |             container_ports.append(V1ContainerPort(container_port=port, name=port_name)) | ||||||
|         meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}") |         meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}") | ||||||
|         return V1Deployment( |         return V1Deployment( | ||||||
|  |             api_version="apps/v1", | ||||||
|  |             kind="deployment", | ||||||
|             metadata=meta, |             metadata=meta, | ||||||
|             spec=V1DeploymentSpec( |             spec=V1DeploymentSpec( | ||||||
|                 replicas=1, |                 replicas=1, | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """Outpost models""" | """Outpost models""" | ||||||
| from dataclasses import asdict, dataclass | from dataclasses import asdict, dataclass | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from json import dumps, loads |  | ||||||
| from typing import Iterable, Optional | from typing import Iterable, Optional | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| @ -9,6 +8,7 @@ from dacite import from_dict | |||||||
| from django.contrib.postgres.fields import ArrayField | from django.contrib.postgres.fields import ArrayField | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db import models | from django.db import models | ||||||
|  | from django.db.models.base import Model | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from guardian.shortcuts import assign_perm | from guardian.shortcuts import assign_perm | ||||||
|  |  | ||||||
| @ -30,13 +30,17 @@ class OutpostConfig: | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostModel: | class OutpostModel(Model): | ||||||
|     """Base model for providers that need more objects than just themselves""" |     """Base model for providers that need more objects than just themselves""" | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[models.Model]: |     def get_required_objects(self) -> Iterable[models.Model]: | ||||||
|         """Return a list of all required objects""" |         """Return a list of all required objects""" | ||||||
|         return [self] |         return [self] | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         abstract = True | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostType(models.TextChoices): | class OutpostType(models.TextChoices): | ||||||
|     """Outpost types, currently only the reverse proxy is available""" |     """Outpost types, currently only the reverse proxy is available""" | ||||||
| @ -79,12 +83,12 @@ class Outpost(models.Model): | |||||||
|     @property |     @property | ||||||
|     def config(self) -> OutpostConfig: |     def config(self) -> OutpostConfig: | ||||||
|         """Load config as OutpostConfig object""" |         """Load config as OutpostConfig object""" | ||||||
|         return from_dict(OutpostConfig, loads(self._config)) |         return from_dict(OutpostConfig, self._config) | ||||||
|  |  | ||||||
|     @config.setter |     @config.setter | ||||||
|     def config(self, value): |     def config(self, value): | ||||||
|         """Dump config into json""" |         """Dump config into json""" | ||||||
|         self._config = dumps(asdict(value)) |         self._config = asdict(value) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def health_cache_key(self) -> str: |     def health_cache_key(self) -> str: | ||||||
|  | |||||||
| @ -1,31 +1,31 @@ | |||||||
| """passbook outpost signals""" | """passbook outpost signals""" | ||||||
| from asgiref.sync import async_to_sync |  | ||||||
| from channels.layers import get_channel_layer |  | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| 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 structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from passbook.lib.utils.reflection import class_to_path | ||||||
| from passbook.outposts.models import Outpost, OutpostModel | from passbook.outposts.models import Outpost, OutpostModel | ||||||
|  | from passbook.outposts.tasks import outpost_send_update | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save, sender=Outpost) | @receiver(post_save, sender=Outpost) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def ensure_user_and_token(sender, instance, **_): | def ensure_user_and_token(sender, instance: Model, **_): | ||||||
|     """Ensure that token is created/updated on save""" |     """Ensure that token is created/updated on save""" | ||||||
|     _ = instance.token |     _ = instance.token | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def post_save_update(sender, instance, **_): | def post_save_update(sender, instance: Model, **_): | ||||||
|     """If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, |     """If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, | ||||||
|     we send a message down the relevant OutpostModels WS connection to trigger an update""" |     we send a message down the relevant OutpostModels WS connection to trigger an update""" | ||||||
|     if isinstance(instance, OutpostModel): |     if isinstance(instance, OutpostModel): | ||||||
|         LOGGER.debug("triggering outpost update from outpostmodel", instance=instance) |         LOGGER.debug("triggering outpost update from outpostmodel", instance=instance) | ||||||
|         _send_update(instance) |         outpost_send_update.delay(class_to_path(instance.__class__), instance.pk) | ||||||
|         return |         return | ||||||
|  |  | ||||||
|     for field in instance._meta.get_fields(): |     for field in instance._meta.get_fields(): | ||||||
| @ -46,13 +46,4 @@ def post_save_update(sender, instance, **_): | |||||||
|         # Because the Outpost Model has an M2M to Provider, |         # Because the Outpost Model has an M2M to Provider, | ||||||
|         # we have to iterate over the entire QS |         # we have to iterate over the entire QS | ||||||
|         for reverse in getattr(instance, field_name).all(): |         for reverse in getattr(instance, field_name).all(): | ||||||
|             _send_update(reverse) |             outpost_send_update(class_to_path(reverse.__class__), reverse.pk) | ||||||
|  |  | ||||||
|  |  | ||||||
| def _send_update(outpost_model: Model): |  | ||||||
|     """Send update trigger for each channel of an outpost model""" |  | ||||||
|     for outpost in outpost_model.outpost_set.all(): |  | ||||||
|         channel_layer = get_channel_layer() |  | ||||||
|         for channel in outpost.channels: |  | ||||||
|             LOGGER.debug("sending update", channel=channel) |  | ||||||
|             async_to_sync(channel_layer.send)(channel, {"type": "event.update"}) |  | ||||||
|  | |||||||
| @ -1,8 +1,22 @@ | |||||||
| """outpost tasks""" | """outpost tasks""" | ||||||
| from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType | from typing import Any | ||||||
|  |  | ||||||
|  | from asgiref.sync import async_to_sync | ||||||
|  | from channels.layers import get_channel_layer | ||||||
|  | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from passbook.lib.utils.reflection import path_to_class | ||||||
|  | from passbook.outposts.models import ( | ||||||
|  |     Outpost, | ||||||
|  |     OutpostDeploymentType, | ||||||
|  |     OutpostModel, | ||||||
|  |     OutpostType, | ||||||
|  | ) | ||||||
| from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController | from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController | ||||||
| from passbook.root.celery import CELERY_APP | from passbook.root.celery import CELERY_APP | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True) | @CELERY_APP.task(bind=True) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| @ -20,3 +34,16 @@ def outpost_k8s_controller_single(self, outpost: str, outpost_type: str): | |||||||
|     """Launch Kubernetes manager and reconcile deployment/service/etc""" |     """Launch Kubernetes manager and reconcile deployment/service/etc""" | ||||||
|     if outpost_type == OutpostType.PROXY: |     if outpost_type == OutpostType.PROXY: | ||||||
|         ProxyKubernetesController(outpost).run() |         ProxyKubernetesController(outpost).run() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @CELERY_APP.task() | ||||||
|  | def outpost_send_update(model_class: str, model_pk: Any): | ||||||
|  |     """Send outpost update to all registered outposts, irregardless to which passbook | ||||||
|  |     instance they are connected""" | ||||||
|  |     model = path_to_class(model_class) | ||||||
|  |     outpost_model: OutpostModel = model.objects.get(model_pk) | ||||||
|  |     for outpost in outpost_model.outpost_set.all(): | ||||||
|  |         channel_layer = get_channel_layer() | ||||||
|  |         for channel in outpost.channels: | ||||||
|  |             LOGGER.debug("sending update", channel=channel) | ||||||
|  |             async_to_sync(channel_layer.send)(channel, {"type": "event.update"}) | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ from django.forms import ModelForm | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.utils import dateformat, timezone | from django.utils import dateformat, timezone | ||||||
| from django.utils.translation import ugettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key | from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key | ||||||
| from jwkest.jws import JWS | from jwkest.jws import JWS | ||||||
|  |  | ||||||
|  | |||||||
| @ -82,7 +82,7 @@ def extract_client_auth(request: HttpRequest) -> Tuple[str, str]: | |||||||
|         b64_user_pass = auth_header.split()[1] |         b64_user_pass = auth_header.split()[1] | ||||||
|         try: |         try: | ||||||
|             user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") |             user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") | ||||||
|             client_id, client_secret = tuple(user_pass) |             client_id, client_secret = user_pass | ||||||
|         except (ValueError, Error): |         except (ValueError, Error): | ||||||
|             client_id = client_secret = "" |             client_id = client_secret = "" | ||||||
|     else: |     else: | ||||||
|  | |||||||
| @ -93,9 +93,9 @@ class OAuthAuthorizationParams: | |||||||
|         if response_type in [ResponseTypes.CODE]: |         if response_type in [ResponseTypes.CODE]: | ||||||
|             grant_type = GrantTypes.AUTHORIZATION_CODE |             grant_type = GrantTypes.AUTHORIZATION_CODE | ||||||
|         elif response_type in [ |         elif response_type in [ | ||||||
|             ResponseTypes.id_token, |             ResponseTypes.ID_TOKEN, | ||||||
|             ResponseTypes.id_token_token, |             ResponseTypes.ID_TOKEN_TOKEN, | ||||||
|             ResponseTypes.token, |             ResponseTypes.TOKEN, | ||||||
|         ]: |         ]: | ||||||
|             grant_type = GrantTypes.IMPLICIT |             grant_type = GrantTypes.IMPLICIT | ||||||
|         elif response_type in [ |         elif response_type in [ | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from typing import Any, Dict, List | from typing import Any, Dict, List | ||||||
|  |  | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.utils.translation import ugettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from django.views import View | from django.views import View | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | |||||||
| @ -0,0 +1,37 @@ | |||||||
|  | # Generated by Django 3.1.1 on 2020-09-13 19:47 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  | import passbook.lib.models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("passbook_providers_proxy", "0003_proxyprovider_certificate"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="proxyprovider", | ||||||
|  |             name="external_host", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 validators=[ | ||||||
|  |                     passbook.lib.models.DomainlessURLValidator( | ||||||
|  |                         schemes=("http", "https") | ||||||
|  |                     ) | ||||||
|  |                 ] | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="proxyprovider", | ||||||
|  |             name="internal_host", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 validators=[ | ||||||
|  |                     passbook.lib.models.DomainlessURLValidator( | ||||||
|  |                         schemes=("http", "https") | ||||||
|  |                     ) | ||||||
|  |                 ] | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -4,12 +4,12 @@ from random import SystemRandom | |||||||
| from typing import Iterable, Type | from typing import Iterable, Type | ||||||
| from urllib.parse import urljoin | from urllib.parse import urljoin | ||||||
|  |  | ||||||
| from django.core.validators import URLValidator |  | ||||||
| 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 as _ | from django.utils.translation import gettext as _ | ||||||
|  |  | ||||||
| from passbook.crypto.models import CertificateKeyPair | from passbook.crypto.models import CertificateKeyPair | ||||||
|  | from passbook.lib.models import DomainlessURLValidator | ||||||
| from passbook.outposts.models import OutpostModel | from passbook.outposts.models import OutpostModel | ||||||
| from passbook.providers.oauth2.constants import ( | from passbook.providers.oauth2.constants import ( | ||||||
|     SCOPE_OPENID, |     SCOPE_OPENID, | ||||||
| @ -41,10 +41,10 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | |||||||
|     Protocols by using a Reverse-Proxy.""" |     Protocols by using a Reverse-Proxy.""" | ||||||
|  |  | ||||||
|     internal_host = models.TextField( |     internal_host = models.TextField( | ||||||
|         validators=[URLValidator(schemes=("http", "https"))] |         validators=[DomainlessURLValidator(schemes=("http", "https"))] | ||||||
|     ) |     ) | ||||||
|     external_host = models.TextField( |     external_host = models.TextField( | ||||||
|         validators=[URLValidator(schemes=("http", "https"))] |         validators=[DomainlessURLValidator(schemes=("http", "https"))] | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     cookie_secret = models.TextField(default=get_cookie_secret) |     cookie_secret = models.TextField(default=get_cookie_secret) | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.db import models | |||||||
| from django.forms import ModelForm | from django.forms import ModelForm | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.utils.translation import ugettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
| from passbook.core.models import PropertyMapping, Provider | from passbook.core.models import PropertyMapping, Provider | ||||||
|  | |||||||
| @ -1,13 +1,22 @@ | |||||||
| """Test AuthN Request generator and parser""" | """Test AuthN Request generator and parser""" | ||||||
|  | from django.contrib.sessions.middleware import SessionMiddleware | ||||||
|  | from django.http.request import QueryDict | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
|  | from guardian.utils import get_anonymous_user | ||||||
|  |  | ||||||
| from passbook.crypto.models import CertificateKeyPair | from passbook.crypto.models import CertificateKeyPair | ||||||
| from passbook.flows.models import Flow | from passbook.flows.models import Flow | ||||||
| from passbook.providers.saml.models import SAMLProvider | from passbook.providers.saml.models import SAMLProvider | ||||||
|  | from passbook.providers.saml.processors.assertion import AssertionProcessor | ||||||
| from passbook.providers.saml.processors.request_parser import AuthNRequestParser | from passbook.providers.saml.processors.request_parser import AuthNRequestParser | ||||||
| from passbook.providers.saml.utils.encoding import deflate_and_base64_encode | from passbook.providers.saml.utils.encoding import deflate_and_base64_encode | ||||||
|  | from passbook.sources.saml.exceptions import MismatchedRequestID | ||||||
| from passbook.sources.saml.models import SAMLSource | from passbook.sources.saml.models import SAMLSource | ||||||
| from passbook.sources.saml.processors.request import RequestProcessor | from passbook.sources.saml.processors.request import ( | ||||||
|  |     SESSION_REQUEST_ID, | ||||||
|  |     RequestProcessor, | ||||||
|  | ) | ||||||
|  | from passbook.sources.saml.processors.response import ResponseProcessor | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestAuthNRequest(TestCase): | class TestAuthNRequest(TestCase): | ||||||
| @ -31,6 +40,11 @@ class TestAuthNRequest(TestCase): | |||||||
|     def test_signed_valid(self): |     def test_signed_valid(self): | ||||||
|         """Test generated AuthNRequest with valid signature""" |         """Test generated AuthNRequest with valid signature""" | ||||||
|         http_request = self.factory.get("/") |         http_request = self.factory.get("/") | ||||||
|  |  | ||||||
|  |         middleware = SessionMiddleware() | ||||||
|  |         middleware.process_request(http_request) | ||||||
|  |         http_request.session.save() | ||||||
|  |  | ||||||
|         # First create an AuthNRequest |         # First create an AuthNRequest | ||||||
|         request_proc = RequestProcessor(self.source, http_request, "test_state") |         request_proc = RequestProcessor(self.source, http_request, "test_state") | ||||||
|         request = request_proc.build_auth_n() |         request = request_proc.build_auth_n() | ||||||
| @ -44,6 +58,11 @@ class TestAuthNRequest(TestCase): | |||||||
|     def test_signed_valid_detached(self): |     def test_signed_valid_detached(self): | ||||||
|         """Test generated AuthNRequest with valid signature (detached)""" |         """Test generated AuthNRequest with valid signature (detached)""" | ||||||
|         http_request = self.factory.get("/") |         http_request = self.factory.get("/") | ||||||
|  |  | ||||||
|  |         middleware = SessionMiddleware() | ||||||
|  |         middleware.process_request(http_request) | ||||||
|  |         http_request.session.save() | ||||||
|  |  | ||||||
|         # First create an AuthNRequest |         # First create an AuthNRequest | ||||||
|         request_proc = RequestProcessor(self.source, http_request, "test_state") |         request_proc = RequestProcessor(self.source, http_request, "test_state") | ||||||
|         params = request_proc.build_auth_n_detached() |         params = request_proc.build_auth_n_detached() | ||||||
| @ -53,3 +72,37 @@ class TestAuthNRequest(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(parsed_request.id, request_proc.request_id) |         self.assertEqual(parsed_request.id, request_proc.request_id) | ||||||
|         self.assertEqual(parsed_request.relay_state, "test_state") |         self.assertEqual(parsed_request.relay_state, "test_state") | ||||||
|  |  | ||||||
|  |     def test_request_id_invalid(self): | ||||||
|  |         """Test generated AuthNRequest with invalid request ID""" | ||||||
|  |         http_request = self.factory.get("/") | ||||||
|  |         http_request.user = get_anonymous_user() | ||||||
|  |  | ||||||
|  |         middleware = SessionMiddleware() | ||||||
|  |         middleware.process_request(http_request) | ||||||
|  |         http_request.session.save() | ||||||
|  |  | ||||||
|  |         # First create an AuthNRequest | ||||||
|  |         request_proc = RequestProcessor(self.source, http_request, "test_state") | ||||||
|  |         request = request_proc.build_auth_n() | ||||||
|  |  | ||||||
|  |         # change the request ID | ||||||
|  |         http_request.session[SESSION_REQUEST_ID] = "test" | ||||||
|  |         http_request.session.save() | ||||||
|  |  | ||||||
|  |         # To get an assertion we need a parsed request (parsed by provider) | ||||||
|  |         parsed_request = AuthNRequestParser(self.provider).parse( | ||||||
|  |             deflate_and_base64_encode(request), "test_state" | ||||||
|  |         ) | ||||||
|  |         # Now create a response and convert it to string (provider) | ||||||
|  |         response_proc = AssertionProcessor(self.provider, http_request, parsed_request) | ||||||
|  |         response = response_proc.build_response() | ||||||
|  |  | ||||||
|  |         # Now parse the response (source) | ||||||
|  |         http_request.POST = QueryDict(mutable=True) | ||||||
|  |         http_request.POST["SAMLResponse"] = deflate_and_base64_encode(response) | ||||||
|  |  | ||||||
|  |         response_parser = ResponseProcessor(self.source) | ||||||
|  |  | ||||||
|  |         with self.assertRaises(MismatchedRequestID): | ||||||
|  |             response_parser.parse(http_request) | ||||||
|  | |||||||
| @ -32,6 +32,11 @@ Send = typing.Callable[[Message], typing.Awaitable[None]] | |||||||
|  |  | ||||||
| ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]] | ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]] | ||||||
|  |  | ||||||
|  | ASGI_IP_HEADERS = ( | ||||||
|  |     b"x-forwarded-for", | ||||||
|  |     b"x-real-ip", | ||||||
|  | ) | ||||||
|  |  | ||||||
| LOGGER = get_logger("passbook.asgi") | LOGGER = get_logger("passbook.asgi") | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -51,7 +56,6 @@ class ASGILogger: | |||||||
|     """ASGI Logger, instantiated for each request""" |     """ASGI Logger, instantiated for each request""" | ||||||
|  |  | ||||||
|     app: ASGIApp |     app: ASGIApp | ||||||
|     send: Send |  | ||||||
|  |  | ||||||
|     scope: Scope |     scope: Scope | ||||||
|     headers: Dict[ByteString, Any] |     headers: Dict[ByteString, Any] | ||||||
| @ -64,11 +68,26 @@ class ASGILogger: | |||||||
|         self.app = app |         self.app = app | ||||||
|  |  | ||||||
|     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: |     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: | ||||||
|         self.send = send |  | ||||||
|         self.scope = scope |         self.scope = scope | ||||||
|         self.content_length = 0 |         self.content_length = 0 | ||||||
|         self.headers = dict(scope.get("headers", [])) |         self.headers = dict(scope.get("headers", [])) | ||||||
|  |  | ||||||
|  |         async def send_hooked(message: Message) -> None: | ||||||
|  |             """Hooked send method, which records status code and content-length, and for the final | ||||||
|  |             requests logs it""" | ||||||
|  |             headers = dict(message.get("headers", [])) | ||||||
|  |  | ||||||
|  |             if "status" in message: | ||||||
|  |                 self.status_code = message["status"] | ||||||
|  |  | ||||||
|  |             if b"Content-Length" in headers: | ||||||
|  |                 self.content_length += int(headers.get(b"Content-Length", b"0")) | ||||||
|  |  | ||||||
|  |             if message["type"] == "http.response.body" and not message["more_body"]: | ||||||
|  |                 runtime = int((time() - self.start) * 10 ** 6) | ||||||
|  |                 self.log(runtime) | ||||||
|  |             await send(message) | ||||||
|  |  | ||||||
|         if self.headers.get(b"host", b"") == b"kubernetes-healthcheck-host": |         if self.headers.get(b"host", b"") == b"kubernetes-healthcheck-host": | ||||||
|             # Don't log kubernetes health/readiness requests |             # Don't log kubernetes health/readiness requests | ||||||
|             await send({"type": "http.response.start", "status": 204, "headers": []}) |             await send({"type": "http.response.start", "status": 204, "headers": []}) | ||||||
| @ -80,25 +99,12 @@ class ASGILogger: | |||||||
|             # https://code.djangoproject.com/ticket/31508 |             # https://code.djangoproject.com/ticket/31508 | ||||||
|             # https://github.com/encode/uvicorn/issues/266 |             # https://github.com/encode/uvicorn/issues/266 | ||||||
|             return |             return | ||||||
|         await self.app(scope, receive, self.send_hooked) |         await self.app(scope, receive, send_hooked) | ||||||
|  |  | ||||||
|     async def send_hooked(self, message: Message) -> None: |  | ||||||
|         """Hooked send method, which records status code and content-length, and for the final |  | ||||||
|         requests logs it""" |  | ||||||
|         headers = dict(message.get("headers", [])) |  | ||||||
|  |  | ||||||
|         if "status" in message: |  | ||||||
|             self.status_code = message["status"] |  | ||||||
|  |  | ||||||
|         if b"Content-Length" in headers: |  | ||||||
|             self.content_length += int(headers.get(b"Content-Length", b"0")) |  | ||||||
|  |  | ||||||
|         if message["type"] == "http.response.body" and not message["more_body"]: |  | ||||||
|             runtime = int((time() - self.start) * 10 ** 6) |  | ||||||
|             self.log(runtime) |  | ||||||
|         return await self.send(message) |  | ||||||
|  |  | ||||||
|     def _get_ip(self) -> str: |     def _get_ip(self) -> str: | ||||||
|  |         for header in ASGI_IP_HEADERS: | ||||||
|  |             if header in self.headers: | ||||||
|  |                 return self.headers[header].decode() | ||||||
|         client_ip, _ = self.scope.get("client", ("", 0)) |         client_ip, _ = self.scope.get("client", ("", 0)) | ||||||
|         return client_ip |         return client_ip | ||||||
|  |  | ||||||
| @ -119,6 +125,6 @@ class ASGILogger: | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| application = SentryAsgiMiddleware( | application = ASGILogger( | ||||||
|     ASGILogger(guarantee_single_callable(get_default_application())) |     guarantee_single_callable(SentryAsgiMiddleware(get_default_application())) | ||||||
| ) | ) | ||||||
|  | |||||||
| @ -12,7 +12,6 @@ https://docs.djangoproject.com/en/2.1/ref/settings/ | |||||||
|  |  | ||||||
| import importlib | import importlib | ||||||
| import os | import os | ||||||
| import sys |  | ||||||
| from json import dumps | from json import dumps | ||||||
|  |  | ||||||
| import structlog | import structlog | ||||||
| @ -156,6 +155,7 @@ DJANGO_REDIS_IGNORE_EXCEPTIONS = True | |||||||
| DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True | DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True | ||||||
| SESSION_ENGINE = "django.contrib.sessions.backends.cache" | SESSION_ENGINE = "django.contrib.sessions.backends.cache" | ||||||
| SESSION_CACHE_ALIAS = "default" | SESSION_CACHE_ALIAS = "default" | ||||||
|  | SESSION_COOKIE_SAMESITE = "lax" | ||||||
|  |  | ||||||
| MIDDLEWARE = [ | MIDDLEWARE = [ | ||||||
|     "django_prometheus.middleware.PrometheusBeforeMiddleware", |     "django_prometheus.middleware.PrometheusBeforeMiddleware", | ||||||
| @ -372,15 +372,9 @@ LOGGING = { | |||||||
| } | } | ||||||
|  |  | ||||||
| TEST = False | TEST = False | ||||||
| TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" | TEST_RUNNER = "passbook.root.test_runner.PytestTestRunner" | ||||||
| LOG_LEVEL = CONFIG.y("log_level").upper() | LOG_LEVEL = CONFIG.y("log_level").upper() | ||||||
|  |  | ||||||
| TEST_OUTPUT_FILE_NAME = "unittest.xml" |  | ||||||
|  |  | ||||||
| if len(sys.argv) >= 2 and sys.argv[1] == "test": |  | ||||||
|     LOG_LEVEL = "DEBUG" |  | ||||||
|     TEST = True |  | ||||||
|     CELERY_TASK_ALWAYS_EAGER = True |  | ||||||
|  |  | ||||||
| _LOGGING_HANDLER_MAP = { | _LOGGING_HANDLER_MAP = { | ||||||
|     "": LOG_LEVEL, |     "": LOG_LEVEL, | ||||||
| @ -431,7 +425,6 @@ for _app in INSTALLED_APPS: | |||||||
|             pass |             pass | ||||||
|  |  | ||||||
| if DEBUG: | if DEBUG: | ||||||
|     SESSION_COOKIE_SAMESITE = None |  | ||||||
|     INSTALLED_APPS.append("debug_toolbar") |     INSTALLED_APPS.append("debug_toolbar") | ||||||
|     MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") |     MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										35
									
								
								passbook/root/test_runner.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								passbook/root/test_runner.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | """Integrate ./manage.py test with pytest""" | ||||||
|  | from django.conf import settings | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PytestTestRunner: | ||||||
|  |     """Runs pytest to discover and run tests.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, verbosity=1, failfast=False, keepdb=False, **_): | ||||||
|  |         self.verbosity = verbosity | ||||||
|  |         self.failfast = failfast | ||||||
|  |         self.keepdb = keepdb | ||||||
|  |         settings.TEST = True | ||||||
|  |         settings.CELERY_TASK_ALWAYS_EAGER = True | ||||||
|  |  | ||||||
|  |     def run_tests(self, test_labels): | ||||||
|  |         """Run pytest and return the exitcode. | ||||||
|  |  | ||||||
|  |         It translates some of Django's test command option to pytest's. | ||||||
|  |         """ | ||||||
|  |         import pytest | ||||||
|  |  | ||||||
|  |         argv = [] | ||||||
|  |         if self.verbosity == 0: | ||||||
|  |             argv.append("--quiet") | ||||||
|  |         if self.verbosity == 2: | ||||||
|  |             argv.append("--verbose") | ||||||
|  |         if self.verbosity == 3: | ||||||
|  |             argv.append("-vv") | ||||||
|  |         if self.failfast: | ||||||
|  |             argv.append("--exitfirst") | ||||||
|  |         if self.keepdb: | ||||||
|  |             argv.append("--reuse-db") | ||||||
|  |  | ||||||
|  |         argv.extend(test_labels) | ||||||
|  |         return pytest.main(argv) | ||||||
| @ -15,7 +15,7 @@ admin.site.login = RedirectView.as_view( | |||||||
|     pattern_name="passbook_flows:default-authentication" |     pattern_name="passbook_flows:default-authentication" | ||||||
| ) | ) | ||||||
| admin.site.logout = RedirectView.as_view( | admin.site.logout = RedirectView.as_view( | ||||||
|     pattern_name="passbook_flows:default-invalidate" |     pattern_name="passbook_flows:default-invalidation" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| handler400 = error.BadRequestView.as_view() | handler400 = error.BadRequestView.as_view() | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								passbook/sources/ldap/migrations/0005_auto_20200913_1947.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								passbook/sources/ldap/migrations/0005_auto_20200913_1947.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | # Generated by Django 3.1.1 on 2020-09-13 19:47 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  | import passbook.lib.models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("passbook_sources_ldap", "0004_auto_20200524_1146"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="ldapsource", | ||||||
|  |             name="server_uri", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 validators=[ | ||||||
|  |                     passbook.lib.models.DomainlessURLValidator( | ||||||
|  |                         schemes=["ldap", "ldaps"] | ||||||
|  |                     ) | ||||||
|  |                 ], | ||||||
|  |                 verbose_name="Server URI", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -1,20 +1,20 @@ | |||||||
| """passbook LDAP Models""" | """passbook LDAP Models""" | ||||||
| from typing import Optional, Type | from typing import Optional, Type | ||||||
|  |  | ||||||
| from django.core.validators import URLValidator |  | ||||||
| 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 Connection, Server | ||||||
|  |  | ||||||
| from passbook.core.models import Group, PropertyMapping, Source | from passbook.core.models import Group, PropertyMapping, Source | ||||||
|  | from passbook.lib.models import DomainlessURLValidator | ||||||
|  |  | ||||||
|  |  | ||||||
| class LDAPSource(Source): | class LDAPSource(Source): | ||||||
|     """Federate LDAP Directory with passbook, or create new accounts in LDAP.""" |     """Federate LDAP Directory with passbook, or create new accounts in LDAP.""" | ||||||
|  |  | ||||||
|     server_uri = models.TextField( |     server_uri = models.TextField( | ||||||
|         validators=[URLValidator(schemes=["ldap", "ldaps"])], |         validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])], | ||||||
|         verbose_name=_("Server URI"), |         verbose_name=_("Server URI"), | ||||||
|     ) |     ) | ||||||
|     bind_cn = models.TextField(verbose_name=_("Bind CN")) |     bind_cn = models.TextField(verbose_name=_("Bind CN")) | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from urllib.parse import parse_qs, urlencode | |||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.utils.crypto import constant_time_compare, get_random_string | from django.utils.crypto import constant_time_compare, get_random_string | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
| from requests import Session | from requests import Session | ||||||
| from requests.exceptions import RequestException | from requests.exceptions import RequestException | ||||||
| from requests_oauthlib import OAuth1 | from requests_oauthlib import OAuth1 | ||||||
| @ -111,7 +111,7 @@ class OAuthClient(BaseOAuthClient): | |||||||
|  |  | ||||||
|     def get_request_token(self, request, callback): |     def get_request_token(self, request, callback): | ||||||
|         "Fetch the OAuth request token. Only required for OAuth 1.0." |         "Fetch the OAuth request token. Only required for OAuth 1.0." | ||||||
|         callback = force_text(request.build_absolute_uri(callback)) |         callback = force_str(request.build_absolute_uri(callback)) | ||||||
|         try: |         try: | ||||||
|             response = self.session.request( |             response = self.session.request( | ||||||
|                 "post", |                 "post", | ||||||
| @ -128,7 +128,7 @@ class OAuthClient(BaseOAuthClient): | |||||||
|  |  | ||||||
|     def get_redirect_args(self, request, callback): |     def get_redirect_args(self, request, callback): | ||||||
|         "Get request parameters for redirect url." |         "Get request parameters for redirect url." | ||||||
|         callback = force_text(request.build_absolute_uri(callback)) |         callback = force_str(request.build_absolute_uri(callback)) | ||||||
|         raw_token = self.get_request_token(request, callback) |         raw_token = self.get_request_token(request, callback) | ||||||
|         token, secret = self.parse_raw_token(raw_token) |         token, secret = self.parse_raw_token(raw_token) | ||||||
|         if token is not None and secret is not None: |         if token is not None and secret is not None: | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from django.contrib import messages | |||||||
| from django.http import Http404, HttpRequest, HttpResponse | from django.http import Http404, HttpRequest, HttpResponse | ||||||
| from django.shortcuts import redirect | from django.shortcuts import redirect | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import View | from django.views.generic import View | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin | |||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.shortcuts import get_object_or_404, redirect, render | from django.shortcuts import get_object_or_404, redirect, render | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import ugettext as _ | from django.utils.translation import gettext as _ | ||||||
| from django.views.generic import TemplateView, View | from django.views.generic import TemplateView, View | ||||||
|  |  | ||||||
| from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | ||||||
|  | |||||||
| @ -15,9 +15,10 @@ class SAMLSourceSerializer(ModelSerializer): | |||||||
|         fields = SOURCE_FORM_FIELDS + [ |         fields = SOURCE_FORM_FIELDS + [ | ||||||
|             "issuer", |             "issuer", | ||||||
|             "sso_url", |             "sso_url", | ||||||
|  |             "slo_url", | ||||||
|  |             "allow_idp_initiated", | ||||||
|             "name_id_policy", |             "name_id_policy", | ||||||
|             "binding_type", |             "binding_type", | ||||||
|             "slo_url", |  | ||||||
|             "temporary_user_delete_after", |             "temporary_user_delete_after", | ||||||
|             "signing_kp", |             "signing_kp", | ||||||
|         ] |         ] | ||||||
|  | |||||||
| @ -8,3 +8,7 @@ class MissingSAMLResponse(SentryIgnoredException): | |||||||
|  |  | ||||||
| class UnsupportedNameIDFormat(SentryIgnoredException): | class UnsupportedNameIDFormat(SentryIgnoredException): | ||||||
|     """Exception raised when SAML Response contains NameID Format not supported.""" |     """Exception raised when SAML Response contains NameID Format not supported.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MismatchedRequestID(SentryIgnoredException): | ||||||
|  |     """Exception raised when the returned request ID doesn't match the saved ID.""" | ||||||
|  | |||||||
| @ -30,9 +30,10 @@ class SAMLSourceForm(forms.ModelForm): | |||||||
|         fields = SOURCE_FORM_FIELDS + [ |         fields = SOURCE_FORM_FIELDS + [ | ||||||
|             "issuer", |             "issuer", | ||||||
|             "sso_url", |             "sso_url", | ||||||
|             "name_id_policy", |  | ||||||
|             "binding_type", |  | ||||||
|             "slo_url", |             "slo_url", | ||||||
|  |             "binding_type", | ||||||
|  |             "name_id_policy", | ||||||
|  |             "allow_idp_initiated", | ||||||
|             "temporary_user_delete_after", |             "temporary_user_delete_after", | ||||||
|             "signing_kp", |             "signing_kp", | ||||||
|         ] |         ] | ||||||
|  | |||||||
| @ -0,0 +1,21 @@ | |||||||
|  | # Generated by Django 3.1.1 on 2020-09-11 22:14 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("passbook_sources_saml", "0005_samlsource_name_id_policy"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="samlsource", | ||||||
|  |             name="allow_idp_initiated", | ||||||
|  |             field=models.BooleanField( | ||||||
|  |                 default=False, | ||||||
|  |                 help_text="Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -53,6 +53,21 @@ class SAMLSource(Source): | |||||||
|         verbose_name=_("SSO URL"), |         verbose_name=_("SSO URL"), | ||||||
|         help_text=_("URL that the initial Login request is sent to."), |         help_text=_("URL that the initial Login request is sent to."), | ||||||
|     ) |     ) | ||||||
|  |     slo_url = models.URLField( | ||||||
|  |         default=None, | ||||||
|  |         blank=True, | ||||||
|  |         null=True, | ||||||
|  |         verbose_name=_("SLO URL"), | ||||||
|  |         help_text=_("Optional URL if your IDP supports Single-Logout."), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     allow_idp_initiated = models.BooleanField( | ||||||
|  |         default=False, | ||||||
|  |         help_text=_( | ||||||
|  |             "Allows authentication flows initiated by the IdP. This can be a security risk, " | ||||||
|  |             "as no validation of the request ID is done." | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|     name_id_policy = models.TextField( |     name_id_policy = models.TextField( | ||||||
|         choices=SAMLNameIDPolicy.choices, |         choices=SAMLNameIDPolicy.choices, | ||||||
|         default=SAMLNameIDPolicy.TRANSIENT, |         default=SAMLNameIDPolicy.TRANSIENT, | ||||||
| @ -66,14 +81,6 @@ class SAMLSource(Source): | |||||||
|         default=SAMLBindingTypes.Redirect, |         default=SAMLBindingTypes.Redirect, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     slo_url = models.URLField( |  | ||||||
|         default=None, |  | ||||||
|         blank=True, |  | ||||||
|         null=True, |  | ||||||
|         verbose_name=_("SLO URL"), |  | ||||||
|         help_text=_("Optional URL if your IDP supports Single-Logout."), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     temporary_user_delete_after = models.TextField( |     temporary_user_delete_after = models.TextField( | ||||||
|         default="days=1", |         default="days=1", | ||||||
|         verbose_name=_("Delete temporary users after"), |         verbose_name=_("Delete temporary users after"), | ||||||
|  | |||||||
| @ -20,6 +20,8 @@ from passbook.sources.saml.processors.constants import ( | |||||||
|     NS_SAML_PROTOCOL, |     NS_SAML_PROTOCOL, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | SESSION_REQUEST_ID = "passbook_source_saml_request_id" | ||||||
|  |  | ||||||
|  |  | ||||||
| class RequestProcessor: | class RequestProcessor: | ||||||
|     """SAML AuthnRequest Processor""" |     """SAML AuthnRequest Processor""" | ||||||
| @ -37,6 +39,7 @@ class RequestProcessor: | |||||||
|         self.http_request = request |         self.http_request = request | ||||||
|         self.relay_state = relay_state |         self.relay_state = relay_state | ||||||
|         self.request_id = get_random_id() |         self.request_id = get_random_id() | ||||||
|  |         self.http_request.session[SESSION_REQUEST_ID] = self.request_id | ||||||
|         self.issue_instant = get_time_string() |         self.issue_instant = get_time_string() | ||||||
|  |  | ||||||
|     def get_issuer(self) -> Element: |     def get_issuer(self) -> Element: | ||||||
|  | |||||||
| @ -2,6 +2,8 @@ | |||||||
| from typing import TYPE_CHECKING, Dict | from typing import TYPE_CHECKING, Dict | ||||||
|  |  | ||||||
| from defusedxml import ElementTree | from defusedxml import ElementTree | ||||||
|  | from django.core.cache import cache | ||||||
|  | from django.core.exceptions import SuspiciousOperation | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from signxml import XMLVerifier | from signxml import XMLVerifier | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
| @ -18,6 +20,7 @@ from passbook.lib.utils.urls import redirect_with_qs | |||||||
| from passbook.policies.utils import delete_none_keys | from passbook.policies.utils import delete_none_keys | ||||||
| from passbook.providers.saml.utils.encoding import decode_base64_and_inflate | from passbook.providers.saml.utils.encoding import decode_base64_and_inflate | ||||||
| from passbook.sources.saml.exceptions import ( | from passbook.sources.saml.exceptions import ( | ||||||
|  |     MismatchedRequestID, | ||||||
|     MissingSAMLResponse, |     MissingSAMLResponse, | ||||||
|     UnsupportedNameIDFormat, |     UnsupportedNameIDFormat, | ||||||
| ) | ) | ||||||
| @ -29,12 +32,15 @@ from passbook.sources.saml.processors.constants import ( | |||||||
|     SAML_NAME_ID_FORMAT_WINDOWS, |     SAML_NAME_ID_FORMAT_WINDOWS, | ||||||
|     SAML_NAME_ID_FORMAT_X509, |     SAML_NAME_ID_FORMAT_X509, | ||||||
| ) | ) | ||||||
|  | from passbook.sources.saml.processors.request import SESSION_REQUEST_ID | ||||||
| from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||||
| from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT | from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from xml.etree.ElementTree import Element  # nosec |     from xml.etree.ElementTree import Element  # nosec | ||||||
|  |  | ||||||
|  | CACHE_SEEN_REQUEST_ID = "passbook_saml_seen_ids_%s" | ||||||
| DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend" | DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend" | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -59,8 +65,9 @@ class ResponseProcessor: | |||||||
|         # Check if response is compressed, b64 decode it |         # Check if response is compressed, b64 decode it | ||||||
|         self._root_xml = decode_base64_and_inflate(raw_response) |         self._root_xml = decode_base64_and_inflate(raw_response) | ||||||
|         self._root = ElementTree.fromstring(self._root_xml) |         self._root = ElementTree.fromstring(self._root_xml) | ||||||
|         # Verify signed XML |  | ||||||
|         self._verify_signed() |         self._verify_signed() | ||||||
|  |         self._verify_request_id(request) | ||||||
|  |  | ||||||
|     def _verify_signed(self): |     def _verify_signed(self): | ||||||
|         """Verify SAML Response's Signature""" |         """Verify SAML Response's Signature""" | ||||||
| @ -70,6 +77,26 @@ class ResponseProcessor: | |||||||
|         ) |         ) | ||||||
|         LOGGER.debug("Successfully verified signautre") |         LOGGER.debug("Successfully verified signautre") | ||||||
|  |  | ||||||
|  |     def _verify_request_id(self, request: HttpRequest): | ||||||
|  |         if self._source.allow_idp_initiated: | ||||||
|  |             # If IdP-initiated SSO flows are enabled, we want to cache the Response ID | ||||||
|  |             # somewhat mitigate replay attacks | ||||||
|  |             seen_ids = cache.get(CACHE_SEEN_REQUEST_ID % self._source.pk, []) | ||||||
|  |             if self._root.attrib["ID"] in seen_ids: | ||||||
|  |                 raise SuspiciousOperation("Replay attack detected") | ||||||
|  |             seen_ids.append(self._root.attrib["ID"]) | ||||||
|  |             cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids) | ||||||
|  |             return | ||||||
|  |         if ( | ||||||
|  |             SESSION_REQUEST_ID not in request.session | ||||||
|  |             or "InResponseTo" not in self._root.attrib | ||||||
|  |         ): | ||||||
|  |             raise MismatchedRequestID( | ||||||
|  |                 "Missing InResponseTo and IdP-initiated Logins are not allowed" | ||||||
|  |             ) | ||||||
|  |         if request.session[SESSION_REQUEST_ID] != self._root.attrib["InResponseTo"]: | ||||||
|  |             raise MismatchedRequestID("Mismatched request ID") | ||||||
|  |  | ||||||
|     def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse: |     def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse: | ||||||
|         """Handle a NameID with the Format of Transient. This is a bit more complex than other |         """Handle a NameID with the Format of Transient. This is a bit more complex than other | ||||||
|         formats, as we need to create a temporary User that is used in the session. This |         formats, as we need to create a temporary User that is used in the session. This | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.markers import StageMarker | from passbook.flows.markers import StageMarker | ||||||
| @ -50,6 +50,6 @@ class TestCaptchaStage(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ from time import sleep | |||||||
|  |  | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import Application, User | from passbook.core.models import Application, User | ||||||
| from passbook.core.tasks import clean_expired_models | from passbook.core.tasks import clean_expired_models | ||||||
| @ -49,7 +49,7 @@ class TestConsentStage(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|         self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) |         self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) | ||||||
| @ -80,7 +80,7 @@ class TestConsentStage(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|         self.assertTrue( |         self.assertTrue( | ||||||
| @ -117,7 +117,7 @@ class TestConsentStage(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|         self.assertTrue( |         self.assertTrue( | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """dummy tests""" | """dummy tests""" | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding | from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||||
| @ -44,7 +44,7 @@ class TestDummyStage(TestCase): | |||||||
|         response = self.client.post(url, {}) |         response = self.client.post(url, {}) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -26,7 +26,7 @@ | |||||||
|  |  | ||||||
|                     <!-- START CENTERED WHITE CONTAINER --> |                     <!-- START CENTERED WHITE CONTAINER --> | ||||||
|                     <table role="presentation" class="main"> |                     <table role="presentation" class="main"> | ||||||
|                         <img src="{% inline_static_binary "passbook/logo.svg" %}" alt=""> |                         <img src="{% inline_static_binary config.passbook.branding.logo %}" alt=""> | ||||||
|                         <!-- START MAIN CONTENT AREA --> |                         <!-- START MAIN CONTENT AREA --> | ||||||
|                         <tr> |                         <tr> | ||||||
|                             <td class="wrapper"> |                             <td class="wrapper"> | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """passbook core inlining template tags""" | """passbook core inlining template tags""" | ||||||
|  | from base64 import b64encode | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
| from django import template | from django import template | ||||||
| @ -9,16 +10,22 @@ register = template.Library() | |||||||
|  |  | ||||||
| @register.simple_tag() | @register.simple_tag() | ||||||
| def inline_static_ascii(path: str) -> str: | def inline_static_ascii(path: str) -> str: | ||||||
|     """Inline static asset. Doesn't check file contents, plain text is assumed""" |     """Inline static asset. Doesn't check file contents, plain text is assumed. | ||||||
|     result = finders.find(path) |     If no file could be found, original path is returned""" | ||||||
|     with open(result) as _file: |     result = Path(finders.find(path)) | ||||||
|         return _file.read() |     if result: | ||||||
|  |         with open(result) as _file: | ||||||
|  |             return _file.read() | ||||||
|  |     return path | ||||||
|  |  | ||||||
|  |  | ||||||
| @register.simple_tag() | @register.simple_tag() | ||||||
| def inline_static_binary(path: str) -> str: | def inline_static_binary(path: str) -> str: | ||||||
|     """Inline static asset. Uses file extension for base64 block""" |     """Inline static asset. Uses file extension for base64 block. If no file could be found, | ||||||
|     result = finders.find(path) |     path is returned.""" | ||||||
|     suffix = Path(path).suffix |     result = Path(finders.find(path)) | ||||||
|     with open(result) as _file: |     if result and result.is_file(): | ||||||
|         return f"data:image/{suffix};base64," + _file.read() |         with open(result) as _file: | ||||||
|  |             b64content = b64encode(_file.read().encode()) | ||||||
|  |             return f"data:image/{result.suffix};base64,{b64content.decode('utf-8')}" | ||||||
|  |     return path | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch | |||||||
| from django.core import mail | from django.core import mail | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import Token, User | from passbook.core.models import Token, User | ||||||
| from passbook.flows.markers import StageMarker | from passbook.flows.markers import StageMarker | ||||||
| @ -114,7 +114,7 @@ class TestEmailStage(TestCase): | |||||||
|  |  | ||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             self.assertJSONEqual( |             self.assertJSONEqual( | ||||||
|                 force_text(response.content), |                 force_str(response.content), | ||||||
|                 {"type": "redirect", "to": reverse("passbook_core:overview")}, |                 {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """identification tests""" | """identification tests""" | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding | from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||||
| @ -56,7 +56,7 @@ class TestIdentificationStage(TestCase): | |||||||
|         response = self.client.post(url, form_data) |         response = self.client.post(url, form_data) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -101,7 +101,7 @@ class TestIdentificationStage(TestCase): | |||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertIn(flow.slug, force_text(response.content)) |         self.assertIn(flow.slug, force_str(response.content)) | ||||||
|  |  | ||||||
|     def test_recovery_flow(self): |     def test_recovery_flow(self): | ||||||
|         """Test that recovery flow is linked correctly""" |         """Test that recovery flow is linked correctly""" | ||||||
| @ -122,4 +122,4 @@ class TestIdentificationStage(TestCase): | |||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertIn(flow.slug, force_text(response.content)) |         self.assertIn(flow.slug, force_str(response.content)) | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch | |||||||
|  |  | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| @ -59,7 +59,7 @@ class TestUserLoginStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, |             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -86,7 +86,7 @@ class TestUserLoginStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -125,6 +125,6 @@ class TestUserLoginStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from typing import Any, Dict | from typing import Any, Dict | ||||||
|  |  | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
| from django.views.generic import FormView | from django.views.generic import FormView | ||||||
| from django_otp.plugins.otp_totp.models import TOTPDevice | from django_otp.plugins.otp_totp.models import TOTPDevice | ||||||
| from lxml.etree import tostring  # nosec | from lxml.etree import tostring  # nosec | ||||||
| @ -35,7 +35,7 @@ class OTPTimeStageView(FormView, StageView): | |||||||
|         """Get QR Code SVG as string based on `device`""" |         """Get QR Code SVG as string based on `device`""" | ||||||
|         qr_code = QRCode(image_factory=SvgFillImage) |         qr_code = QRCode(image_factory=SvgFillImage) | ||||||
|         qr_code.add_data(device.config_url) |         qr_code.add_data(device.config_url) | ||||||
|         return force_text(tostring(qr_code.make_image().get_image())) |         return force_str(tostring(qr_code.make_image().get_image())) | ||||||
|  |  | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|         user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) |         user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch | |||||||
| from django.core.exceptions import PermissionDenied | from django.core.exceptions import PermissionDenied | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.markers import StageMarker | from passbook.flows.markers import StageMarker | ||||||
| @ -61,7 +61,7 @@ class TestPasswordStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, |             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -84,7 +84,7 @@ class TestPasswordStage(TestCase): | |||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertIn(flow.slug, force_text(response.content)) |         self.assertIn(flow.slug, force_str(response.content)) | ||||||
|  |  | ||||||
|     def test_valid_password(self): |     def test_valid_password(self): | ||||||
|         """Test with a valid pending user and valid password""" |         """Test with a valid pending user and valid password""" | ||||||
| @ -106,7 +106,7 @@ class TestPasswordStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -154,6 +154,6 @@ class TestPasswordStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, |             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch | |||||||
|  |  | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.markers import StageMarker | from passbook.flows.markers import StageMarker | ||||||
| @ -110,9 +110,9 @@ class TestPromptStage(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         for prompt in self.stage.fields.all(): |         for prompt in self.stage.fields.all(): | ||||||
|             self.assertIn(prompt.field_key, force_text(response.content)) |             self.assertIn(prompt.field_key, force_str(response.content)) | ||||||
|             self.assertIn(prompt.label, force_text(response.content)) |             self.assertIn(prompt.label, force_str(response.content)) | ||||||
|             self.assertIn(prompt.placeholder, force_text(response.content)) |             self.assertIn(prompt.placeholder, force_str(response.content)) | ||||||
|  |  | ||||||
|     def test_valid_form_with_policy(self) -> PromptForm: |     def test_valid_form_with_policy(self) -> PromptForm: | ||||||
|         """Test form validation""" |         """Test form validation""" | ||||||
| @ -164,7 +164,7 @@ class TestPromptStage(TestCase): | |||||||
|             ) |             ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """delete tests""" | """delete tests""" | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.markers import StageMarker | from passbook.flows.markers import StageMarker | ||||||
| @ -44,7 +44,7 @@ class TestUserDeleteStage(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, |             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -83,7 +83,7 @@ class TestUserDeleteStage(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """login tests""" | """login tests""" | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.markers import StageMarker | from passbook.flows.markers import StageMarker | ||||||
| @ -50,7 +50,7 @@ class TestUserLoginStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -71,7 +71,7 @@ class TestUserLoginStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, |             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -93,7 +93,7 @@ class TestUserLoginStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, |             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """logout tests""" | """logout tests""" | ||||||
| from django.shortcuts import reverse | from django.shortcuts import reverse | ||||||
| from django.test import Client, TestCase | from django.test import Client, TestCase | ||||||
| from django.utils.encoding import force_text | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from passbook.core.models import User | from passbook.core.models import User | ||||||
| from passbook.flows.markers import StageMarker | from passbook.flows.markers import StageMarker | ||||||
| @ -50,7 +50,7 @@ class TestUserLogoutStage(TestCase): | |||||||
|  |  | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_text(response.content), |             force_str(response.content), | ||||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, |             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	