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] | ||||
| current_version = 0.10.0-rc4 | ||||
| current_version = 0.10.0-stable | ||||
| tag = True | ||||
| commit = True | ||||
| 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/kubernetes.md] | ||||
|  | ||||
| [bumpversion:file:docker-compose.yml] | ||||
|  | ||||
| [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: | ||||
|   release | ||||
|   release: | ||||
|     types: [published, created] | ||||
|  | ||||
| jobs: | ||||
|   # Build | ||||
| @ -16,17 +18,26 @@ jobs: | ||||
|       - name: Building Docker Image | ||||
|         run: docker build | ||||
|           --no-cache | ||||
|           -t beryju/passbook:0.10.0-rc4 | ||||
|           -t beryju/passbook:0.10.0-stable | ||||
|           -t beryju/passbook:latest | ||||
|           -f Dockerfile . | ||||
|       - 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) | ||||
|         run: docker push beryju/passbook:latest | ||||
|   build-proxy: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - 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 | ||||
|         env: | ||||
|           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||
| @ -37,11 +48,11 @@ jobs: | ||||
|           cd proxy | ||||
|           docker build \ | ||||
|           --no-cache \ | ||||
|           -t beryju/passbook-proxy:0.10.0-rc4 \ | ||||
|           -t beryju/passbook-proxy:0.10.0-stable \ | ||||
|           -t beryju/passbook-proxy:latest \ | ||||
|           -f Dockerfile . | ||||
|       - 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) | ||||
|         run: docker push beryju/passbook-proxy:latest | ||||
|   build-static: | ||||
| @ -66,11 +77,11 @@ jobs: | ||||
|         run: docker build | ||||
|           --no-cache | ||||
|           --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 | ||||
|           -f static.Dockerfile . | ||||
|       - 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) | ||||
|         run: docker push beryju/passbook-static:latest | ||||
|   test-release: | ||||
| @ -85,7 +96,7 @@ jobs: | ||||
|           docker-compose pull -q | ||||
|           docker-compose up --no-start | ||||
|           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: | ||||
|     needs: | ||||
|       - test-release | ||||
| @ -100,5 +111,5 @@ jobs: | ||||
|           SENTRY_PROJECT: passbook | ||||
|           SENTRY_URL: https://sentry.beryju.org | ||||
|         with: | ||||
|           tagName: 0.10.0-rc4 | ||||
|           tagName: 0.10.0-stable | ||||
|           environment: beryjuorg-prod | ||||
|  | ||||
							
								
								
									
										8
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,10 +1,10 @@ | ||||
| name: passbook-on-tag | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|     - 'version/*' | ||||
|  | ||||
| name: passbook-version-tag | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     name: Create Release from Tag | ||||
| @ -21,7 +21,7 @@ jobs: | ||||
|             -f Dockerfile . | ||||
|           docker-compose up --no-start | ||||
|           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 | ||||
|         run: | | ||||
|           apt update && apt install -y curl | ||||
| @ -31,7 +31,7 @@ jobs: | ||||
|           helm dependency update helm/ | ||||
|           helm package helm/ | ||||
|           mv passbook-*.tgz passbook-chart.tgz | ||||
|       - name: Extract verison number | ||||
|       - name: Extract version number | ||||
|         id: get_version | ||||
|         uses: actions/github-script@0.2.0 | ||||
|         with: | ||||
|  | ||||
							
								
								
									
										8
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | ||||
| all: lint-fix lint coverage gen | ||||
|  | ||||
| coverage: | ||||
| 	coverage run --concurrency=multiprocessing manage.py test passbook --failfast | ||||
| 	coverage run --concurrency=multiprocessing manage.py test --failfast | ||||
| 	coverage combine | ||||
| 	coverage html | ||||
| 	coverage report | ||||
| @ -18,3 +18,9 @@ lint: | ||||
|  | ||||
| gen: coverage | ||||
| 	./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-django = "*" | ||||
| selenium = "*" | ||||
| unittest-xml-reporting = "*" | ||||
| prospector = "*" | ||||
| pytest = "*" | ||||
| pytest-django = "*" | ||||
|  | ||||
							
								
								
									
										87
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										87
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "a798bbd0b97857cac136c1743b8d6ad8bf8c3d95e2760c71d324bb2a7f47f678" | ||||
|             "sha256": "80570636236962f4b934a884817292de9f7bb48520aa964afc2959b0f795fb57" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": { | ||||
| @ -74,18 +74,18 @@ | ||||
|         }, | ||||
|         "boto3": { | ||||
|             "hashes": [ | ||||
|                 "sha256:2ab73b0c400ab8c7df84bee7564ef8a0813021da28dd7a05fcbffb77a8ae9de9", | ||||
|                 "sha256:bb2222fa02fcd09b39e581e532d4f013ea850742d8cd46e9c10a21028b6d2ef5" | ||||
|                 "sha256:20edd03ae4c4e141b0d8a9a9afc773af4345d54b68202b6aa502956b57b18b3f", | ||||
|                 "sha256:b596a80181fecd775ccc009286400f4d785136f250967895cb34beeeef65eb1f" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==1.14.56" | ||||
|             "version": "==1.14.59" | ||||
|         }, | ||||
|         "botocore": { | ||||
|             "hashes": [ | ||||
|                 "sha256:37cc3f1013c00dc0f061582198d6b785dadf147bd99307d41c5c0e47debca65c", | ||||
|                 "sha256:acd2df778a5e12b2a16ac040ce6e91a6c6f2d7ac67bd4f966472ce5c68b5b62d" | ||||
|                 "sha256:193f193a66ac79106725e14dd73e28ed36bcec99b37156538a2202d061056a58", | ||||
|                 "sha256:e55a4fc652537f5ccb2362133f3928ebeafb04ee9fe15ea11c2df80ba4ef8a12" | ||||
|             ], | ||||
|             "version": "==1.17.58" | ||||
|             "version": "==1.17.60" | ||||
|         }, | ||||
|         "cachetools": { | ||||
|             "hashes": [ | ||||
| @ -1373,6 +1373,13 @@ | ||||
|             ], | ||||
|             "version": "==2.10" | ||||
|         }, | ||||
|         "iniconfig": { | ||||
|             "hashes": [ | ||||
|                 "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", | ||||
|                 "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" | ||||
|             ], | ||||
|             "version": "==1.0.1" | ||||
|         }, | ||||
|         "isort": { | ||||
|             "hashes": [ | ||||
|                 "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", | ||||
| @ -1413,6 +1420,21 @@ | ||||
|             ], | ||||
|             "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": { | ||||
|             "hashes": [ | ||||
|                 "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", | ||||
| @ -1434,6 +1456,13 @@ | ||||
|             ], | ||||
|             "version": "==0.10.0" | ||||
|         }, | ||||
|         "pluggy": { | ||||
|             "hashes": [ | ||||
|                 "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", | ||||
|                 "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" | ||||
|             ], | ||||
|             "version": "==0.13.1" | ||||
|         }, | ||||
|         "prospector": { | ||||
|             "hashes": [ | ||||
|                 "sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f" | ||||
| @ -1441,6 +1470,13 @@ | ||||
|             "index": "pypi", | ||||
|             "version": "==1.3.0" | ||||
|         }, | ||||
|         "py": { | ||||
|             "hashes": [ | ||||
|                 "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", | ||||
|                 "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" | ||||
|             ], | ||||
|             "version": "==1.9.0" | ||||
|         }, | ||||
|         "pycodestyle": { | ||||
|             "hashes": [ | ||||
|                 "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", | ||||
| @ -1497,6 +1533,29 @@ | ||||
|             ], | ||||
|             "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": { | ||||
|             "hashes": [ | ||||
|                 "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", | ||||
| @ -1604,10 +1663,10 @@ | ||||
|         }, | ||||
|         "stevedore": { | ||||
|             "hashes": [ | ||||
|                 "sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e", | ||||
|                 "sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee" | ||||
|                 "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62", | ||||
|                 "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0" | ||||
|             ], | ||||
|             "version": "==3.2.1" | ||||
|             "version": "==3.2.2" | ||||
|         }, | ||||
|         "toml": { | ||||
|             "hashes": [ | ||||
| @ -1642,14 +1701,6 @@ | ||||
|             ], | ||||
|             "version": "==1.4.1" | ||||
|         }, | ||||
|         "unittest-xml-reporting": { | ||||
|             "hashes": [ | ||||
|                 "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", | ||||
|                 "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==3.0.4" | ||||
|         }, | ||||
|         "urllib3": { | ||||
|             "extras": [ | ||||
|                 "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) | ||||
|  | ||||
| @ -20,7 +20,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml | ||||
| # Optionally enable Error-reporting | ||||
| # export PASSBOOK_ERROR_REPORTING=true | ||||
| # Optionally deploy a different version | ||||
| # export PASSBOOK_TAG=0.10.0-rc4 | ||||
| # export PASSBOOK_TAG=0.10.0-stable | ||||
| # If this is a productive installation, set a different PostgreSQL Password | ||||
| # export PG_PASS=$(pwgen 40 1) | ||||
| docker-compose pull | ||||
|  | ||||
| @ -150,7 +150,7 @@ stages: | ||||
|               publishLocation: 'pipeline' | ||||
|       - job: coverage_e2e | ||||
|         pool: | ||||
|           vmImage: 'ubuntu-latest' | ||||
|           name: coventry | ||||
|         steps: | ||||
|           - task: UsePythonVersion@0 | ||||
|             inputs: | ||||
| @ -300,4 +300,4 @@ stages: | ||||
|               chartType: 'FilePath' | ||||
|               chartPath: 'helm/' | ||||
|               releaseName: 'passbook-dev' | ||||
|               recreate: true | ||||
|               recreate: true | ||||
|  | ||||
| @ -21,7 +21,7 @@ services: | ||||
|     labels: | ||||
|       - traefik.enable=false | ||||
|   server: | ||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc4} | ||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-stable} | ||||
|     command: server | ||||
|     environment: | ||||
|       PASSBOOK_REDIS__HOST: redis | ||||
| @ -38,7 +38,7 @@ services: | ||||
|       - traefik.docker.network=internal | ||||
|       - traefik.frontend.rule=PathPrefix:/ | ||||
|   worker: | ||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc4} | ||||
|     image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-stable} | ||||
|     command: worker | ||||
|     networks: | ||||
|       - internal | ||||
| @ -51,7 +51,7 @@ services: | ||||
|       PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword} | ||||
|       PASSBOOK_LOG_LEVEL: debug | ||||
|   static: | ||||
|     image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.0-rc4} | ||||
|     image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.0-stable} | ||||
|     networks: | ||||
|       - internal | ||||
|     labels: | ||||
|  | ||||
| @ -16,7 +16,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml | ||||
| # Optionally enable Error-reporting | ||||
| # export PASSBOOK_ERROR_REPORTING=true | ||||
| # Optionally deploy a different version | ||||
| # export PASSBOOK_TAG=0.10.0-rc4 | ||||
| # export PASSBOOK_TAG=0.10.0-stable | ||||
| # If this is a productive installation, set a different PostgreSQL Password | ||||
| # export PG_PASS=$(pwgen 40 1) | ||||
| docker-compose pull | ||||
|  | ||||
| @ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th | ||||
| image: | ||||
|   name: beryju/passbook | ||||
|   name_static: beryju/passbook-static | ||||
|   tag: 0.9.0-stable | ||||
|   tag: 0.10.0-stable | ||||
|  | ||||
| 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. | ||||
|  | ||||
| To deploy an outpost, you can for example use this docker-compose snippet: | ||||
| To deploy an outpost, see: <a name="deploy"> | ||||
|  | ||||
| ```yaml | ||||
| version: 3.5 | ||||
| - [Kubernetes](deploy-kubernetes.md) | ||||
| - [docker-compose](deploy-docker-compose.md) | ||||
|  | ||||
| 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 | ||||
| ``` | ||||
|  | ||||
| In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster.w | ||||
| In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster. | ||||
|  | ||||
| @ -2,7 +2,7 @@ version: '3.7' | ||||
|  | ||||
| services: | ||||
|   chrome: | ||||
|     image: selenium/standalone-chrome-debug:3.141.59-20200525 | ||||
|     image: selenium/standalone-chrome-debug:3.141.59-20200719 | ||||
|     volumes: | ||||
|       - /dev/shm:/dev/shm | ||||
|     network_mode: host | ||||
|  | ||||
| @ -1,13 +1,12 @@ | ||||
| """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 docker import DockerClient, from_env | ||||
| from docker.models.containers import Container | ||||
| from docker.types import Healthcheck | ||||
| from selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.support import expected_conditions as ec | ||||
| from structlog import get_logger | ||||
|  | ||||
| from e2e.utils import USER, SeleniumTestCase | ||||
| 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_write.models import UserWriteStage | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestFlowsEnroll(SeleniumTestCase): | ||||
|     """Test Enroll flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.container = self.setup_client() | ||||
|         super().setUp() | ||||
|  | ||||
|     def setup_client(self) -> Container: | ||||
|         """Setup test IdP container""" | ||||
|         client: DockerClient = from_env() | ||||
|         container = client.containers.run( | ||||
|             image="mailhog/mailhog:v1.0.1", | ||||
|             detach=True, | ||||
|             network_mode="host", | ||||
|             auto_remove=True, | ||||
|             healthcheck=Healthcheck( | ||||
|     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||
|         return { | ||||
|             "image": "mailhog/mailhog:v1.0.1", | ||||
|             "detach": True, | ||||
|             "network_mode": "host", | ||||
|             "auto_remove": True, | ||||
|             "healthcheck": Healthcheck( | ||||
|                 test=["CMD", "wget", "--spider", "http://localhost:8025"], | ||||
|                 interval=5 * 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): | ||||
|         """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_email").send_keys("foo@bar.baz") | ||||
|         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 | ||||
|         self.driver.get("http://localhost:8025") | ||||
|  | ||||
|         # 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() | ||||
|         sleep(3) | ||||
|         self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane")) | ||||
|         self.driver.find_element(By.ID, "confirm").click() | ||||
|         self.driver.close() | ||||
|         self.driver.switch_to.window(self.driver.window_handles[0]) | ||||
|  | ||||
|         # We're now logged in | ||||
|         sleep(3) | ||||
|         self.wait.until( | ||||
|             ec.presence_of_element_located( | ||||
|                 (By.XPATH, "//a[contains(@href, '/-/user/')]") | ||||
|  | ||||
| @ -1,10 +1,14 @@ | ||||
| """test default login flow""" | ||||
| from sys import platform | ||||
| from unittest.case import skipUnless | ||||
|  | ||||
| from selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.common.keys import Keys | ||||
|  | ||||
| from e2e.utils import USER, SeleniumTestCase | ||||
|  | ||||
|  | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestFlowsLogin(SeleniumTestCase): | ||||
|     """test default login flow""" | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """test stage setup flows (password change)""" | ||||
| import string | ||||
| from random import SystemRandom | ||||
| from time import sleep | ||||
| from sys import platform | ||||
| from unittest.case import skipUnless | ||||
|  | ||||
| from selenium.webdriver.common.by import By | ||||
| 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 passbook.core.models import User | ||||
| from passbook.flows.models import Flow, FlowDesignation | ||||
| from passbook.providers.oauth2.generators import generate_client_secret | ||||
| from passbook.stages.password.models import PasswordStage | ||||
|  | ||||
|  | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestFlowsStageSetup(SeleniumTestCase): | ||||
|     """test stage setup flows""" | ||||
|  | ||||
| @ -27,10 +28,7 @@ class TestFlowsStageSetup(SeleniumTestCase): | ||||
|         stage.change_flow = flow | ||||
|         stage.save() | ||||
|  | ||||
|         new_password = "".join( | ||||
|             SystemRandom().choice(string.ascii_uppercase + string.digits) | ||||
|             for _ in range(8) | ||||
|         ) | ||||
|         new_password = generate_client_secret() | ||||
|  | ||||
|         self.driver.get( | ||||
|             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.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 | ||||
|         user = User.objects.get(username=USER().username) | ||||
|         self.assertTrue(user.check_password(new_password)) | ||||
|  | ||||
| @ -1,12 +1,11 @@ | ||||
| """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 selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.common.keys import Keys | ||||
| from structlog import get_logger | ||||
|  | ||||
| from e2e.utils import USER, SeleniumTestCase | ||||
| 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 | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestProviderOAuth2Github(SeleniumTestCase): | ||||
|     """test OAuth Provider flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.client_id = generate_client_id() | ||||
|         self.client_secret = generate_client_secret() | ||||
|         self.container = self.setup_client() | ||||
|         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""" | ||||
|         client: DockerClient = from_env() | ||||
|         container = client.containers.run( | ||||
|             image="grafana/grafana:7.1.0", | ||||
|             detach=True, | ||||
|             network_mode="host", | ||||
|             auto_remove=True, | ||||
|             healthcheck=Healthcheck( | ||||
|         return { | ||||
|             "image": "grafana/grafana:7.1.0", | ||||
|             "detach": True, | ||||
|             "network_mode": "host", | ||||
|             "auto_remove": True, | ||||
|             "healthcheck": Healthcheck( | ||||
|                 test=["CMD", "wget", "--spider", "http://localhost:3000"], | ||||
|                 interval=5 * 100 * 1000000, | ||||
|                 start_period=1 * 100 * 1000000, | ||||
|             ), | ||||
|             environment={ | ||||
|             "environment": { | ||||
|                 "GF_AUTH_GITHUB_ENABLED": "true", | ||||
|                 "GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true", | ||||
|                 "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, | ||||
| @ -61,22 +57,10 @@ class TestProviderOAuth2Github(SeleniumTestCase): | ||||
|                 ), | ||||
|                 "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): | ||||
|         """test OAuth Provider flow (default authorization flow with implied consent)""" | ||||
|         sleep(1) | ||||
|         # Bootstrap all needed objects | ||||
|         authorization_flow = Flow.objects.get( | ||||
|             slug="default-provider-authorization-implicit-consent" | ||||
| @ -129,7 +113,6 @@ class TestProviderOAuth2Github(SeleniumTestCase): | ||||
|  | ||||
|     def test_authorization_consent_explicit(self): | ||||
|         """test OAuth Provider flow (default authorization flow with explicit consent)""" | ||||
|         sleep(1) | ||||
|         # Bootstrap all needed objects | ||||
|         authorization_flow = Flow.objects.get( | ||||
|             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]" | ||||
|             ).text, | ||||
|         ) | ||||
|         sleep(1) | ||||
|         self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() | ||||
|         self.driver.find_element( | ||||
|             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.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() | ||||
| @ -197,7 +185,6 @@ class TestProviderOAuth2Github(SeleniumTestCase): | ||||
|  | ||||
|     def test_denied(self): | ||||
|         """test OAuth Provider flow (default authorization flow, denied)""" | ||||
|         sleep(1) | ||||
|         # Bootstrap all needed objects | ||||
|         authorization_flow = Flow.objects.get( | ||||
|             slug="default-provider-authorization-explicit-consent" | ||||
|  | ||||
| @ -1,8 +1,9 @@ | ||||
| """test OAuth2 OpenID Provider flow""" | ||||
| from sys import platform | ||||
| 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 selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.common.keys import Keys | ||||
| @ -34,29 +35,27 @@ from passbook.providers.oauth2.models import ( | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestProviderOAuth2OIDC(SeleniumTestCase): | ||||
|     """test OAuth with OpenID Provider flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.client_id = generate_client_id() | ||||
|         self.client_secret = generate_client_secret() | ||||
|         self.container = self.setup_client() | ||||
|         super().setUp() | ||||
|  | ||||
|     def setup_client(self) -> Container: | ||||
|         """Setup client grafana container which we test OIDC against""" | ||||
|         client: DockerClient = from_env() | ||||
|         container = client.containers.run( | ||||
|             image="grafana/grafana:7.1.0", | ||||
|             detach=True, | ||||
|             network_mode="host", | ||||
|             auto_remove=True, | ||||
|             healthcheck=Healthcheck( | ||||
|     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||
|         return { | ||||
|             "image": "grafana/grafana:7.1.0", | ||||
|             "detach": True, | ||||
|             "network_mode": "host", | ||||
|             "auto_remove": True, | ||||
|             "healthcheck": Healthcheck( | ||||
|                 test=["CMD", "wget", "--spider", "http://localhost:3000"], | ||||
|                 interval=5 * 100 * 1000000, | ||||
|                 start_period=1 * 100 * 1000000, | ||||
|             ), | ||||
|             environment={ | ||||
|             "environment": { | ||||
|                 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", | ||||
|                 "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id, | ||||
|                 "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, | ||||
| @ -72,18 +71,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): | ||||
|                 ), | ||||
|                 "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): | ||||
|         """test OpenID Provider flow (invalid redirect URI, check error message)""" | ||||
|  | ||||
| @ -1,5 +1,7 @@ | ||||
| """test SAML Provider flow""" | ||||
| from sys import platform | ||||
| from time import sleep | ||||
| from unittest.case import skipUnless | ||||
|  | ||||
| from docker import DockerClient, from_env | ||||
| from docker.models.containers import Container | ||||
| @ -23,6 +25,7 @@ from passbook.providers.saml.models import ( | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestProviderSAML(SeleniumTestCase): | ||||
|     """test SAML Provider flow""" | ||||
|  | ||||
| @ -60,10 +63,6 @@ class TestProviderSAML(SeleniumTestCase): | ||||
|             LOGGER.info("Container failed healthcheck") | ||||
|             sleep(1) | ||||
|  | ||||
|     def tearDown(self): | ||||
|         self.container.kill() | ||||
|         super().tearDown() | ||||
|  | ||||
|     def test_sp_initiated_implicit(self): | ||||
|         """test SAML Provider flow SP-initiated flow (implicit consent)""" | ||||
|         # Bootstrap all needed objects | ||||
|  | ||||
| @ -1,8 +1,11 @@ | ||||
| """test OAuth Source""" | ||||
| from os.path import abspath | ||||
| from sys import platform | ||||
| 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.types import Healthcheck | ||||
| from selenium.webdriver.common.by import By | ||||
| @ -21,6 +24,7 @@ CONFIG_PATH = "/tmp/dex.yml" | ||||
| LOGGER = get_logger() | ||||
| 
 | ||||
| 
 | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestSourceOAuth(SeleniumTestCase): | ||||
|     """test OAuth Source flow""" | ||||
| 
 | ||||
| @ -28,7 +32,7 @@ class TestSourceOAuth(SeleniumTestCase): | ||||
| 
 | ||||
|     def setUp(self): | ||||
|         self.client_secret = generate_client_secret() | ||||
|         self.container = self.setup_client() | ||||
|         self.prepare_dex_config() | ||||
|         super().setUp() | ||||
| 
 | ||||
|     def prepare_dex_config(self): | ||||
| @ -66,34 +70,23 @@ class TestSourceOAuth(SeleniumTestCase): | ||||
|         with open(CONFIG_PATH, "w+") as _file: | ||||
|             safe_dump(config, _file) | ||||
| 
 | ||||
|     def setup_client(self) -> Container: | ||||
|         """Setup test Dex container""" | ||||
|         self.prepare_dex_config() | ||||
|         client: DockerClient = from_env() | ||||
|         container = client.containers.run( | ||||
|             image="quay.io/dexidp/dex:v2.24.0", | ||||
|             detach=True, | ||||
|             network_mode="host", | ||||
|             auto_remove=True, | ||||
|             command="serve /config.yml", | ||||
|             healthcheck=Healthcheck( | ||||
|     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||
|         return { | ||||
|             "image": "quay.io/dexidp/dex:v2.24.0", | ||||
|             "detach": True, | ||||
|             "network_mode": "host", | ||||
|             "auto_remove": True, | ||||
|             "command": "serve /config.yml", | ||||
|             "healthcheck": Healthcheck( | ||||
|                 test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"], | ||||
|                 interval=5 * 100 * 1000000, | ||||
|                 start_period=1 * 100 * 1000000, | ||||
|             ), | ||||
|             volumes={abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}}, | ||||
|         ) | ||||
|         while True: | ||||
|             container.reload() | ||||
|             status = container.attrs.get("State", {}).get("Health", {}).get("Status") | ||||
|             if status == "healthy": | ||||
|                 return container | ||||
|             LOGGER.info("Container failed healthcheck") | ||||
|             sleep(1) | ||||
|             "volumes": {abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}}, | ||||
|         } | ||||
| 
 | ||||
|     def create_objects(self): | ||||
|         """Create required objects""" | ||||
|         sleep(1) | ||||
|         # Bootstrap all needed objects | ||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||
| @ -111,10 +104,6 @@ class TestSourceOAuth(SeleniumTestCase): | ||||
|             consumer_secret=self.client_secret, | ||||
|         ) | ||||
| 
 | ||||
|     def tearDown(self): | ||||
|         self.container.kill() | ||||
|         super().tearDown() | ||||
| 
 | ||||
|     def test_oauth_enroll(self): | ||||
|         """test OAuth Source With With OIDC""" | ||||
|         self.create_objects() | ||||
| @ -141,6 +130,7 @@ class TestSourceOAuth(SeleniumTestCase): | ||||
|         ) | ||||
|         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 | ||||
|         # and we're asked for the username | ||||
|         self.driver.find_element(By.NAME, "username").click() | ||||
| @ -167,6 +157,42 @@ class TestSourceOAuth(SeleniumTestCase): | ||||
|             "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): | ||||
|         """test OAuth Source With With OIDC (enroll and authenticate again)""" | ||||
|         self.test_oauth_enroll() | ||||
| @ -178,10 +204,11 @@ class TestSourceOAuth(SeleniumTestCase): | ||||
|                 (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") | ||||
|             ) | ||||
|         ) | ||||
|         sleep(1) | ||||
|         self.driver.find_element( | ||||
|             By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" | ||||
|         ).click() | ||||
| 
 | ||||
|         sleep(1) | ||||
|         # 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") | ||||
| @ -1,8 +1,9 @@ | ||||
| """test SAML Source""" | ||||
| from sys import platform | ||||
| 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 selenium.webdriver.common.by import By | ||||
| from selenium.webdriver.common.keys import Keys | ||||
| @ -68,48 +69,31 @@ Sm75WXsflOxuTn08LbgGc4s= | ||||
| -----END PRIVATE KEY-----""" | ||||
|  | ||||
|  | ||||
| @skipUnless(platform.startswith("linux"), "requires local docker") | ||||
| class TestSourceSAML(SeleniumTestCase): | ||||
|     """test SAML Source flow""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.container = self.setup_client() | ||||
|         super().setUp() | ||||
|  | ||||
|     def setup_client(self) -> Container: | ||||
|         """Setup test IdP container""" | ||||
|         client: DockerClient = from_env() | ||||
|         container = client.containers.run( | ||||
|             image="kristophjunge/test-saml-idp:1.15", | ||||
|             detach=True, | ||||
|             network_mode="host", | ||||
|             auto_remove=True, | ||||
|             healthcheck=Healthcheck( | ||||
|     def get_container_specs(self) -> Optional[Dict[str, Any]]: | ||||
|         return { | ||||
|             "image": "kristophjunge/test-saml-idp:1.15", | ||||
|             "detach": True, | ||||
|             "network_mode": "host", | ||||
|             "auto_remove": True, | ||||
|             "healthcheck": Healthcheck( | ||||
|                 test=["CMD", "curl", "http://localhost:8080"], | ||||
|                 interval=5 * 100 * 1000000, | ||||
|                 start_period=1 * 100 * 1000000, | ||||
|             ), | ||||
|             environment={ | ||||
|             "environment": { | ||||
|                 "SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id", | ||||
|                 "SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": ( | ||||
|                     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): | ||||
|         """test SAML Source With redirect binding""" | ||||
|         sleep(1) | ||||
|         # Bootstrap all needed objects | ||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||
| @ -161,7 +145,6 @@ class TestSourceSAML(SeleniumTestCase): | ||||
|  | ||||
|     def test_idp_post(self): | ||||
|         """test SAML Source With post binding""" | ||||
|         sleep(1) | ||||
|         # Bootstrap all needed objects | ||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||
| @ -215,7 +198,6 @@ class TestSourceSAML(SeleniumTestCase): | ||||
|  | ||||
|     def test_idp_post_auto(self): | ||||
|         """test SAML Source With post binding (auto redirect)""" | ||||
|         sleep(1) | ||||
|         # Bootstrap all needed objects | ||||
|         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||
|         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||
|  | ||||
							
								
								
									
										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 inspect import getmembers, isfunction | ||||
| 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.contrib.staticfiles.testing import StaticLiveServerTestCase | ||||
| from django.db import connection, transaction | ||||
| from django.db.utils import IntegrityError | ||||
| from django.shortcuts import reverse | ||||
| from docker import DockerClient, from_env | ||||
| from docker.models.containers import Container | ||||
| from selenium import webdriver | ||||
| from selenium.webdriver.common.desired_capabilities import DesiredCapabilities | ||||
| from selenium.webdriver.remote.webdriver import WebDriver | ||||
| @ -30,15 +33,35 @@ def USER() -> User:  # noqa | ||||
| class SeleniumTestCase(StaticLiveServerTestCase): | ||||
|     """StaticLiveServerTestCase which automatically creates a Webdriver instance""" | ||||
|  | ||||
|     container: Optional[Container] = None | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|         makedirs("selenium_screenshots/", exist_ok=True) | ||||
|         self.driver = self._get_driver() | ||||
|         self.driver.maximize_window() | ||||
|         self.driver.implicitly_wait(30) | ||||
|         self.wait = WebDriverWait(self.driver, 50) | ||||
|         self.driver.implicitly_wait(10) | ||||
|         self.wait = WebDriverWait(self.driver, 30) | ||||
|         self.apply_default_data() | ||||
|         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: | ||||
|         return webdriver.Remote( | ||||
| @ -57,6 +80,8 @@ class SeleniumTestCase(StaticLiveServerTestCase): | ||||
|             self.logger.warning( | ||||
|                 line["message"], source=line["source"], level=line["level"] | ||||
|             ) | ||||
|         if self.container: | ||||
|             self.container.kill() | ||||
|         self.driver.quit() | ||||
|         super().tearDown() | ||||
|  | ||||
|  | ||||
| @ -1,9 +1,9 @@ | ||||
| apiVersion: v2 | ||||
| appVersion: "0.10.0-rc4" | ||||
| appVersion: "0.10.0-stable" | ||||
| description: A Helm chart for passbook. | ||||
| name: passbook | ||||
| version: "0.10.0-rc4" | ||||
| icon: https://github.com/BeryJu/passbook/blob/master/passbook/static/static/passbook/logo.svg | ||||
| version: "0.10.0-stable" | ||||
| icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg | ||||
| dependencies: | ||||
|   - name: postgresql | ||||
|     version: 9.3.2 | ||||
|  | ||||
| @ -9,7 +9,7 @@ metadata: | ||||
|     app.kubernetes.io/managed-by: {{ .Release.Service }} | ||||
|     k8s.passbook.beryju.org/component: web | ||||
| spec: | ||||
|   replicas: {{ serverReplicas }} | ||||
|   replicas: {{ .Values.serverReplicas }} | ||||
|   selector: | ||||
|     matchLabels: | ||||
|       app.kubernetes.io/name: {{ include "passbook.name" . }} | ||||
|  | ||||
| @ -9,7 +9,7 @@ metadata: | ||||
|     app.kubernetes.io/managed-by: {{ .Release.Service }} | ||||
|     k8s.passbook.beryju.org/component: worker | ||||
| spec: | ||||
|   replicas: {{ workerReplicas }} | ||||
|   replicas: {{ .Values.workerReplicas }} | ||||
|   selector: | ||||
|     matchLabels: | ||||
|       app.kubernetes.io/name: {{ include "passbook.name" . }} | ||||
|  | ||||
| @ -4,7 +4,7 @@ | ||||
| image: | ||||
|   name: beryju/passbook | ||||
|   name_static: beryju/passbook-static | ||||
|   tag: 0.10.0-rc4 | ||||
|   tag: 0.10.0-stable | ||||
|  | ||||
| nameOverride: "" | ||||
|  | ||||
|  | ||||
| @ -1,9 +1,10 @@ | ||||
| """Gunicorn config""" | ||||
| from multiprocessing import cpu_count | ||||
| from pathlib import Path | ||||
|  | ||||
| import structlog | ||||
|  | ||||
| bind = "0.0.0.0:8000" | ||||
| workers = 2 | ||||
| threads = 4 | ||||
|  | ||||
| user = "passbook" | ||||
| group = "passbook" | ||||
| @ -40,3 +41,11 @@ logconfig_dict = { | ||||
|         "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 | ||||
|     - SAML: providers/saml.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: | ||||
|     - Overview: expressions/index.md | ||||
|     - Reference: | ||||
|  | ||||
| @ -1,2 +1,2 @@ | ||||
| """passbook""" | ||||
| __version__ = "0.10.0-rc4" | ||||
| __version__ = "0.10.0-stable" | ||||
|  | ||||
| @ -69,6 +69,7 @@ | ||||
|                     <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-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> | ||||
|                 </tr> | ||||
|                 {% endfor %} | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -7,7 +7,7 @@ from django.contrib.auth.mixins import ( | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| from django.http import HttpRequest, HttpResponse, JsonResponse | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -10,7 +10,7 @@ from django.contrib.messages.views import SuccessMessageMixin | ||||
| from django.db.models import QuerySet | ||||
| from django.http import HttpResponse | ||||
| 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.detail import DetailView | ||||
| 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.db.models import QuerySet | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 passbook.admin.views.utils import ( | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 passbook.admin.views.utils import ( | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 passbook.admin.views.utils import ( | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 passbook.admin.views.utils import ( | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -6,7 +6,7 @@ from django.contrib.auth.mixins import ( | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| from django.http import HttpResponseRedirect | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.contrib.auth.mixins import ( | ||||
| ) | ||||
| from django.contrib.messages.views import SuccessMessageMixin | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """passbook Token administration""" | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| 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 guardian.mixins import PermissionListMixin, PermissionRequiredMixin | ||||
|  | ||||
|  | ||||
| @ -9,7 +9,7 @@ from django.http import HttpRequest, HttpResponse | ||||
| from django.shortcuts import redirect | ||||
| from django.urls import reverse, reverse_lazy | ||||
| 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 guardian.mixins import ( | ||||
|     PermissionListMixin, | ||||
|  | ||||
| @ -1,6 +1,5 @@ | ||||
| """api v2 urls""" | ||||
| from django.conf.urls import url | ||||
| from django.urls import path | ||||
| from django.urls import path, re_path | ||||
| from drf_yasg import openapi | ||||
| from drf_yasg.views import get_schema_view | ||||
| from rest_framework import routers | ||||
| @ -119,7 +118,7 @@ SchemaView = get_schema_view( | ||||
| ) | ||||
|  | ||||
| urlpatterns = [ | ||||
|     url( | ||||
|     re_path( | ||||
|         r"^swagger(?P<format>\.json|\.yaml)$", | ||||
|         SchemaView.without_ui(cache_timeout=0), | ||||
|         name="schema-json", | ||||
|  | ||||
| @ -20,8 +20,12 @@ | ||||
|                 </button> | ||||
|             </div> | ||||
|             <a class="pf-c-page__header-brand-link"> | ||||
|                 <img class="pf-c-brand" src="{% static 'passbook/logo.png' %}" alt="" /> | ||||
|                 <img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" alt="passbook" /> | ||||
|                 <div class="pf-c-brand pb-brand"> | ||||
|                     <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> | ||||
|         </div> | ||||
|         <div class="pf-c-page__header-nav"> | ||||
|  | ||||
| @ -6,15 +6,17 @@ | ||||
|  | ||||
| <html lang="en"> | ||||
|     <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 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="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-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 'passbook/pf.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'passbook/passbook.css' %}"> | ||||
|         {% block head %} | ||||
|         {% endblock %} | ||||
|     </head> | ||||
| @ -35,6 +37,6 @@ | ||||
|         {% endblock %} | ||||
|         {% block scripts %} | ||||
|         {% endblock %} | ||||
|         <script src="{% static 'passbook/pf.js' %}"></script> | ||||
|         <script src="{% static 'passbook/passbook.js' %}"></script> | ||||
|     </body> | ||||
| </html> | ||||
|  | ||||
| @ -22,8 +22,12 @@ | ||||
| <div class="pf-c-login"> | ||||
|     <div class="pf-c-login__container"> | ||||
|         <header class="pf-c-login__header"> | ||||
|             <img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" /> | ||||
|             <img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" /> | ||||
|             <div class="pf-c-brand pb-brand"> | ||||
|                 <img src="{{ config.passbook.branding.logo }}" alt="passbook icon" /> | ||||
|                 {% if config.passbook.branding.title_show %} | ||||
|                 <p>{{ config.passbook.branding.title }}</p> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|         </header> | ||||
|         {% block main_container %} | ||||
|         <main class="pf-c-login__main"> | ||||
| @ -47,6 +51,13 @@ | ||||
|                     <a href="{{ link.href }}">{{ link.name }}</a> | ||||
|                 </li> | ||||
|                 {% endfor %} | ||||
|                 {% if config.passbook.branding.title != "passbook" %} | ||||
|                 <li> | ||||
|                     <a href="https://github.com/beryju/passbook"> | ||||
|                         {% trans 'Powered by passbook' %} | ||||
|                     </a> | ||||
|                 </li> | ||||
|                 {% endif %} | ||||
|             </ul> | ||||
|         </footer> | ||||
|     </div> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| """passbook core utils view""" | ||||
| from django.utils.translation import ugettext as _ | ||||
| from django.utils.translation import gettext as _ | ||||
| from django.views.generic import TemplateView | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -52,7 +52,7 @@ def create_default_source_enrollment_flow( | ||||
|  | ||||
|     # PromptStage to ask user for their username | ||||
|     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( | ||||
|         field_key="username", | ||||
|  | ||||
| @ -115,11 +115,12 @@ const updateFormAction = (form) => { | ||||
|     for (let index = 0; index < form.elements.length; index++) { | ||||
|         const element = form.elements[index]; | ||||
|         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; | ||||
|         } | ||||
|     } | ||||
|     form.action = flowBodyUrl; | ||||
|     console.log(`pb-flow: updated form.action ${flowBodyUrl}`); | ||||
|     return true; | ||||
| }; | ||||
| const checkAutosubmit = (form) => { | ||||
| @ -129,11 +130,11 @@ const checkAutosubmit = (form) => { | ||||
| }; | ||||
| const setFormSubmitHandlers = () => { | ||||
|     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); | ||||
|         console.log(`Setting action for form ${form}`); | ||||
|         console.log(`pb-flow: Setting action for form ${form}`); | ||||
|         updateFormAction(form); | ||||
|         console.log(`Adding handler for form ${form}`); | ||||
|         console.log(`pb-flow: Adding handler for form ${form}`); | ||||
|         form.addEventListener('submit', (e) => { | ||||
|             e.preventDefault(); | ||||
|             let formData = new FormData(form); | ||||
| @ -145,6 +146,7 @@ const setFormSubmitHandlers = () => { | ||||
|                 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.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.markers import ReevaluateMarker, StageMarker | ||||
| @ -247,7 +247,7 @@ class TestFlowExecutor(TestCase): | ||||
|         response = self.client.post(exec_url) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -293,7 +293,7 @@ class TestFlowExecutor(TestCase): | ||||
|             # First request, run the planner | ||||
|             response = self.client.get(exec_url) | ||||
|             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] | ||||
|  | ||||
| @ -316,13 +316,13 @@ class TestFlowExecutor(TestCase): | ||||
|         # but it won't save it, hence we cant' check the plan | ||||
|         response = self.client.get(exec_url) | ||||
|         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) | ||||
|         # We do this request without the patch, so the policy results in false | ||||
|         response = self.client.post(exec_url) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -21,6 +21,10 @@ error_reporting: | ||||
|   send_pii: false | ||||
|  | ||||
| passbook: | ||||
|   branding: | ||||
|     title: passbook | ||||
|     title_show: true | ||||
|     logo: /static/passbook/logo.svg | ||||
|   # Optionally add links to the footer on the login page | ||||
|   footer_links: | ||||
|     - name: Documentation | ||||
|  | ||||
| @ -1,5 +1,9 @@ | ||||
| """Generic models""" | ||||
| import re | ||||
|  | ||||
| from django.core.validators import URLValidator | ||||
| from django.db import models | ||||
| from django.utils.regex_helper import _lazy_re_compile | ||||
| from model_utils.managers import InheritanceManager | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| @ -48,3 +52,21 @@ class InheritanceForeignKey(models.ForeignKey): | ||||
|     """Custom ForeignKey that uses 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""" | ||||
| from base64 import b64encode | ||||
| from io import StringIO | ||||
|  | ||||
| from kubernetes.client import ( | ||||
| @ -24,6 +25,11 @@ from passbook import __version__ | ||||
| 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): | ||||
|     """Manage deployment of outpost in kubernetes""" | ||||
|  | ||||
| @ -37,9 +43,9 @@ class KubernetesController(BaseController): | ||||
|         with StringIO() as _str: | ||||
|             dump_all( | ||||
|                 [ | ||||
|                     self.get_deployment_secret(), | ||||
|                     self.get_deployment(), | ||||
|                     self.get_service(), | ||||
|                     self.get_deployment_secret().to_dict(), | ||||
|                     self.get_deployment().to_dict(), | ||||
|                     self.get_service().to_dict(), | ||||
|                 ], | ||||
|                 stream=_str, | ||||
|                 default_flow_style=False, | ||||
| @ -63,15 +69,18 @@ class KubernetesController(BaseController): | ||||
|     def get_deployment_secret(self) -> V1Secret: | ||||
|         """Get secret with token and passbook host""" | ||||
|         return V1Secret( | ||||
|             api_version="v1", | ||||
|             kind="secret", | ||||
|             type="Opaque", | ||||
|             metadata=self.get_object_meta( | ||||
|                 name=f"passbook-outpost-{self.outpost.name}-api" | ||||
|             ), | ||||
|             data={ | ||||
|                 "passbook_host": self.outpost.config.passbook_host, | ||||
|                 "passbook_host_insecure": str( | ||||
|                     self.outpost.config.passbook_host_insecure | ||||
|                 "passbook_host": b64encode_str(self.outpost.config.passbook_host), | ||||
|                 "passbook_host_insecure": b64encode_str( | ||||
|                     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(): | ||||
|             ports.append(V1ServicePort(name=port_name, port=port)) | ||||
|         return V1Service( | ||||
|             api_version="v1", | ||||
|             kind="service", | ||||
|             metadata=meta, | ||||
|             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)) | ||||
|         meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}") | ||||
|         return V1Deployment( | ||||
|             api_version="apps/v1", | ||||
|             kind="deployment", | ||||
|             metadata=meta, | ||||
|             spec=V1DeploymentSpec( | ||||
|                 replicas=1, | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """Outpost models""" | ||||
| from dataclasses import asdict, dataclass | ||||
| from datetime import datetime | ||||
| from json import dumps, loads | ||||
| from typing import Iterable, Optional | ||||
| from uuid import uuid4 | ||||
|  | ||||
| @ -9,6 +8,7 @@ from dacite import from_dict | ||||
| from django.contrib.postgres.fields import ArrayField | ||||
| from django.core.cache import cache | ||||
| from django.db import models | ||||
| from django.db.models.base import Model | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| 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""" | ||||
|  | ||||
|     def get_required_objects(self) -> Iterable[models.Model]: | ||||
|         """Return a list of all required objects""" | ||||
|         return [self] | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         abstract = True | ||||
|  | ||||
|  | ||||
| class OutpostType(models.TextChoices): | ||||
|     """Outpost types, currently only the reverse proxy is available""" | ||||
| @ -79,12 +83,12 @@ class Outpost(models.Model): | ||||
|     @property | ||||
|     def config(self) -> OutpostConfig: | ||||
|         """Load config as OutpostConfig object""" | ||||
|         return from_dict(OutpostConfig, loads(self._config)) | ||||
|         return from_dict(OutpostConfig, self._config) | ||||
|  | ||||
|     @config.setter | ||||
|     def config(self, value): | ||||
|         """Dump config into json""" | ||||
|         self._config = dumps(asdict(value)) | ||||
|         self._config = asdict(value) | ||||
|  | ||||
|     @property | ||||
|     def health_cache_key(self) -> str: | ||||
|  | ||||
| @ -1,31 +1,31 @@ | ||||
| """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.signals import post_save | ||||
| from django.dispatch import receiver | ||||
| from structlog import get_logger | ||||
|  | ||||
| from passbook.lib.utils.reflection import class_to_path | ||||
| from passbook.outposts.models import Outpost, OutpostModel | ||||
| from passbook.outposts.tasks import outpost_send_update | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=Outpost) | ||||
| # 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""" | ||||
|     _ = instance.token | ||||
|  | ||||
|  | ||||
| @receiver(post_save) | ||||
| # 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, | ||||
|     we send a message down the relevant OutpostModels WS connection to trigger an update""" | ||||
|     if isinstance(instance, OutpostModel): | ||||
|         LOGGER.debug("triggering outpost update from outpostmodel", instance=instance) | ||||
|         _send_update(instance) | ||||
|         outpost_send_update.delay(class_to_path(instance.__class__), instance.pk) | ||||
|         return | ||||
|  | ||||
|     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, | ||||
|         # we have to iterate over the entire QS | ||||
|         for reverse in getattr(instance, field_name).all(): | ||||
|             _send_update(reverse) | ||||
|  | ||||
|  | ||||
| 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"}) | ||||
|             outpost_send_update(class_to_path(reverse.__class__), reverse.pk) | ||||
|  | ||||
| @ -1,8 +1,22 @@ | ||||
| """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.root.celery import CELERY_APP | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task(bind=True) | ||||
| # 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""" | ||||
|     if outpost_type == OutpostType.PROXY: | ||||
|         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.shortcuts import reverse | ||||
| 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.jws import JWS | ||||
|  | ||||
|  | ||||
| @ -82,7 +82,7 @@ def extract_client_auth(request: HttpRequest) -> Tuple[str, str]: | ||||
|         b64_user_pass = auth_header.split()[1] | ||||
|         try: | ||||
|             user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") | ||||
|             client_id, client_secret = tuple(user_pass) | ||||
|             client_id, client_secret = user_pass | ||||
|         except (ValueError, Error): | ||||
|             client_id = client_secret = "" | ||||
|     else: | ||||
|  | ||||
| @ -93,9 +93,9 @@ class OAuthAuthorizationParams: | ||||
|         if response_type in [ResponseTypes.CODE]: | ||||
|             grant_type = GrantTypes.AUTHORIZATION_CODE | ||||
|         elif response_type in [ | ||||
|             ResponseTypes.id_token, | ||||
|             ResponseTypes.id_token_token, | ||||
|             ResponseTypes.token, | ||||
|             ResponseTypes.ID_TOKEN, | ||||
|             ResponseTypes.ID_TOKEN_TOKEN, | ||||
|             ResponseTypes.TOKEN, | ||||
|         ]: | ||||
|             grant_type = GrantTypes.IMPLICIT | ||||
|         elif response_type in [ | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from typing import Any, Dict, List | ||||
|  | ||||
| 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 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 urllib.parse import urljoin | ||||
|  | ||||
| from django.core.validators import URLValidator | ||||
| from django.db import models | ||||
| from django.forms import ModelForm | ||||
| from django.utils.translation import gettext as _ | ||||
|  | ||||
| from passbook.crypto.models import CertificateKeyPair | ||||
| from passbook.lib.models import DomainlessURLValidator | ||||
| from passbook.outposts.models import OutpostModel | ||||
| from passbook.providers.oauth2.constants import ( | ||||
|     SCOPE_OPENID, | ||||
| @ -41,10 +41,10 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | ||||
|     Protocols by using a Reverse-Proxy.""" | ||||
|  | ||||
|     internal_host = models.TextField( | ||||
|         validators=[URLValidator(schemes=("http", "https"))] | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))] | ||||
|     ) | ||||
|     external_host = models.TextField( | ||||
|         validators=[URLValidator(schemes=("http", "https"))] | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))] | ||||
|     ) | ||||
|  | ||||
|     cookie_secret = models.TextField(default=get_cookie_secret) | ||||
|  | ||||
| @ -5,7 +5,7 @@ from django.db import models | ||||
| from django.forms import ModelForm | ||||
| from django.http import HttpRequest | ||||
| from django.shortcuts import reverse | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from structlog import get_logger | ||||
|  | ||||
| from passbook.core.models import PropertyMapping, Provider | ||||
|  | ||||
| @ -1,13 +1,22 @@ | ||||
| """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 guardian.utils import get_anonymous_user | ||||
|  | ||||
| from passbook.crypto.models import CertificateKeyPair | ||||
| from passbook.flows.models import Flow | ||||
| 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.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.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): | ||||
| @ -31,6 +40,11 @@ class TestAuthNRequest(TestCase): | ||||
|     def test_signed_valid(self): | ||||
|         """Test generated AuthNRequest with valid signature""" | ||||
|         http_request = self.factory.get("/") | ||||
|  | ||||
|         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() | ||||
| @ -44,6 +58,11 @@ class TestAuthNRequest(TestCase): | ||||
|     def test_signed_valid_detached(self): | ||||
|         """Test generated AuthNRequest with valid signature (detached)""" | ||||
|         http_request = self.factory.get("/") | ||||
|  | ||||
|         middleware = SessionMiddleware() | ||||
|         middleware.process_request(http_request) | ||||
|         http_request.session.save() | ||||
|  | ||||
|         # First create an AuthNRequest | ||||
|         request_proc = RequestProcessor(self.source, http_request, "test_state") | ||||
|         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.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]] | ||||
|  | ||||
| ASGI_IP_HEADERS = ( | ||||
|     b"x-forwarded-for", | ||||
|     b"x-real-ip", | ||||
| ) | ||||
|  | ||||
| LOGGER = get_logger("passbook.asgi") | ||||
|  | ||||
|  | ||||
| @ -51,7 +56,6 @@ class ASGILogger: | ||||
|     """ASGI Logger, instantiated for each request""" | ||||
|  | ||||
|     app: ASGIApp | ||||
|     send: Send | ||||
|  | ||||
|     scope: Scope | ||||
|     headers: Dict[ByteString, Any] | ||||
| @ -64,11 +68,26 @@ class ASGILogger: | ||||
|         self.app = app | ||||
|  | ||||
|     async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: | ||||
|         self.send = send | ||||
|         self.scope = scope | ||||
|         self.content_length = 0 | ||||
|         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": | ||||
|             # Don't log kubernetes health/readiness requests | ||||
|             await send({"type": "http.response.start", "status": 204, "headers": []}) | ||||
| @ -80,25 +99,12 @@ class ASGILogger: | ||||
|             # https://code.djangoproject.com/ticket/31508 | ||||
|             # https://github.com/encode/uvicorn/issues/266 | ||||
|             return | ||||
|         await self.app(scope, receive, self.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) | ||||
|         await self.app(scope, receive, send_hooked) | ||||
|  | ||||
|     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)) | ||||
|         return client_ip | ||||
|  | ||||
| @ -119,6 +125,6 @@ class ASGILogger: | ||||
|         ) | ||||
|  | ||||
|  | ||||
| application = SentryAsgiMiddleware( | ||||
|     ASGILogger(guarantee_single_callable(get_default_application())) | ||||
| application = ASGILogger( | ||||
|     guarantee_single_callable(SentryAsgiMiddleware(get_default_application())) | ||||
| ) | ||||
|  | ||||
| @ -12,7 +12,6 @@ https://docs.djangoproject.com/en/2.1/ref/settings/ | ||||
|  | ||||
| import importlib | ||||
| import os | ||||
| import sys | ||||
| from json import dumps | ||||
|  | ||||
| import structlog | ||||
| @ -156,6 +155,7 @@ DJANGO_REDIS_IGNORE_EXCEPTIONS = True | ||||
| DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True | ||||
| SESSION_ENGINE = "django.contrib.sessions.backends.cache" | ||||
| SESSION_CACHE_ALIAS = "default" | ||||
| SESSION_COOKIE_SAMESITE = "lax" | ||||
|  | ||||
| MIDDLEWARE = [ | ||||
|     "django_prometheus.middleware.PrometheusBeforeMiddleware", | ||||
| @ -372,15 +372,9 @@ LOGGING = { | ||||
| } | ||||
|  | ||||
| TEST = False | ||||
| TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" | ||||
| TEST_RUNNER = "passbook.root.test_runner.PytestTestRunner" | ||||
| 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 = { | ||||
|     "": LOG_LEVEL, | ||||
| @ -431,7 +425,6 @@ for _app in INSTALLED_APPS: | ||||
|             pass | ||||
|  | ||||
| if DEBUG: | ||||
|     SESSION_COOKIE_SAMESITE = None | ||||
|     INSTALLED_APPS.append("debug_toolbar") | ||||
|     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" | ||||
| ) | ||||
| admin.site.logout = RedirectView.as_view( | ||||
|     pattern_name="passbook_flows:default-invalidate" | ||||
|     pattern_name="passbook_flows:default-invalidation" | ||||
| ) | ||||
|  | ||||
| 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""" | ||||
| from typing import Optional, Type | ||||
|  | ||||
| from django.core.validators import URLValidator | ||||
| from django.db import models | ||||
| from django.forms import ModelForm | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from ldap3 import Connection, Server | ||||
|  | ||||
| from passbook.core.models import Group, PropertyMapping, Source | ||||
| from passbook.lib.models import DomainlessURLValidator | ||||
|  | ||||
|  | ||||
| class LDAPSource(Source): | ||||
|     """Federate LDAP Directory with passbook, or create new accounts in LDAP.""" | ||||
|  | ||||
|     server_uri = models.TextField( | ||||
|         validators=[URLValidator(schemes=["ldap", "ldaps"])], | ||||
|         validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])], | ||||
|         verbose_name=_("Server URI"), | ||||
|     ) | ||||
|     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.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.exceptions import RequestException | ||||
| from requests_oauthlib import OAuth1 | ||||
| @ -111,7 +111,7 @@ class OAuthClient(BaseOAuthClient): | ||||
|  | ||||
|     def get_request_token(self, request, callback): | ||||
|         "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: | ||||
|             response = self.session.request( | ||||
|                 "post", | ||||
| @ -128,7 +128,7 @@ class OAuthClient(BaseOAuthClient): | ||||
|  | ||||
|     def get_redirect_args(self, request, callback): | ||||
|         "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) | ||||
|         token, secret = self.parse_raw_token(raw_token) | ||||
|         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.shortcuts import redirect | ||||
| 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 structlog import get_logger | ||||
|  | ||||
|  | ||||
| @ -6,7 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.shortcuts import get_object_or_404, redirect, render | ||||
| 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 passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | ||||
|  | ||||
| @ -15,9 +15,10 @@ class SAMLSourceSerializer(ModelSerializer): | ||||
|         fields = SOURCE_FORM_FIELDS + [ | ||||
|             "issuer", | ||||
|             "sso_url", | ||||
|             "slo_url", | ||||
|             "allow_idp_initiated", | ||||
|             "name_id_policy", | ||||
|             "binding_type", | ||||
|             "slo_url", | ||||
|             "temporary_user_delete_after", | ||||
|             "signing_kp", | ||||
|         ] | ||||
|  | ||||
| @ -8,3 +8,7 @@ class MissingSAMLResponse(SentryIgnoredException): | ||||
|  | ||||
| class UnsupportedNameIDFormat(SentryIgnoredException): | ||||
|     """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 + [ | ||||
|             "issuer", | ||||
|             "sso_url", | ||||
|             "name_id_policy", | ||||
|             "binding_type", | ||||
|             "slo_url", | ||||
|             "binding_type", | ||||
|             "name_id_policy", | ||||
|             "allow_idp_initiated", | ||||
|             "temporary_user_delete_after", | ||||
|             "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"), | ||||
|         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( | ||||
|         choices=SAMLNameIDPolicy.choices, | ||||
|         default=SAMLNameIDPolicy.TRANSIENT, | ||||
| @ -66,14 +81,6 @@ class SAMLSource(Source): | ||||
|         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( | ||||
|         default="days=1", | ||||
|         verbose_name=_("Delete temporary users after"), | ||||
|  | ||||
| @ -20,6 +20,8 @@ from passbook.sources.saml.processors.constants import ( | ||||
|     NS_SAML_PROTOCOL, | ||||
| ) | ||||
|  | ||||
| SESSION_REQUEST_ID = "passbook_source_saml_request_id" | ||||
|  | ||||
|  | ||||
| class RequestProcessor: | ||||
|     """SAML AuthnRequest Processor""" | ||||
| @ -37,6 +39,7 @@ class RequestProcessor: | ||||
|         self.http_request = request | ||||
|         self.relay_state = relay_state | ||||
|         self.request_id = get_random_id() | ||||
|         self.http_request.session[SESSION_REQUEST_ID] = self.request_id | ||||
|         self.issue_instant = get_time_string() | ||||
|  | ||||
|     def get_issuer(self) -> Element: | ||||
|  | ||||
| @ -2,6 +2,8 @@ | ||||
| from typing import TYPE_CHECKING, Dict | ||||
|  | ||||
| from defusedxml import ElementTree | ||||
| from django.core.cache import cache | ||||
| from django.core.exceptions import SuspiciousOperation | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from signxml import XMLVerifier | ||||
| 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.providers.saml.utils.encoding import decode_base64_and_inflate | ||||
| from passbook.sources.saml.exceptions import ( | ||||
|     MismatchedRequestID, | ||||
|     MissingSAMLResponse, | ||||
|     UnsupportedNameIDFormat, | ||||
| ) | ||||
| @ -29,12 +32,15 @@ from passbook.sources.saml.processors.constants import ( | ||||
|     SAML_NAME_ID_FORMAT_WINDOWS, | ||||
|     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.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| if TYPE_CHECKING: | ||||
|     from xml.etree.ElementTree import Element  # nosec | ||||
|  | ||||
| CACHE_SEEN_REQUEST_ID = "passbook_saml_seen_ids_%s" | ||||
| DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend" | ||||
|  | ||||
|  | ||||
| @ -59,8 +65,9 @@ class ResponseProcessor: | ||||
|         # Check if response is compressed, b64 decode it | ||||
|         self._root_xml = decode_base64_and_inflate(raw_response) | ||||
|         self._root = ElementTree.fromstring(self._root_xml) | ||||
|         # Verify signed XML | ||||
|  | ||||
|         self._verify_signed() | ||||
|         self._verify_request_id(request) | ||||
|  | ||||
|     def _verify_signed(self): | ||||
|         """Verify SAML Response's Signature""" | ||||
| @ -70,6 +77,26 @@ class ResponseProcessor: | ||||
|         ) | ||||
|         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: | ||||
|         """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 | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from django.conf import settings | ||||
| from django.shortcuts import reverse | ||||
| 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.flows.markers import StageMarker | ||||
| @ -50,6 +50,6 @@ class TestCaptchaStage(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -3,7 +3,7 @@ from time import sleep | ||||
|  | ||||
| from django.shortcuts import reverse | ||||
| 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.tasks import clean_expired_models | ||||
| @ -49,7 +49,7 @@ class TestConsentStage(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|         self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) | ||||
| @ -80,7 +80,7 @@ class TestConsentStage(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|         self.assertTrue( | ||||
| @ -117,7 +117,7 @@ class TestConsentStage(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|         self.assertTrue( | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """dummy tests""" | ||||
| from django.shortcuts import reverse | ||||
| 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.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||
| @ -44,7 +44,7 @@ class TestDummyStage(TestCase): | ||||
|         response = self.client.post(url, {}) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -26,7 +26,7 @@ | ||||
|  | ||||
|                     <!-- START CENTERED WHITE CONTAINER --> | ||||
|                     <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 --> | ||||
|                         <tr> | ||||
|                             <td class="wrapper"> | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| """passbook core inlining template tags""" | ||||
| from base64 import b64encode | ||||
| from pathlib import Path | ||||
|  | ||||
| from django import template | ||||
| @ -9,16 +10,22 @@ register = template.Library() | ||||
|  | ||||
| @register.simple_tag() | ||||
| def inline_static_ascii(path: str) -> str: | ||||
|     """Inline static asset. Doesn't check file contents, plain text is assumed""" | ||||
|     result = finders.find(path) | ||||
|     with open(result) as _file: | ||||
|         return _file.read() | ||||
|     """Inline static asset. Doesn't check file contents, plain text is assumed. | ||||
|     If no file could be found, original path is returned""" | ||||
|     result = Path(finders.find(path)) | ||||
|     if result: | ||||
|         with open(result) as _file: | ||||
|             return _file.read() | ||||
|     return path | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def inline_static_binary(path: str) -> str: | ||||
|     """Inline static asset. Uses file extension for base64 block""" | ||||
|     result = finders.find(path) | ||||
|     suffix = Path(path).suffix | ||||
|     with open(result) as _file: | ||||
|         return f"data:image/{suffix};base64," + _file.read() | ||||
|     """Inline static asset. Uses file extension for base64 block. If no file could be found, | ||||
|     path is returned.""" | ||||
|     result = Path(finders.find(path)) | ||||
|     if result and result.is_file(): | ||||
|         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.shortcuts import reverse | ||||
| 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.flows.markers import StageMarker | ||||
| @ -114,7 +114,7 @@ class TestEmailStage(TestCase): | ||||
|  | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|             self.assertJSONEqual( | ||||
|                 force_text(response.content), | ||||
|                 force_str(response.content), | ||||
|                 {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|             ) | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """identification tests""" | ||||
| from django.shortcuts import reverse | ||||
| 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.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||
| @ -56,7 +56,7 @@ class TestIdentificationStage(TestCase): | ||||
|         response = self.client.post(url, form_data) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -101,7 +101,7 @@ class TestIdentificationStage(TestCase): | ||||
|             ), | ||||
|         ) | ||||
|         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): | ||||
|         """Test that recovery flow is linked correctly""" | ||||
| @ -122,4 +122,4 @@ class TestIdentificationStage(TestCase): | ||||
|             ), | ||||
|         ) | ||||
|         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.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 passbook.core.models import User | ||||
| @ -59,7 +59,7 @@ class TestUserLoginStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||
|         ) | ||||
|  | ||||
| @ -86,7 +86,7 @@ class TestUserLoginStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -125,6 +125,6 @@ class TestUserLoginStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from typing import Any, Dict | ||||
|  | ||||
| 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_otp.plugins.otp_totp.models import TOTPDevice | ||||
| from lxml.etree import tostring  # nosec | ||||
| @ -35,7 +35,7 @@ class OTPTimeStageView(FormView, StageView): | ||||
|         """Get QR Code SVG as string based on `device`""" | ||||
|         qr_code = QRCode(image_factory=SvgFillImage) | ||||
|         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: | ||||
|         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.shortcuts import reverse | ||||
| 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.flows.markers import StageMarker | ||||
| @ -61,7 +61,7 @@ class TestPasswordStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||
|         ) | ||||
|  | ||||
| @ -84,7 +84,7 @@ class TestPasswordStage(TestCase): | ||||
|             ), | ||||
|         ) | ||||
|         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): | ||||
|         """Test with a valid pending user and valid password""" | ||||
| @ -106,7 +106,7 @@ class TestPasswordStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -154,6 +154,6 @@ class TestPasswordStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||
|         ) | ||||
|  | ||||
| @ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch | ||||
|  | ||||
| from django.shortcuts import reverse | ||||
| 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.flows.markers import StageMarker | ||||
| @ -110,9 +110,9 @@ class TestPromptStage(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         for prompt in self.stage.fields.all(): | ||||
|             self.assertIn(prompt.field_key, force_text(response.content)) | ||||
|             self.assertIn(prompt.label, force_text(response.content)) | ||||
|             self.assertIn(prompt.placeholder, force_text(response.content)) | ||||
|             self.assertIn(prompt.field_key, force_str(response.content)) | ||||
|             self.assertIn(prompt.label, force_str(response.content)) | ||||
|             self.assertIn(prompt.placeholder, force_str(response.content)) | ||||
|  | ||||
|     def test_valid_form_with_policy(self) -> PromptForm: | ||||
|         """Test form validation""" | ||||
| @ -164,7 +164,7 @@ class TestPromptStage(TestCase): | ||||
|             ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """delete tests""" | ||||
| from django.shortcuts import reverse | ||||
| 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.flows.markers import StageMarker | ||||
| @ -44,7 +44,7 @@ class TestUserDeleteStage(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||
|         ) | ||||
|  | ||||
| @ -83,7 +83,7 @@ class TestUserDeleteStage(TestCase): | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """login tests""" | ||||
| from django.shortcuts import reverse | ||||
| 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.flows.markers import StageMarker | ||||
| @ -50,7 +50,7 @@ class TestUserLoginStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_core:overview")}, | ||||
|         ) | ||||
|  | ||||
| @ -71,7 +71,7 @@ class TestUserLoginStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||
|         ) | ||||
|  | ||||
| @ -93,7 +93,7 @@ class TestUserLoginStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"type": "redirect", "to": reverse("passbook_flows:denied")}, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """logout tests""" | ||||
| from django.shortcuts import reverse | ||||
| 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.flows.markers import StageMarker | ||||
| @ -50,7 +50,7 @@ class TestUserLogoutStage(TestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             force_text(response.content), | ||||
|             force_str(response.content), | ||||
|             {"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
	