Compare commits
	
		
			218 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 39ad9d7c9d | |||
| 20d09c14b2 | |||
| 3a4d514bae | |||
| 4932846e14 | |||
| bb62aa7c7f | |||
| 907b837301 | |||
| b60a3d45dc | |||
| 3f5585ca84 | |||
| ba9a4efc9b | |||
| 902378af53 | |||
| 2352a7f4d6 | |||
| d89266a9d2 | |||
| d678d33756 | |||
| 49d0ccd9c7 | |||
| ea082ed9ef | |||
| d62fc9766c | |||
| 983747b13b | |||
| de4710ea71 | |||
| d55b31dd82 | |||
| d87871f806 | |||
| 148194e12b | |||
| a2c587be43 | |||
| 673da2a96e | |||
| a9a7b26264 | |||
| 83d2c442a5 | |||
| 4029e19b72 | |||
| 538a466090 | |||
| 322a343c81 | |||
| 6ddd6bfa72 | |||
| 36de302250 | |||
| 9eb13c50e9 | |||
| cffc6a1b88 | |||
| ba437beacc | |||
| da32b05eba | |||
| 45b7e7565d | |||
| a0b63f50bf | |||
| dc5d571c99 | |||
| 05161db458 | |||
| 311ffa9f79 | |||
| 7cbe33d65d | |||
| be9ca48de0 | |||
| b3159a74e5 | |||
| 89fafff0af | |||
| ae77c872a0 | |||
| 5f13563e03 | |||
| e17c9040bb | |||
| 280ef3d265 | |||
| a5bb583268 | |||
| 212ff11b6d | |||
| 1fa9d70945 | |||
| eeeaa9317b | |||
| 09b932100f | |||
| aa701c5725 | |||
| 6f98833150 | |||
| 30aa24ce6e | |||
| a426a1a0b6 | |||
| 061c549a40 | |||
| efa09d5e1d | |||
| 4fe0bd4b6c | |||
| 7c2decf5ec | |||
| 7f39399c32 | |||
| 7fd78a591d | |||
| bdb84b7a8f | |||
| 84e9748340 | |||
| 7dfc621ae4 | |||
| cd0a6f2d7c | |||
| b7835a751b | |||
| fd197ceee7 | |||
| be5c8341d2 | |||
| 2036827f04 | |||
| 35665d248e | |||
| bc30b41157 | |||
| 2af7fab42c | |||
| 4de205809b | |||
| e8433472fd | |||
| 3896299312 | |||
| 5cfbb0993a | |||
| a62e3557ac | |||
| 626936636a | |||
| 85ec713213 | |||
| 406bbdcfc9 | |||
| 02f87032cc | |||
| b7a929d304 | |||
| 3c0cc27ea1 | |||
| ec254d5927 | |||
| 92ba77e9e5 | |||
| 7ddb459030 | |||
| 076e89b600 | |||
| ba5fa2a04f | |||
| 90fe1c2ce8 | |||
| 85f88e785f | |||
| a7c4f81275 | |||
| 396fbc4a76 | |||
| 2dcd0128aa | |||
| e5aa9e0774 | |||
| 53d78d561b | |||
| 93001d1329 | |||
| 40428f5a82 | |||
| 007838fcf2 | |||
| 5e03b27348 | |||
| 7c51afa36c | |||
| 38fd5c5614 | |||
| 7e3148fab5 | |||
| 948db46406 | |||
| cccddd8c69 | |||
| adc4cd9c0d | |||
| abed254ca1 | |||
| edfab0995f | |||
| 528dedf99d | |||
| 5d7eec3049 | |||
| ad44567ebe | |||
| ac82002339 | |||
| df92111296 | |||
| da8417a141 | |||
| 7f32355e3e | |||
| 5afe88a605 | |||
| 320dab3425 | |||
| ca44f8bd60 | |||
| 5fd408ca82 | |||
| becb9e34b5 | |||
| 4917ab9985 | |||
| bd92505bc2 | |||
| 30033d1f90 | |||
| 3e5dfcbd0f | |||
| bf0141acc6 | |||
| 0c8d513567 | |||
| d07704fdf1 | |||
| 086a8753c0 | |||
| ae7a6e2fd6 | |||
| 6a4ddcaba7 | |||
| 2c9b596f01 | |||
| 7257108091 | |||
| 91f7b289cc | |||
| 77a507d2f8 | |||
| 3e60e956f4 | |||
| 84ec70c2a2 | |||
| 72846f0ae1 | |||
| dd53e7e9b1 | |||
| 9df16a9ae0 | |||
| 3dc9e247d5 | |||
| 02dd44eeec | |||
| 2f78e14381 | |||
| ef6f692526 | |||
| 2dd575874b | |||
| 84c2ebabaa | |||
| 3e26170f4b | |||
| 4709dca33c | |||
| 6064a481fb | |||
| 3979b0bde7 | |||
| 4280847bcc | |||
| ade8644da6 | |||
| 3c3fd53999 | |||
| 7b823f23ae | |||
| a67bea95d4 | |||
| 775e0ef2fa | |||
| d102c59654 | |||
| 03448a9169 | |||
| 1e6c081e5c | |||
| 8b9ce4a745 | |||
| 2a0bd50e23 | |||
| 014d93d485 | |||
| ff42663d3c | |||
| ce49d7ea5b | |||
| 8429dd19b2 | |||
| 680b182d95 | |||
| b2a832175e | |||
| b3ce8331f5 | |||
| ef0f618234 | |||
| b8a7186a55 | |||
| b39530f873 | |||
| 7937c84f2b | |||
| 621843c60c | |||
| c19da839b1 | |||
| fea1f3be6f | |||
| 6f5ec7838f | |||
| 94300492e7 | |||
| 5d3931c128 | |||
| 262a8b5ae8 | |||
| fe069c5e55 | |||
| c6e60c0ebc | |||
| 90b457c5ee | |||
| 5e724e4299 | |||
| b4c8dd6b91 | |||
| 63d163cc65 | |||
| 2b1356bb91 | |||
| ba9edd6c44 | |||
| 3b2b3262d7 | |||
| 5431e7fe9d | |||
| 7d9c74ce04 | |||
| 60c3cf890a | |||
| 4ec5df6b12 | |||
| 0403f6d373 | |||
| b7f4d15a94 | |||
| 56450887ca | |||
| 9bd613a31d | |||
| 3fe0483dbf | |||
| 63a28ca1e9 | |||
| 2543b075be | |||
| b8bdf7a035 | |||
| a3ff7cea23 | |||
| bb776c2710 | |||
| c9ad87d419 | |||
| 0d81eaffff | |||
| 6930c84425 | |||
| eaaeaccf5d | |||
| efbbd0adcf | |||
| c8d9771640 | |||
| 1554dc9feb | |||
| 1005f341e4 | |||
| 2b98637ca5 | |||
| e3f7185564 | |||
| d1198fc6c1 | |||
| 8cb5f8fbee | |||
| fad5b09aee | |||
| b98895ac2c | |||
| 6dc38b0132 | |||
| e154e28611 | |||
| 690b7be1d8 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2021.6.2 | current_version = 2021.7.1-rc1 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||||
| @ -21,14 +21,14 @@ values = | |||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
|  | [bumpversion:file:schema.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:.github/workflows/release.yml] | [bumpversion:file:.github/workflows/release.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:authentik/__init__.py] | [bumpversion:file:authentik/__init__.py] | ||||||
|  |  | ||||||
| [bumpversion:file:internal/constants/constants.go] | [bumpversion:file:internal/constants/constants.go] | ||||||
|  |  | ||||||
| [bumpversion:file:outpost/pkg/version.go] |  | ||||||
|  |  | ||||||
| [bumpversion:file:web/src/constants.ts] | [bumpversion:file:web/src/constants.ts] | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md] | [bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md] | ||||||
|  | |||||||
| @ -3,3 +3,6 @@ static | |||||||
| htmlcov | htmlcov | ||||||
| *.env.yml | *.env.yml | ||||||
| **/node_modules | **/node_modules | ||||||
|  | dist/** | ||||||
|  | build/** | ||||||
|  | build_docs/** | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,7 +9,7 @@ updates: | |||||||
|   assignees: |   assignees: | ||||||
|   - BeryJu |   - BeryJu | ||||||
| - package-ecosystem: gomod | - package-ecosystem: gomod | ||||||
|   directory: "/outpost" |   directory: "/" | ||||||
|   schedule: |   schedule: | ||||||
|     interval: daily |     interval: daily | ||||||
|     time: "04:00" |     time: "04:00" | ||||||
| @ -48,11 +48,3 @@ updates: | |||||||
|   open-pull-requests-limit: 10 |   open-pull-requests-limit: 10 | ||||||
|   assignees: |   assignees: | ||||||
|   - BeryJu |   - BeryJu | ||||||
| - package-ecosystem: docker |  | ||||||
|   directory: "/outpost" |  | ||||||
|   schedule: |  | ||||||
|     interval: daily |  | ||||||
|     time: "04:00" |  | ||||||
|   open-pull-requests-limit: 10 |  | ||||||
|   assignees: |  | ||||||
|   - BeryJu |  | ||||||
|  | |||||||
							
								
								
									
										26
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -33,14 +33,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik:2021.6.2, |             beryju/authentik:2021.7.1-rc1, | ||||||
|             beryju/authentik:latest, |             beryju/authentik:latest, | ||||||
|             ghcr.io/goauthentik/server:2021.6.2, |             ghcr.io/goauthentik/server:2021.7.1-rc1, | ||||||
|             ghcr.io/goauthentik/server:latest |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.6.2', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.7.1-rc1', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik:latest |           docker pull beryju/authentik:latest | ||||||
|           docker tag beryju/authentik:latest beryju/authentik:stable |           docker tag beryju/authentik:latest beryju/authentik:stable | ||||||
| @ -75,14 +75,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-proxy:2021.6.2, |             beryju/authentik-proxy:2021.7.1-rc1, | ||||||
|             beryju/authentik-proxy:latest, |             beryju/authentik-proxy:latest, | ||||||
|             ghcr.io/goauthentik/proxy:2021.6.2, |             ghcr.io/goauthentik/proxy:2021.7.1-rc1, | ||||||
|             ghcr.io/goauthentik/proxy:latest |             ghcr.io/goauthentik/proxy:latest | ||||||
|           file: outpost/proxy.Dockerfile |           file: proxy.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.6.2', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.7.1-rc1', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik-proxy:latest |           docker pull beryju/authentik-proxy:latest | ||||||
|           docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable |           docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable | ||||||
| @ -117,14 +117,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-ldap:2021.6.2, |             beryju/authentik-ldap:2021.7.1-rc1, | ||||||
|             beryju/authentik-ldap:latest, |             beryju/authentik-ldap:latest, | ||||||
|             ghcr.io/goauthentik/ldap:2021.6.2, |             ghcr.io/goauthentik/ldap:2021.7.1-rc1, | ||||||
|             ghcr.io/goauthentik/ldap:latest |             ghcr.io/goauthentik/ldap:latest | ||||||
|           file: outpost/ldap.Dockerfile |           file: ldap.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.6.2', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.7.1-rc1', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik-ldap:latest |           docker pull beryju/authentik-ldap:latest | ||||||
|           docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable |           docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable | ||||||
| @ -157,7 +157,7 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - name: Setup Node.js environment |       - name: Setup Node.js environment | ||||||
|         uses: actions/setup-node@v2.1.5 |         uses: actions/setup-node@v2.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: 12.x |           node-version: 12.x | ||||||
|       - name: Build web api client and web ui |       - name: Build web api client and web ui | ||||||
| @ -176,6 +176,6 @@ jobs: | |||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2021.6.2 |           version: authentik@2021.7.1-rc1 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|           sourcemaps: './web/dist' |           sourcemaps: './web/dist' | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -200,3 +200,4 @@ media/ | |||||||
| *mmdb | *mmdb | ||||||
|  |  | ||||||
| .idea/ | .idea/ | ||||||
|  | api/ | ||||||
|  | |||||||
							
								
								
									
										50
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										50
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -10,8 +10,16 @@ RUN pip install pipenv && \ | |||||||
|     pipenv lock -r > requirements.txt && \ |     pipenv lock -r > requirements.txt && \ | ||||||
|     pipenv lock -r --dev-only > requirements-dev.txt |     pipenv lock -r --dev-only > requirements-dev.txt | ||||||
|  |  | ||||||
| # Stage 2: Build web API | # Stage 2: Build website | ||||||
| FROM openapitools/openapi-generator-cli as api-builder | FROM node as website-builder | ||||||
|  |  | ||||||
|  | COPY ./website /static/ | ||||||
|  |  | ||||||
|  | ENV NODE_ENV=production | ||||||
|  | RUN cd /static && npm i && npm run build-docs-only | ||||||
|  |  | ||||||
|  | # Stage 3: Build web API | ||||||
|  | FROM openapitools/openapi-generator-cli as web-api-builder | ||||||
|  |  | ||||||
| COPY ./schema.yml /local/schema.yml | COPY ./schema.yml /local/schema.yml | ||||||
|  |  | ||||||
| @ -21,34 +29,52 @@ RUN	docker-entrypoint.sh generate \ | |||||||
|     -o /local/web/api \ |     -o /local/web/api \ | ||||||
|     --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 |     --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | ||||||
|  |  | ||||||
| # Stage 3: Build webui | # Stage 3: Generate API Client | ||||||
| FROM node as npm-builder | FROM openapitools/openapi-generator-cli as go-api-builder | ||||||
|  |  | ||||||
|  | COPY ./schema.yml /local/schema.yml | ||||||
|  |  | ||||||
|  | RUN	docker-entrypoint.sh generate \ | ||||||
|  |     --git-host goauthentik.io \ | ||||||
|  |     --git-repo-id outpost \ | ||||||
|  |     --git-user-id api \ | ||||||
|  |     -i /local/schema.yml \ | ||||||
|  |     -g go \ | ||||||
|  |     -o /local/api \ | ||||||
|  |     --additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true && \ | ||||||
|  |     rm -f /local/api/go.mod /local/api/go.sum | ||||||
|  |  | ||||||
|  | # Stage 4: Build webui | ||||||
|  | FROM node as web-builder | ||||||
|  |  | ||||||
| COPY ./web /static/ | COPY ./web /static/ | ||||||
| COPY --from=api-builder /local/web/api /static/api | COPY --from=web-api-builder /local/web/api /static/api | ||||||
|  |  | ||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
| RUN cd /static && npm i && npm run build | RUN cd /static && npm i && npm run build | ||||||
|  |  | ||||||
| # Stage 4: Build go proxy | # Stage 5: Build go proxy | ||||||
| FROM golang:1.16.5 AS builder | FROM golang:1.16.6 AS builder | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
|  |  | ||||||
| COPY --from=npm-builder /static/robots.txt /work/web/robots.txt | COPY --from=web-builder /static/robots.txt /work/web/robots.txt | ||||||
| COPY --from=npm-builder /static/security.txt /work/web/security.txt | COPY --from=web-builder /static/security.txt /work/web/security.txt | ||||||
| COPY --from=npm-builder /static/dist/ /work/web/dist/ | COPY --from=web-builder /static/dist/ /work/web/dist/ | ||||||
| COPY --from=npm-builder /static/authentik/ /work/web/authentik/ | COPY --from=web-builder /static/authentik/ /work/web/authentik/ | ||||||
|  | COPY --from=website-builder /static/help/ /work/website/help/ | ||||||
|  |  | ||||||
|  | COPY --from=go-api-builder /local/api api | ||||||
| COPY ./cmd /work/cmd | COPY ./cmd /work/cmd | ||||||
| COPY ./web/static.go /work/web/static.go | COPY ./web/static.go /work/web/static.go | ||||||
|  | COPY ./website/static.go /work/website/static.go | ||||||
| COPY ./internal /work/internal | COPY ./internal /work/internal | ||||||
| COPY ./go.mod /work/go.mod | COPY ./go.mod /work/go.mod | ||||||
| COPY ./go.sum /work/go.sum | COPY ./go.sum /work/go.sum | ||||||
|  |  | ||||||
| RUN go build -o /work/authentik ./cmd/server/main.go | RUN go build -o /work/authentik ./cmd/server/main.go | ||||||
|  |  | ||||||
| # Stage 5: Run | # Stage 6: Run | ||||||
| FROM python:3.9-slim-buster | FROM python:3.9-slim-buster | ||||||
|  |  | ||||||
| WORKDIR / | WORKDIR / | ||||||
|  | |||||||
							
								
								
									
										9
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								Makefile
									
									
									
									
									
								
							| @ -32,7 +32,7 @@ gen-build: | |||||||
|  |  | ||||||
| gen-clean: | gen-clean: | ||||||
| 	rm -rf web/api/src/ | 	rm -rf web/api/src/ | ||||||
| 	rm -rf outpost/api/ | 	rm -rf api/ | ||||||
|  |  | ||||||
| gen-web: | gen-web: | ||||||
| 	docker run \ | 	docker run \ | ||||||
| @ -55,11 +55,14 @@ gen-outpost: | |||||||
| 		--git-user-id api \ | 		--git-user-id api \ | ||||||
| 		-i /local/schema.yml \ | 		-i /local/schema.yml \ | ||||||
| 		-g go \ | 		-g go \ | ||||||
| 		-o /local/outpost/api \ | 		-o /local/api \ | ||||||
| 		--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true | 		--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true | ||||||
| 	rm -f outpost/api/go.mod outpost/api/go.sum | 	rm -f api/go.mod api/go.sum | ||||||
|  |  | ||||||
| gen: gen-build gen-clean gen-web gen-outpost | gen: gen-build gen-clean gen-web gen-outpost | ||||||
|  |  | ||||||
|  | migrate: | ||||||
|  | 	python -m lifecycle.migrate | ||||||
|  |  | ||||||
| run: | run: | ||||||
| 	go run -v cmd/server/main.go | 	go run -v cmd/server/main.go | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @ -47,6 +47,7 @@ xmlsec = "*" | |||||||
| duo-client = "*" | duo-client = "*" | ||||||
| ua-parser = "*" | ua-parser = "*" | ||||||
| deepmerge = "*" | deepmerge = "*" | ||||||
|  | colorama = "*" | ||||||
|  |  | ||||||
| [requires] | [requires] | ||||||
| python_version = "3.9" | python_version = "3.9" | ||||||
|  | |||||||
							
								
								
									
										413
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										413
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "f90d9fb4713eaf9c5ffe6a3858e64843670f79ab5007e7debf914c1f094c8d63" |             "sha256": "e4f2e57bd5c709809515ab2b95eb3f5fa337d4a9334f4110a24bf28c3f9d5f8f" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": { |         "requires": { | ||||||
| @ -76,11 +76,11 @@ | |||||||
|         }, |         }, | ||||||
|         "asgiref": { |         "asgiref": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", |                 "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", | ||||||
|                 "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" |                 "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==3.3.4" |             "version": "==3.4.1" | ||||||
|         }, |         }, | ||||||
|         "async-timeout": { |         "async-timeout": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -122,19 +122,19 @@ | |||||||
|         }, |         }, | ||||||
|         "boto3": { |         "boto3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2c2f70608934b03f9c08f4cd185de223b5abd18245dd4d4800e1fbc2a2523e31", |                 "sha256:13e60f88d13161df951d6e52bd483cdbe1a36a31f818746289d8ba0879465710", | ||||||
|                 "sha256:fccfa81cda69bb2317ed97e7149d7d84d19e6ec3bfbe3f721139e7ac0c407c73" |                 "sha256:3be2f259b279d69495433e3288db3670817fdb1813cfde92abf867bba3ad8148" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.17.98" |             "version": "==1.18.3" | ||||||
|         }, |         }, | ||||||
|         "botocore": { |         "botocore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b2a49de4ee04b690142c8e7240f0f5758e3f7673dd39cf398efe893bf5e11c3f", |                 "sha256:0b6f378c9efbc72eee61aba1e16cab90bde53a37bd2d861f6435552fd7030adf", | ||||||
|                 "sha256:b955b23fe2fbdbbc8e66f37fe2970de6b5d8169f940b200bcf434751709d38f6" |                 "sha256:285ab9459cdd49d4a9322692c6e13772b97af723a03c0eed519b589446491a5b" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==1.20.98" |             "version": "==1.21.3" | ||||||
|         }, |         }, | ||||||
|         "cachetools": { |         "cachetools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -165,11 +165,11 @@ | |||||||
|         }, |         }, | ||||||
|         "celery": { |         "celery": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:54436cd97b031bf2e08064223240e2a83d601d9414bcb1b702f94c6c33c29485", |                 "sha256:8d9a3de9162965e97f8e8cc584c67aad83b3f7a267584fa47701ed11c3e0d4b0", | ||||||
|                 "sha256:b5399d76cf70d5cfac3ec993f8796ec1aa90d4cef55972295751f384758a80d7" |                 "sha256:9dab2170b4038f7bf10ef2861dbf486ddf1d20592290a1040f7b7a1259705d42" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==5.1.1" |             "version": "==5.1.2" | ||||||
|         }, |         }, | ||||||
|         "certifi": { |         "certifi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -180,73 +180,69 @@ | |||||||
|         }, |         }, | ||||||
|         "cffi": { |         "cffi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", |                 "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d", | ||||||
|                 "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", |                 "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771", | ||||||
|                 "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", |                 "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872", | ||||||
|                 "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", |                 "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c", | ||||||
|                 "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", |                 "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc", | ||||||
|                 "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", |                 "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762", | ||||||
|                 "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", |                 "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202", | ||||||
|                 "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", |                 "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5", | ||||||
|                 "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", |                 "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548", | ||||||
|                 "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", |                 "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a", | ||||||
|                 "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", |                 "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f", | ||||||
|                 "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", |                 "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20", | ||||||
|                 "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", |                 "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218", | ||||||
|                 "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", |                 "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c", | ||||||
|                 "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", |                 "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e", | ||||||
|                 "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", |                 "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56", | ||||||
|                 "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", |                 "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224", | ||||||
|                 "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", |                 "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a", | ||||||
|                 "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", |                 "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2", | ||||||
|                 "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", |                 "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a", | ||||||
|                 "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", |                 "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819", | ||||||
|                 "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", |                 "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346", | ||||||
|                 "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", |                 "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b", | ||||||
|                 "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", |                 "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e", | ||||||
|                 "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", |                 "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534", | ||||||
|                 "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", |                 "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb", | ||||||
|                 "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", |                 "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0", | ||||||
|                 "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", |                 "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156", | ||||||
|                 "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", |                 "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd", | ||||||
|                 "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", |                 "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87", | ||||||
|                 "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", |                 "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc", | ||||||
|                 "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", |                 "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195", | ||||||
|                 "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", |                 "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33", | ||||||
|                 "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", |                 "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f", | ||||||
|                 "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", |                 "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d", | ||||||
|                 "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", |                 "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd", | ||||||
|                 "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", |                 "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728", | ||||||
|                 "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", |                 "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7", | ||||||
|                 "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", |                 "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca", | ||||||
|                 "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", |                 "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99", | ||||||
|                 "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", |                 "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf", | ||||||
|                 "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", |                 "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e", | ||||||
|                 "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", |                 "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c", | ||||||
|                 "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", |                 "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5", | ||||||
|                 "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", |                 "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69" | ||||||
|                 "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", |  | ||||||
|                 "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", |  | ||||||
|                 "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", |  | ||||||
|                 "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" |  | ||||||
|             ], |             ], | ||||||
|             "version": "==1.14.5" |             "version": "==1.14.6" | ||||||
|         }, |         }, | ||||||
|         "channels": { |         "channels": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:056b72e51080a517a0f33a0a30003e03833b551d75394d6636c885d4edb8188f", |                 "sha256:0ff0422b4224d10efac76e451575517f155fe7c97d369b5973b116f22eeaf86c", | ||||||
|                 "sha256:3f15bdd2138bb4796e76ea588a0a344b12a7964ea9b2e456f992fddb988a4317" |                 "sha256:fdd9a94987a23d8d7ebd97498ed8b8cc83163f37e53fc6c85098aba7a3bb8b75" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==3.0.3" |             "version": "==3.0.4" | ||||||
|         }, |         }, | ||||||
|         "channels-redis": { |         "channels-redis": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:18d63f6462a58011740dc8eeb57ea4b31ec220eb551cb71b27de9c6779a549de", |                 "sha256:0a18ce279c15ba79b7985bb12b2d6dd0ac8a14e4ad6952681f4422a4cc4a5ea9", | ||||||
|                 "sha256:2fb31a63b05373f6402da2e6a91a22b9e66eb8b56626c6bfc93e156c734c5ae6" |                 "sha256:1abd5820ff1ed4ac627f8a219ad389e4c87e52e47a230929a7a474e95dd2c6c2" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==3.2.0" |             "version": "==3.3.0" | ||||||
|         }, |         }, | ||||||
|         "chardet": { |         "chardet": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -256,6 +252,14 @@ | |||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==4.0.0" |             "version": "==4.0.0" | ||||||
|         }, |         }, | ||||||
|  |         "charset-normalizer": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1", | ||||||
|  |                 "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12" | ||||||
|  |             ], | ||||||
|  |             "markers": "python_version >= '3'", | ||||||
|  |             "version": "==2.0.3" | ||||||
|  |         }, | ||||||
|         "click": { |         "click": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", |                 "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", | ||||||
| @ -284,6 +288,14 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.2.0" |             "version": "==0.2.0" | ||||||
|         }, |         }, | ||||||
|  |         "colorama": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", | ||||||
|  |                 "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==0.4.4" | ||||||
|  |         }, | ||||||
|         "constantly": { |         "constantly": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", |                 "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", | ||||||
| @ -342,11 +354,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django": { |         "django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:66c9d8db8cc6fe938a28b7887c1596e42d522e27618562517cc8929eb7e7f296", |                 "sha256:3da05fea54fdec2315b54a563d5b59f3b4e2b1e69c3a5841dda35019c01855cd", | ||||||
|                 "sha256:ea735cbbbb3b2fba6d4da4784a0043d84c67c92f1fdf15ad6db69900e792c10f" |                 "sha256:c58b5f19c5ae0afe6d75cbdd7df561e6eb929339985dbbda2565e1cabb19a62e" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==3.2.4" |             "version": "==3.2.5" | ||||||
|         }, |         }, | ||||||
|         "django-dbbackup": { |         "django-dbbackup": { | ||||||
|             "git": "https://github.com/django-dbbackup/django-dbbackup.git", |             "git": "https://github.com/django-dbbackup/django-dbbackup.git", | ||||||
| @ -473,11 +485,11 @@ | |||||||
|         }, |         }, | ||||||
|         "google-auth": { |         "google-auth": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b3a67fa9ba5b768861dacf374c2135eb09fa14a0e40c851c3b8ea7abe6fc8fef", |                 "sha256:036dd68c1e8baa422b6b61619b8e02793da2e20f55e69514612de6c080468755", | ||||||
|                 "sha256:e34e5f5de5610b202f9b40ebd9f8b27571d5c5537db9afed3a72b2db5a345039" |                 "sha256:7665c04f2df13cc938dc7d9066cddb1f8af62b038bc8b2306848c1b23121865f" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", | ||||||
|             "version": "==1.32.0" |             "version": "==1.33.1" | ||||||
|         }, |         }, | ||||||
|         "gunicorn": { |         "gunicorn": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -571,10 +583,10 @@ | |||||||
|         }, |         }, | ||||||
|         "idna": { |         "idna": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", |                 "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", | ||||||
|                 "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" |                 "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" | ||||||
|             ], |             ], | ||||||
|             "version": "==2.10" |             "version": "==3.2" | ||||||
|         }, |         }, | ||||||
|         "incremental": { |         "incremental": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -624,14 +636,14 @@ | |||||||
|         }, |         }, | ||||||
|         "ldap3": { |         "ldap3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91", |                 "sha256:2bc966556fc4d4fa9f445a1c31dc484ee81d44a51ab0e2d0fd05b62cac75daa6", | ||||||
|                 "sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59", |                 "sha256:5630d1383e09ba94839e253e013f1aa1a2cf7a547628ba1265cb7b9a844b5687", | ||||||
|                 "sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c", |                 "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", | ||||||
|                 "sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056", |                 "sha256:5ab7febc00689181375de40c396dcad4f2659cd260fc5e94c508b6d77c17e9d5", | ||||||
|                 "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57" |                 "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==2.9" |             "version": "==2.9.1" | ||||||
|         }, |         }, | ||||||
|         "lxml": { |         "lxml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -778,11 +790,11 @@ | |||||||
|         }, |         }, | ||||||
|         "packaging": { |         "packaging": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", |                 "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", | ||||||
|                 "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" |                 "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==20.9" |             "version": "==21.0" | ||||||
|         }, |         }, | ||||||
|         "prometheus-client": { |         "prometheus-client": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -948,18 +960,38 @@ | |||||||
|         }, |         }, | ||||||
|         "pyrsistent": { |         "pyrsistent": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" |                 "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2", | ||||||
|  |                 "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7", | ||||||
|  |                 "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea", | ||||||
|  |                 "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426", | ||||||
|  |                 "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710", | ||||||
|  |                 "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1", | ||||||
|  |                 "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396", | ||||||
|  |                 "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2", | ||||||
|  |                 "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680", | ||||||
|  |                 "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35", | ||||||
|  |                 "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427", | ||||||
|  |                 "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b", | ||||||
|  |                 "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b", | ||||||
|  |                 "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f", | ||||||
|  |                 "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef", | ||||||
|  |                 "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c", | ||||||
|  |                 "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4", | ||||||
|  |                 "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d", | ||||||
|  |                 "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78", | ||||||
|  |                 "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b", | ||||||
|  |                 "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '3.5'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==0.17.3" |             "version": "==0.18.0" | ||||||
|         }, |         }, | ||||||
|         "python-dateutil": { |         "python-dateutil": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", |                 "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", | ||||||
|                 "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" |                 "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==2.8.1" |             "version": "==2.8.2" | ||||||
|         }, |         }, | ||||||
|         "python-dotenv": { |         "python-dotenv": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1020,11 +1052,11 @@ | |||||||
|         }, |         }, | ||||||
|         "requests": { |         "requests": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", |                 "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", | ||||||
|                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" |                 "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", | ||||||
|             "version": "==2.25.1" |             "version": "==2.26.0" | ||||||
|         }, |         }, | ||||||
|         "requests-oauthlib": { |         "requests-oauthlib": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1045,18 +1077,19 @@ | |||||||
|         }, |         }, | ||||||
|         "s3transfer": { |         "s3transfer": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc", |                 "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c", | ||||||
|                 "sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2" |                 "sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.4.2" |             "markers": "python_version >= '3.6'", | ||||||
|  |             "version": "==0.5.0" | ||||||
|         }, |         }, | ||||||
|         "sentry-sdk": { |         "sentry-sdk": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:c1227d38dca315ba35182373f129c3e2722e8ed999e52584e6aca7d287870739", |                 "sha256:5210a712dd57d88d225c1fc3fe3a3626fee493637bcd54e204826cf04b8d769c", | ||||||
|                 "sha256:c7d380a21281e15be3d9f67a3c4fbb4f800c481d88ff8d8931f39486dd7b4ada" |                 "sha256:6864dcb6f7dec692635e5518c2a5c80010adf673c70340817f1a1b713d65bb41" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.1.0" |             "version": "==1.3.0" | ||||||
|         }, |         }, | ||||||
|         "service-identity": { |         "service-identity": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1167,11 +1200,11 @@ | |||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", |                 "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", | ||||||
|                 "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" |                 "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.5" |             "version": "==1.26.6" | ||||||
|         }, |         }, | ||||||
|         "uvicorn": { |         "uvicorn": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
| @ -1186,18 +1219,18 @@ | |||||||
|         }, |         }, | ||||||
|         "uvloop": { |         "uvloop": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:114543c84e95df1b4ff546e6e3a27521580466a30127f12172a3278172ad68bc", |                 "sha256:0de811931e90ae2da9e19ce70ffad73047ab0c1dba7c6e74f9ae1a3aabeb89bd", | ||||||
|                 "sha256:19fa1d56c91341318ac5d417e7b61c56e9a41183946cc70c411341173de02c69", |                 "sha256:1ff05116ede1ebdd81802df339e5b1d4cab1dfbd99295bf27e90b4cec64d70e9", | ||||||
|                 "sha256:2bb0624a8a70834e54dde8feed62ed63b50bad7a1265c40d6403a2ac447bce01", |                 "sha256:2d8ffe44ae709f839c54bacf14ed283f41bee90430c3b398e521e10f8d117b3a", | ||||||
|                 "sha256:42eda9f525a208fbc4f7cecd00fa15c57cc57646c76632b3ba2fe005004f051d", |                 "sha256:5cda65fc60a645470b8525ce014516b120b7057b576fa876cdfdd5e60ab1efbb", | ||||||
|                 "sha256:44cac8575bf168601424302045234d74e3561fbdbac39b2b54cc1d1d00b70760", |                 "sha256:63a3288abbc9c8ee979d7e34c34e780b2fbab3e7e53d00b6c80271119f277399", | ||||||
|                 "sha256:6de130d0cb78985a5d080e323b86c5ecaf3af82f4890492c05981707852f983c", |                 "sha256:7522df4e45e4f25b50adbbbeb5bb9847495c438a628177099d2721f2751ff825", | ||||||
|                 "sha256:7ae39b11a5f4cec1432d706c21ecc62f9e04d116883178b09671aa29c46f7a47", |                 "sha256:7f4b8a905df909a407c5791fb582f6c03b0d3b491ecdc1cdceaefbc9bf9e08f6", | ||||||
|                 "sha256:90e56f17755e41b425ad19a08c41dc358fa7bf1226c0f8e54d4d02d556f7af7c", |                 "sha256:905f0adb0c09c9f44222ee02f6b96fd88b493478fffb7a345287f9444e926030", | ||||||
|                 "sha256:b45218c99795803fb8bdbc9435ff7f54e3a591b44cd4c121b02fa83affb61c7c", |                 "sha256:ae2b325c0f6d748027f7463077e457006b4fdb35a8788f01754aadba825285ee", | ||||||
|                 "sha256:e5e5f855c9bf483ee6cd1eb9a179b740de80cb0ae2988e3fa22309b78e2ea0e7" |                 "sha256:e71fb9038bfcd7646ca126c5ef19b17e48d4af9e838b2bcfda7a9f55a6552a32" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.15.2" |             "version": "==0.15.3" | ||||||
|         }, |         }, | ||||||
|         "vine": { |         "vine": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1403,11 +1436,11 @@ | |||||||
|         }, |         }, | ||||||
|         "astroid": { |         "astroid": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", |                 "sha256:6021561b2e87ed6b3c93c2682ac50079c65ab08f1e4e0277ba38f97e0e492185", | ||||||
|                 "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" |                 "sha256:a670dd7af3fe603f51aa7117462588b7c3bdcd58007edfaee752bf82eceecd28" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version ~= '3.6'", |             "markers": "python_version ~= '3.6'", | ||||||
|             "version": "==2.5.6" |             "version": "==2.6.4" | ||||||
|         }, |         }, | ||||||
|         "attrs": { |         "attrs": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1448,13 +1481,13 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==2021.5.30" |             "version": "==2021.5.30" | ||||||
|         }, |         }, | ||||||
|         "chardet": { |         "charset-normalizer": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", |                 "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1", | ||||||
|                 "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" |                 "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", |             "markers": "python_version >= '3'", | ||||||
|             "version": "==4.0.0" |             "version": "==2.0.3" | ||||||
|         }, |         }, | ||||||
|         "click": { |         "click": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1548,10 +1581,10 @@ | |||||||
|         }, |         }, | ||||||
|         "idna": { |         "idna": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", |                 "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", | ||||||
|                 "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" |                 "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" | ||||||
|             ], |             ], | ||||||
|             "version": "==2.10" |             "version": "==3.2" | ||||||
|         }, |         }, | ||||||
|         "iniconfig": { |         "iniconfig": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1562,11 +1595,11 @@ | |||||||
|         }, |         }, | ||||||
|         "isort": { |         "isort": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:83510593e07e433b77bd5bff0f6f607dbafa06d1a89022616f02d8b699cfcd56", |                 "sha256:eed17b53c3e7912425579853d078a0832820f023191561fcee9d7cae424e0813", | ||||||
|                 "sha256:8e2c107091cfec7286bc0f68a547d0ba4c094d460b732075b6fba674f1035c0c" |                 "sha256:f65ce5bd4cbc6abdfbe29afc2f0245538ab358c14590912df638033f157d555e" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", |             "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", | ||||||
|             "version": "==5.9.1" |             "version": "==5.9.2" | ||||||
|         }, |         }, | ||||||
|         "lazy-object-proxy": { |         "lazy-object-proxy": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1612,18 +1645,18 @@ | |||||||
|         }, |         }, | ||||||
|         "packaging": { |         "packaging": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", |                 "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", | ||||||
|                 "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" |                 "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==20.9" |             "version": "==21.0" | ||||||
|         }, |         }, | ||||||
|         "pathspec": { |         "pathspec": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", |                 "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", | ||||||
|                 "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" |                 "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.8.1" |             "version": "==0.9.0" | ||||||
|         }, |         }, | ||||||
|         "pbr": { |         "pbr": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1651,11 +1684,11 @@ | |||||||
|         }, |         }, | ||||||
|         "pylint": { |         "pylint": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0a049c5d47b629d9070c3932d13bff482b12119b6a241a93bc460b0be16953c8", |                 "sha256:2a971129fb2d594068913a7e531d4b6d2785b2a68c6857e2baa40d3214da30f4", | ||||||
|                 "sha256:792b38ff30903884e4a9eab814ee3523731abd3c463f3ba48d7b627e87013484" |                 "sha256:a622c4c4c79dc8fe5e784efccacec3afe9d5e5ffab5fda2264fb5afa7c9b5797" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==2.8.3" |             "version": "==2.9.4" | ||||||
|         }, |         }, | ||||||
|         "pylint-django": { |         "pylint-django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1733,57 +1766,57 @@ | |||||||
|         }, |         }, | ||||||
|         "regex": { |         "regex": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5", |                 "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f", | ||||||
|                 "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79", |                 "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad", | ||||||
|                 "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31", |                 "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a", | ||||||
|                 "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500", |                 "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf", | ||||||
|                 "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11", |                 "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59", | ||||||
|                 "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14", |                 "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d", | ||||||
|                 "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3", |                 "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895", | ||||||
|                 "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439", |                 "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4", | ||||||
|                 "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c", |                 "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3", | ||||||
|                 "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82", |                 "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222", | ||||||
|                 "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711", |                 "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0", | ||||||
|                 "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093", |                 "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c", | ||||||
|                 "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a", |                 "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417", | ||||||
|                 "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb", |                 "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d", | ||||||
|                 "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8", |                 "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d", | ||||||
|                 "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17", |                 "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761", | ||||||
|                 "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000", |                 "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0", | ||||||
|                 "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d", |                 "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026", | ||||||
|                 "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480", |                 "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854", | ||||||
|                 "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc", |                 "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb", | ||||||
|                 "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0", |                 "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d", | ||||||
|                 "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9", |                 "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068", | ||||||
|                 "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765", |                 "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde", | ||||||
|                 "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e", |                 "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d", | ||||||
|                 "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a", |                 "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec", | ||||||
|                 "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07", |                 "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa", | ||||||
|                 "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f", |                 "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd", | ||||||
|                 "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac", |                 "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b", | ||||||
|                 "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7", |                 "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26", | ||||||
|                 "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed", |                 "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2", | ||||||
|                 "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968", |                 "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f", | ||||||
|                 "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7", |                 "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694", | ||||||
|                 "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2", |                 "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0", | ||||||
|                 "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4", |                 "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407", | ||||||
|                 "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87", |                 "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874", | ||||||
|                 "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8", |                 "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035", | ||||||
|                 "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10", |                 "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d", | ||||||
|                 "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29", |                 "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c", | ||||||
|                 "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605", |                 "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5", | ||||||
|                 "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6", |                 "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985", | ||||||
|                 "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042" |                 "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58" | ||||||
|             ], |             ], | ||||||
|             "version": "==2021.4.4" |             "version": "==2021.7.6" | ||||||
|         }, |         }, | ||||||
|         "requests": { |         "requests": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", |                 "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", | ||||||
|                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" |                 "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" | ||||||
|             ], |             ], | ||||||
|             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", | ||||||
|             "version": "==2.25.1" |             "version": "==2.26.0" | ||||||
|         }, |         }, | ||||||
|         "requests-mock": { |         "requests-mock": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1838,11 +1871,11 @@ | |||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", |                 "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", | ||||||
|                 "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" |                 "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.5" |             "version": "==1.26.6" | ||||||
|         }, |         }, | ||||||
|         "wrapt": { |         "wrapt": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|  | |||||||
| @ -1,3 +1,3 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "2021.6.2" | __version__ = "2021.7.1-rc1" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  | |||||||
| @ -19,7 +19,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]: | |||||||
|     auth_credentials = raw_header.decode() |     auth_credentials = raw_header.decode() | ||||||
|     if auth_credentials == "" or " " not in auth_credentials: |     if auth_credentials == "" or " " not in auth_credentials: | ||||||
|         return None |         return None | ||||||
|     auth_type, auth_credentials = auth_credentials.split() |     auth_type, _, auth_credentials = auth_credentials.partition(" ") | ||||||
|     if auth_type.lower() not in ["basic", "bearer"]: |     if auth_type.lower() not in ["basic", "bearer"]: | ||||||
|         LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) |         LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) | ||||||
|         raise AuthenticationFailed("Unsupported authentication type") |         raise AuthenticationFailed("Unsupported authentication type") | ||||||
|  | |||||||
| @ -49,7 +49,7 @@ class ConfigView(APIView): | |||||||
|             caps.append(Capabilities.CAN_GEO_IP) |             caps.append(Capabilities.CAN_GEO_IP) | ||||||
|         if SERVICE_HOST_ENV_NAME in environ: |         if SERVICE_HOST_ENV_NAME in environ: | ||||||
|             # Running in k8s, only s3 backup is supported |             # Running in k8s, only s3 backup is supported | ||||||
|             if CONFIG.y("postgresql.s3_backup"): |             if CONFIG.y_bool("postgresql.s3_backup"): | ||||||
|                 caps.append(Capabilities.CAN_BACKUP) |                 caps.append(Capabilities.CAN_BACKUP) | ||||||
|         else: |         else: | ||||||
|             # Running in compose, backup is always supported |             # Running in compose, backup is always supported | ||||||
|  | |||||||
							
								
								
									
										38
									
								
								authentik/api/v2/sentry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								authentik/api/v2/sentry.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | |||||||
|  | """Sentry tunnel""" | ||||||
|  | from json import loads | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
|  | from django.http.request import HttpRequest | ||||||
|  | from django.http.response import HttpResponse | ||||||
|  | from django.views.generic.base import View | ||||||
|  | from requests import post | ||||||
|  | from requests.exceptions import RequestException | ||||||
|  |  | ||||||
|  | from authentik.lib.config import CONFIG | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SentryTunnelView(View): | ||||||
|  |     """Sentry tunnel, to prevent ad blockers from blocking sentry""" | ||||||
|  |  | ||||||
|  |     def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|  |         """Sentry tunnel, to prevent ad blockers from blocking sentry""" | ||||||
|  |         # Only allow usage of this endpoint when error reporting is enabled | ||||||
|  |         if not CONFIG.y_bool("error_reporting.enabled", False): | ||||||
|  |             return HttpResponse(status=400) | ||||||
|  |         # Body is 2 json objects separated by \n | ||||||
|  |         full_body = request.body | ||||||
|  |         header = loads(full_body.splitlines()[0]) | ||||||
|  |         # Check that the DSN is what we expect | ||||||
|  |         dsn = header.get("dsn", "") | ||||||
|  |         if dsn != settings.SENTRY_DSN: | ||||||
|  |             return HttpResponse(status=400) | ||||||
|  |         response = post( | ||||||
|  |             "https://sentry.beryju.org/api/8/envelope/", | ||||||
|  |             data=full_body, | ||||||
|  |             headers={"Content-Type": "application/octet-stream"}, | ||||||
|  |         ) | ||||||
|  |         try: | ||||||
|  |             response.raise_for_status() | ||||||
|  |         except RequestException: | ||||||
|  |             return HttpResponse(status=500) | ||||||
|  |         return HttpResponse(status=response.status_code) | ||||||
| @ -1,5 +1,6 @@ | |||||||
| """api v2 urls""" | """api v2 urls""" | ||||||
| from django.urls import path | from django.urls import path | ||||||
|  | from django.views.decorators.csrf import csrf_exempt | ||||||
| from drf_spectacular.views import SpectacularAPIView | from drf_spectacular.views import SpectacularAPIView | ||||||
| from rest_framework import routers | from rest_framework import routers | ||||||
|  |  | ||||||
| @ -10,6 +11,7 @@ from authentik.admin.api.tasks import TaskViewSet | |||||||
| from authentik.admin.api.version import VersionView | from authentik.admin.api.version import VersionView | ||||||
| from authentik.admin.api.workers import WorkerView | from authentik.admin.api.workers import WorkerView | ||||||
| from authentik.api.v2.config import ConfigView | from authentik.api.v2.config import ConfigView | ||||||
|  | from authentik.api.v2.sentry import SentryTunnelView | ||||||
| from authentik.api.views import APIBrowserView | from authentik.api.views import APIBrowserView | ||||||
| from authentik.core.api.applications import ApplicationViewSet | from authentik.core.api.applications import ApplicationViewSet | ||||||
| from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | ||||||
| @ -235,6 +237,7 @@ urlpatterns = ( | |||||||
|             FlowExecutorView.as_view(), |             FlowExecutorView.as_view(), | ||||||
|             name="flow-executor", |             name="flow-executor", | ||||||
|         ), |         ), | ||||||
|  |         path("sentry/", csrf_exempt(SentryTunnelView.as_view()), name="sentry"), | ||||||
|         path("schema/", SpectacularAPIView.as_view(), name="schema"), |         path("schema/", SpectacularAPIView.as_view(), name="schema"), | ||||||
|     ] |     ] | ||||||
| ) | ) | ||||||
|  | |||||||
| @ -1,24 +1,60 @@ | |||||||
| """Groups API Viewset""" | """Groups API Viewset""" | ||||||
| from django.db.models.query import QuerySet | from django.db.models.query import QuerySet | ||||||
| from rest_framework.fields import JSONField | from rest_framework.fields import BooleanField, CharField, JSONField | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ListSerializer, ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import is_dict | from authentik.core.api.utils import is_dict | ||||||
| from authentik.core.models import Group | from authentik.core.models import Group, User | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GroupMemberSerializer(ModelSerializer): | ||||||
|  |     """Stripped down user serializer to show relevant users for groups""" | ||||||
|  |  | ||||||
|  |     is_superuser = BooleanField(read_only=True) | ||||||
|  |     avatar = CharField(read_only=True) | ||||||
|  |     attributes = JSONField(validators=[is_dict], required=False) | ||||||
|  |     uid = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         model = User | ||||||
|  |         fields = [ | ||||||
|  |             "pk", | ||||||
|  |             "username", | ||||||
|  |             "name", | ||||||
|  |             "is_active", | ||||||
|  |             "last_login", | ||||||
|  |             "is_superuser", | ||||||
|  |             "email", | ||||||
|  |             "avatar", | ||||||
|  |             "attributes", | ||||||
|  |             "uid", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupSerializer(ModelSerializer): | class GroupSerializer(ModelSerializer): | ||||||
|     """Group Serializer""" |     """Group Serializer""" | ||||||
|  |  | ||||||
|     attributes = JSONField(validators=[is_dict], required=False) |     attributes = JSONField(validators=[is_dict], required=False) | ||||||
|  |     users_obj = ListSerializer( | ||||||
|  |         child=GroupMemberSerializer(), read_only=True, source="users", required=False | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Group |         model = Group | ||||||
|         fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] |         fields = [ | ||||||
|  |             "pk", | ||||||
|  |             "name", | ||||||
|  |             "is_superuser", | ||||||
|  |             "parent", | ||||||
|  |             "users", | ||||||
|  |             "attributes", | ||||||
|  |             "users_obj", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupViewSet(UsedByMixin, ModelViewSet): | class GroupViewSet(UsedByMixin, ModelViewSet): | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ from authentik.api.decorators import permission_required | |||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import Token, TokenIntents | from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.managed.api import ManagedSerializer | from authentik.managed.api import ManagedSerializer | ||||||
|  |  | ||||||
| @ -61,11 +61,19 @@ class TokenViewSet(UsedByMixin, ModelViewSet): | |||||||
|         "intent", |         "intent", | ||||||
|         "user__username", |         "user__username", | ||||||
|         "description", |         "description", | ||||||
|  |         "expires", | ||||||
|  |         "expiring", | ||||||
|     ] |     ] | ||||||
|     ordering = ["expires"] |     ordering = ["expires"] | ||||||
|  |  | ||||||
|     def perform_create(self, serializer: TokenSerializer): |     def perform_create(self, serializer: TokenSerializer): | ||||||
|         serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API) |         serializer.save( | ||||||
|  |             user=self.request.user, | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |             expiring=self.request.user.attributes.get( | ||||||
|  |                 USER_ATTRIBUTE_TOKEN_EXPIRING, True | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_token_key") |     @permission_required("authentik_core.view_token_key") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|  | |||||||
| @ -2,12 +2,11 @@ | |||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| from django.db.models.query import QuerySet | from django.db.models.query import QuerySet | ||||||
| from django.http.response import Http404 |  | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.http import urlencode | from django.utils.http import urlencode | ||||||
| from django_filters.filters import BooleanFilter, CharFilter | from django_filters.filters import BooleanFilter, CharFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field | from drf_spectacular.utils import extend_schema, extend_schema_field | ||||||
| from guardian.utils import get_anonymous_user | from guardian.utils import get_anonymous_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, JSONField, SerializerMethodField | from rest_framework.fields import CharField, JSONField, SerializerMethodField | ||||||
| @ -173,7 +172,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             "200": LinkSerializer(many=False), |             "200": LinkSerializer(many=False), | ||||||
|             "404": OpenApiResponse(description="No recovery flow found."), |             "404": LinkSerializer(many=False), | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
| @ -184,7 +183,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         # Check that there is a recovery flow, if not return an error |         # Check that there is a recovery flow, if not return an error | ||||||
|         flow = tenant.flow_recovery |         flow = tenant.flow_recovery | ||||||
|         if not flow: |         if not flow: | ||||||
|             raise Http404 |             return Response({"link": ""}, status=404) | ||||||
|         user: User = self.get_object() |         user: User = self.get_object() | ||||||
|         token, __ = Token.objects.get_or_create( |         token, __ = Token.objects.get_or_create( | ||||||
|             identifier=f"{user.uid}-password-reset", |             identifier=f"{user.uid}-password-reset", | ||||||
|  | |||||||
| @ -14,7 +14,9 @@ def is_dict(value: Any): | |||||||
|     """Ensure a value is a dictionary, useful for JSONFields""" |     """Ensure a value is a dictionary, useful for JSONFields""" | ||||||
|     if isinstance(value, dict): |     if isinstance(value, dict): | ||||||
|         return |         return | ||||||
|     raise ValidationError("Value must be a dictionary.") |     raise ValidationError( | ||||||
|  |         "Value must be a dictionary, and not have any duplicate keys." | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PassiveSerializer(Serializer): | class PassiveSerializer(Serializer): | ||||||
|  | |||||||
| @ -0,0 +1,20 @@ | |||||||
|  | # Generated by Django 3.2.5 on 2021-07-09 17:27 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_core", "0025_alter_application_meta_icon"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="application", | ||||||
|  |             name="meta_icon", | ||||||
|  |             field=models.FileField( | ||||||
|  |                 default=None, max_length=500, null=True, upload_to="application-icons/" | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -5,14 +5,13 @@ from typing import Any, Optional, Type | |||||||
| from urllib.parse import urlencode | from urllib.parse import urlencode | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| import django.db.models.options as options |  | ||||||
| from deepmerge import always_merger | from deepmerge import always_merger | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.models import AbstractUser | from django.contrib.auth.models import AbstractUser | ||||||
| from django.contrib.auth.models import UserManager as DjangoUserManager | from django.contrib.auth.models import UserManager as DjangoUserManager | ||||||
| from django.core import validators | from django.core import validators | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models import Q, QuerySet | from django.db.models import Q, QuerySet, options | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.templatetags.static import static | from django.templatetags.static import static | ||||||
| from django.utils.functional import cached_property | from django.utils.functional import cached_property | ||||||
| @ -38,6 +37,8 @@ LOGGER = get_logger() | |||||||
| USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" | USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" | ||||||
| USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" | USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" | ||||||
| USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" | USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" | ||||||
|  | USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires"  # nosec | ||||||
|  | USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | ||||||
|  |  | ||||||
| GRAVATAR_URL = "https://secure.gravatar.com" | GRAVATAR_URL = "https://secure.gravatar.com" | ||||||
| DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | ||||||
| @ -229,7 +230,10 @@ class Application(PolicyBindingModel): | |||||||
|     ) |     ) | ||||||
|     # For template applications, this can be set to /static/authentik/applications/* |     # For template applications, this can be set to /static/authentik/applications/* | ||||||
|     meta_icon = models.FileField( |     meta_icon = models.FileField( | ||||||
|         upload_to="application-icons/", default=None, null=True |         upload_to="application-icons/", | ||||||
|  |         default=None, | ||||||
|  |         null=True, | ||||||
|  |         max_length=500, | ||||||
|     ) |     ) | ||||||
|     meta_description = models.TextField(default="", blank=True) |     meta_description = models.TextField(default="", blank=True) | ||||||
|     meta_publisher = models.TextField(default="", blank=True) |     meta_publisher = models.TextField(default="", blank=True) | ||||||
| @ -378,6 +382,13 @@ class ExpiringModel(models.Model): | |||||||
|     expires = models.DateTimeField(default=default_token_duration) |     expires = models.DateTimeField(default=default_token_duration) | ||||||
|     expiring = models.BooleanField(default=True) |     expiring = models.BooleanField(default=True) | ||||||
|  |  | ||||||
|  |     def expire_action(self, *args, **kwargs): | ||||||
|  |         """Handler which is called when this object is expired. By | ||||||
|  |         default the object is deleted. This is less efficient compared | ||||||
|  |         to bulk deleting objects, but classes like Token() need to change | ||||||
|  |         values instead of being deleted.""" | ||||||
|  |         return self.delete(*args, **kwargs) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def filter_not_expired(cls, **kwargs) -> QuerySet: |     def filter_not_expired(cls, **kwargs) -> QuerySet: | ||||||
|         """Filer for tokens which are not expired yet or are not expiring, |         """Filer for tokens which are not expired yet or are not expiring, | ||||||
| @ -422,6 +433,18 @@ class Token(ManagedModel, ExpiringModel): | |||||||
|     user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+") |     user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+") | ||||||
|     description = models.TextField(default="", blank=True) |     description = models.TextField(default="", blank=True) | ||||||
|  |  | ||||||
|  |     def expire_action(self, *args, **kwargs): | ||||||
|  |         """Handler which is called when this object is expired.""" | ||||||
|  |         from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |         self.key = default_token_key() | ||||||
|  |         self.save(*args, **kwargs) | ||||||
|  |         Event.new( | ||||||
|  |             action=EventAction.SECRET_ROTATE, | ||||||
|  |             token=self, | ||||||
|  |             message=f"Token {self.identifier}'s secret was rotated.", | ||||||
|  |         ).save() | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         description = f"{self.identifier}" |         description = f"{self.identifier}" | ||||||
|         if self.expiring: |         if self.expiring: | ||||||
|  | |||||||
| @ -213,7 +213,7 @@ class SourceFlowManager: | |||||||
|         planner = FlowPlanner(flow) |         planner = FlowPlanner(flow) | ||||||
|         plan = planner.plan(self.request, kwargs) |         plan = planner.plan(self.request, kwargs) | ||||||
|         for stage in self.get_stages_to_append(flow): |         for stage in self.get_stages_to_append(flow): | ||||||
|             plan.append(stage) |             plan.append_stage(stage=stage) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -26,14 +26,16 @@ def clean_expired_models(self: MonitoredTask): | |||||||
|     messages = [] |     messages = [] | ||||||
|     for cls in ExpiringModel.__subclasses__(): |     for cls in ExpiringModel.__subclasses__(): | ||||||
|         cls: ExpiringModel |         cls: ExpiringModel | ||||||
|         amount, _ = ( |         objects = ( | ||||||
|             cls.objects.all() |             cls.objects.all() | ||||||
|             .exclude(expiring=False) |             .exclude(expiring=False) | ||||||
|             .exclude(expiring=True, expires__gt=now()) |             .exclude(expiring=True, expires__gt=now()) | ||||||
|             .delete() |  | ||||||
|         ) |         ) | ||||||
|         LOGGER.debug("Deleted expired models", model=cls, amount=amount) |         for obj in objects: | ||||||
|         messages.append(f"Deleted {amount} expired {cls._meta.verbose_name_plural}") |             obj.expire_action() | ||||||
|  |         amount = objects.count() | ||||||
|  |         LOGGER.debug("Expired models", model=cls, amount=amount) | ||||||
|  |         messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}") | ||||||
|     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) |     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ | |||||||
| <script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script> | <script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script> | ||||||
| <style> | <style> | ||||||
| .pf-c-background-image::before { | .pf-c-background-image::before { | ||||||
|     background-image: url("{{ flow.background_url }}"); |     --ak-flow-background: url("{{ flow.background_url }}"); | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
| {% block head %} | {% block head %} | ||||||
| <style> | <style> | ||||||
| .pf-c-background-image::before { | .pf-c-background-image::before { | ||||||
|     background-image: url("/static/dist/assets/images/flow_background.jpg"); |     --ak-flow-background: url("/static/dist/assets/images/flow_background.jpg"); | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -1,18 +0,0 @@ | |||||||
| """authentik core task tests""" |  | ||||||
| from django.test import TestCase |  | ||||||
| from django.utils.timezone import now |  | ||||||
| from guardian.shortcuts import get_anonymous_user |  | ||||||
|  |  | ||||||
| from authentik.core.models import Token |  | ||||||
| from authentik.core.tasks import clean_expired_models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestTasks(TestCase): |  | ||||||
|     """Test Tasks""" |  | ||||||
|  |  | ||||||
|     def test_token_cleanup(self): |  | ||||||
|         """Test Token cleanup task""" |  | ||||||
|         Token.objects.create(expires=now(), user=get_anonymous_user()) |  | ||||||
|         self.assertEqual(Token.objects.all().count(), 1) |  | ||||||
|         clean_expired_models.delay().get() |  | ||||||
|         self.assertEqual(Token.objects.all().count(), 0) |  | ||||||
| @ -1,8 +1,16 @@ | |||||||
| """Test token API""" | """Test token API""" | ||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
|  | from django.utils.timezone import now | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import Token, TokenIntents, User | from authentik.core.models import ( | ||||||
|  |     USER_ATTRIBUTE_TOKEN_EXPIRING, | ||||||
|  |     Token, | ||||||
|  |     TokenIntents, | ||||||
|  |     User, | ||||||
|  | ) | ||||||
|  | from authentik.core.tasks import clean_expired_models | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestTokenAPI(APITestCase): | class TestTokenAPI(APITestCase): | ||||||
| @ -22,3 +30,25 @@ class TestTokenAPI(APITestCase): | |||||||
|         token = Token.objects.get(identifier="test-token") |         token = Token.objects.get(identifier="test-token") | ||||||
|         self.assertEqual(token.user, self.user) |         self.assertEqual(token.user, self.user) | ||||||
|         self.assertEqual(token.intent, TokenIntents.INTENT_API) |         self.assertEqual(token.intent, TokenIntents.INTENT_API) | ||||||
|  |         self.assertEqual(token.expiring, True) | ||||||
|  |  | ||||||
|  |     def test_token_create_non_expiring(self): | ||||||
|  |         """Test token creation endpoint""" | ||||||
|  |         self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = False | ||||||
|  |         self.user.save() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:token-list"), {"identifier": "test-token"} | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |         token = Token.objects.get(identifier="test-token") | ||||||
|  |         self.assertEqual(token.user, self.user) | ||||||
|  |         self.assertEqual(token.intent, TokenIntents.INTENT_API) | ||||||
|  |         self.assertEqual(token.expiring, False) | ||||||
|  |  | ||||||
|  |     def test_token_expire(self): | ||||||
|  |         """Test Token expire task""" | ||||||
|  |         token: Token = Token.objects.create(expires=now(), user=get_anonymous_user()) | ||||||
|  |         key = token.key | ||||||
|  |         clean_expired_models.delay().get() | ||||||
|  |         token.refresh_from_db() | ||||||
|  |         self.assertNotEqual(key, token.key) | ||||||
|  | |||||||
| @ -97,7 +97,8 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|         fields = [ |         fields = [ | ||||||
|             "pk", |             "pk", | ||||||
|             "name", |             "name", | ||||||
|             "fingerprint", |             "fingerprint_sha256", | ||||||
|  |             "fingerprint_sha1", | ||||||
|             "certificate_data", |             "certificate_data", | ||||||
|             "key_data", |             "key_data", | ||||||
|             "cert_expiry", |             "cert_expiry", | ||||||
|  | |||||||
| @ -16,11 +16,6 @@ from authentik.crypto.models import CertificateKeyPair | |||||||
| class CertificateBuilder: | class CertificateBuilder: | ||||||
|     """Build self-signed certificates""" |     """Build self-signed certificates""" | ||||||
|  |  | ||||||
|     __public_key = None |  | ||||||
|     __private_key = None |  | ||||||
|     __builder = None |  | ||||||
|     __certificate = None |  | ||||||
|  |  | ||||||
|     common_name: str |     common_name: str | ||||||
|  |  | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|  | |||||||
| @ -68,12 +68,19 @@ class CertificateKeyPair(CreatedUpdatedModel): | |||||||
|         return self._private_key |         return self._private_key | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def fingerprint(self) -> str: |     def fingerprint_sha256(self) -> str: | ||||||
|         """Get SHA256 Fingerprint of certificate_data""" |         """Get SHA256 Fingerprint of certificate_data""" | ||||||
|         return hexlify(self.certificate.fingerprint(hashes.SHA256()), ":").decode( |         return hexlify(self.certificate.fingerprint(hashes.SHA256()), ":").decode( | ||||||
|             "utf-8" |             "utf-8" | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def fingerprint_sha1(self) -> str: | ||||||
|  |         """Get SHA1 Fingerprint of certificate_data""" | ||||||
|  |         return hexlify( | ||||||
|  |             self.certificate.fingerprint(hashes.SHA1()), ":"  # nosec | ||||||
|  |         ).decode("utf-8") | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def kid(self): |     def kid(self): | ||||||
|         """Get Key ID used for JWKS""" |         """Get Key ID used for JWKS""" | ||||||
|  | |||||||
| @ -6,11 +6,11 @@ from drf_spectacular.types import OpenApiTypes | |||||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema | from drf_spectacular.utils import OpenApiParameter, extend_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, DictField, IntegerField | from rest_framework.fields import DictField, IntegerField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer | from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| @ -19,11 +19,6 @@ from authentik.events.models import Event, EventAction | |||||||
| class EventSerializer(ModelSerializer): | class EventSerializer(ModelSerializer): | ||||||
|     """Event Serializer""" |     """Event Serializer""" | ||||||
|  |  | ||||||
|     # Since we only use this serializer for read-only operations, |  | ||||||
|     # no checking of the action is done here. |  | ||||||
|     # This allows clients to check wildcards, prefixes and custom types |  | ||||||
|     action = CharField() |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Event |         model = Event | ||||||
| @ -96,7 +91,7 @@ class EventsFilter(django_filters.FilterSet): | |||||||
|         fields = ["action", "client_ip", "username"] |         fields = ["action", "client_ip", "username"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventViewSet(ReadOnlyModelViewSet): | class EventViewSet(ModelViewSet): | ||||||
|     """Event Read-Only Viewset""" |     """Event Read-Only Viewset""" | ||||||
|  |  | ||||||
|     queryset = Event.objects.all() |     queryset = Event.objects.all() | ||||||
|  | |||||||
| @ -46,7 +46,7 @@ class NotificationTransportTestSerializer(Serializer): | |||||||
|  |  | ||||||
|     messages = ListField(child=CharField()) |     messages = ListField(child=CharField()) | ||||||
|  |  | ||||||
|     def create(self, request: Request) -> Response: |     def create(self, validated_data: Request) -> Response: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def update(self, request: Request) -> Response: |     def update(self, request: Request) -> Response: | ||||||
|  | |||||||
| @ -27,10 +27,9 @@ class GeoIPDict(TypedDict): | |||||||
| class GeoIPReader: | class GeoIPReader: | ||||||
|     """Slim wrapper around GeoIP API""" |     """Slim wrapper around GeoIP API""" | ||||||
|  |  | ||||||
|     __reader: Optional[Reader] = None |  | ||||||
|     __last_mtime: float = 0.0 |  | ||||||
|  |  | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|  |         self.__reader: Optional[Reader] = None | ||||||
|  |         self.__last_mtime: float = 0.0 | ||||||
|         self.__open() |         self.__open() | ||||||
|  |  | ||||||
|     def __open(self): |     def __open(self): | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from functools import partial | |||||||
| from typing import Callable | from typing import Callable | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  | from django.core.exceptions import SuspiciousOperation | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.signals import post_save, pre_delete | from django.db.models.signals import post_save, pre_delete | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| @ -13,6 +14,7 @@ from authentik.core.models import User | |||||||
| from authentik.events.models import Event, EventAction, Notification | from authentik.events.models import Event, EventAction, Notification | ||||||
| from authentik.events.signals import EventNewThread | from authentik.events.signals import EventNewThread | ||||||
| from authentik.events.utils import model_to_dict | from authentik.events.utils import model_to_dict | ||||||
|  | from authentik.lib.sentry import before_send | ||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -62,6 +64,15 @@ class AuditMiddleware: | |||||||
|  |  | ||||||
|         if settings.DEBUG: |         if settings.DEBUG: | ||||||
|             return |             return | ||||||
|  |         # Special case for SuspiciousOperation, we have a special event action for that | ||||||
|  |         if isinstance(exception, SuspiciousOperation): | ||||||
|  |             thread = EventNewThread( | ||||||
|  |                 EventAction.SUSPICIOUS_REQUEST, | ||||||
|  |                 request, | ||||||
|  |                 message=str(exception), | ||||||
|  |             ) | ||||||
|  |             thread.run() | ||||||
|  |         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||||
|             thread = EventNewThread( |             thread = EventNewThread( | ||||||
|                 EventAction.SYSTEM_EXCEPTION, |                 EventAction.SYSTEM_EXCEPTION, | ||||||
|                 request, |                 request, | ||||||
|  | |||||||
							
								
								
									
										47
									
								
								authentik/events/migrations/0017_alter_event_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								authentik/events/migrations/0017_alter_event_action.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,47 @@ | |||||||
|  | # Generated by Django 3.2.5 on 2021-07-14 19:15 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_events", "0016_add_tenant"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="event", | ||||||
|  |             name="action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("login", "Login"), | ||||||
|  |                     ("login_failed", "Login Failed"), | ||||||
|  |                     ("logout", "Logout"), | ||||||
|  |                     ("user_write", "User Write"), | ||||||
|  |                     ("suspicious_request", "Suspicious Request"), | ||||||
|  |                     ("password_set", "Password Set"), | ||||||
|  |                     ("secret_view", "Secret View"), | ||||||
|  |                     ("secret_rotate", "Secret Rotate"), | ||||||
|  |                     ("invitation_used", "Invite Used"), | ||||||
|  |                     ("authorize_application", "Authorize Application"), | ||||||
|  |                     ("source_linked", "Source Linked"), | ||||||
|  |                     ("impersonation_started", "Impersonation Started"), | ||||||
|  |                     ("impersonation_ended", "Impersonation Ended"), | ||||||
|  |                     ("policy_execution", "Policy Execution"), | ||||||
|  |                     ("policy_exception", "Policy Exception"), | ||||||
|  |                     ("property_mapping_exception", "Property Mapping Exception"), | ||||||
|  |                     ("system_task_execution", "System Task Execution"), | ||||||
|  |                     ("system_task_exception", "System Task Exception"), | ||||||
|  |                     ("system_exception", "System Exception"), | ||||||
|  |                     ("configuration_error", "Configuration Error"), | ||||||
|  |                     ("model_created", "Model Created"), | ||||||
|  |                     ("model_updated", "Model Updated"), | ||||||
|  |                     ("model_deleted", "Model Deleted"), | ||||||
|  |                     ("email_sent", "Email Sent"), | ||||||
|  |                     ("update_available", "Update Available"), | ||||||
|  |                     ("custom_", "Custom Prefix"), | ||||||
|  |                 ] | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -62,6 +62,7 @@ class EventAction(models.TextChoices): | |||||||
|     PASSWORD_SET = "password_set"  # noqa # nosec |     PASSWORD_SET = "password_set"  # noqa # nosec | ||||||
|  |  | ||||||
|     SECRET_VIEW = "secret_view"  # noqa # nosec |     SECRET_VIEW = "secret_view"  # noqa # nosec | ||||||
|  |     SECRET_ROTATE = "secret_rotate"  # noqa # nosec | ||||||
|  |  | ||||||
|     INVITE_USED = "invitation_used" |     INVITE_USED = "invitation_used" | ||||||
|  |  | ||||||
| @ -313,7 +314,8 @@ class NotificationTransport(models.Model): | |||||||
|             response = post(self.webhook_url, json=body) |             response = post(self.webhook_url, json=body) | ||||||
|             response.raise_for_status() |             response.raise_for_status() | ||||||
|         except RequestException as exc: |         except RequestException as exc: | ||||||
|             raise NotificationTransportError(exc.response.text) from exc |             text = exc.response.text if exc.response else str(exc) | ||||||
|  |             raise NotificationTransportError(text) from exc | ||||||
|         return [ |         return [ | ||||||
|             response.status_code, |             response.status_code, | ||||||
|             response.text, |             response.text, | ||||||
|  | |||||||
| @ -105,7 +105,11 @@ def notification_transport( | |||||||
|     """Send notification over specified transport""" |     """Send notification over specified transport""" | ||||||
|     self.save_on_success = False |     self.save_on_success = False | ||||||
|     try: |     try: | ||||||
|         notification: Notification = Notification.objects.get(pk=notification_pk) |         notification: Notification = Notification.objects.filter( | ||||||
|  |             pk=notification_pk | ||||||
|  |         ).first() | ||||||
|  |         if not notification: | ||||||
|  |             return | ||||||
|         transport: NotificationTransport = NotificationTransport.objects.get( |         transport: NotificationTransport = NotificationTransport.objects.get( | ||||||
|             pk=transport_pk |             pk=transport_pk | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -25,6 +25,7 @@ class FlowStageBindingSerializer(ModelSerializer): | |||||||
|             "re_evaluate_policies", |             "re_evaluate_policies", | ||||||
|             "order", |             "order", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|  |             "invalid_response_action", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,8 +5,7 @@ from typing import TYPE_CHECKING, Optional | |||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.flows.models import FlowStageBinding | ||||||
| from authentik.flows.models import Stage |  | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
|  |  | ||||||
| @ -22,11 +21,14 @@ class StageMarker: | |||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def process( |     def process( | ||||||
|         self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] |         self, | ||||||
|     ) -> Optional[Stage]: |         plan: "FlowPlan", | ||||||
|  |         binding: FlowStageBinding, | ||||||
|  |         http_request: HttpRequest, | ||||||
|  |     ) -> Optional[FlowStageBinding]: | ||||||
|         """Process callback for this marker. This should be overridden by sub-classes. |         """Process callback for this marker. This should be overridden by sub-classes. | ||||||
|         If a stage should be removed, return None.""" |         If a stage should be removed, return None.""" | ||||||
|         return stage |         return binding | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
| @ -34,24 +36,34 @@ class ReevaluateMarker(StageMarker): | |||||||
|     """Reevaluate Marker, forces stage's policies to be evaluated again.""" |     """Reevaluate Marker, forces stage's policies to be evaluated again.""" | ||||||
|  |  | ||||||
|     binding: PolicyBinding |     binding: PolicyBinding | ||||||
|     user: User |  | ||||||
|  |  | ||||||
|     def process( |     def process( | ||||||
|         self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] |         self, | ||||||
|     ) -> Optional[Stage]: |         plan: "FlowPlan", | ||||||
|  |         binding: FlowStageBinding, | ||||||
|  |         http_request: HttpRequest, | ||||||
|  |     ) -> Optional[FlowStageBinding]: | ||||||
|         """Re-evaluate policies bound to stage, and if they fail, remove from plan""" |         """Re-evaluate policies bound to stage, and if they fail, remove from plan""" | ||||||
|         engine = PolicyEngine(self.binding, self.user) |         from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
|  |  | ||||||
|  |         LOGGER.debug( | ||||||
|  |             "f(plan_inst)[re-eval marker]: running re-evaluation", | ||||||
|  |             binding=binding, | ||||||
|  |             policy_binding=self.binding, | ||||||
|  |         ) | ||||||
|  |         engine = PolicyEngine( | ||||||
|  |             self.binding, plan.context.get(PLAN_CONTEXT_PENDING_USER, http_request.user) | ||||||
|  |         ) | ||||||
|         engine.use_cache = False |         engine.use_cache = False | ||||||
|         if http_request: |  | ||||||
|         engine.request.set_http_request(http_request) |         engine.request.set_http_request(http_request) | ||||||
|         engine.request.context = plan.context |         engine.request.context = plan.context | ||||||
|         engine.build() |         engine.build() | ||||||
|         result = engine.result |         result = engine.result | ||||||
|         if result.passing: |         if result.passing: | ||||||
|             return stage |             return binding | ||||||
|         LOGGER.warning( |         LOGGER.warning( | ||||||
|             "f(plan_inst)[re-eval marker]: stage failed re-evaluation", |             "f(plan_inst)[re-eval marker]: binding failed re-evaluation", | ||||||
|             stage=stage, |             binding=binding, | ||||||
|             messages=result.messages, |             messages=result.messages, | ||||||
|         ) |         ) | ||||||
|         return None |         return None | ||||||
|  | |||||||
| @ -135,7 +135,7 @@ class Migration(migrations.Migration): | |||||||
|  |  | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_flows", "0017_auto_20210329_1334"), |         ("authentik_flows", "0017_auto_20210329_1334"), | ||||||
|         ("authentik_stages_user_write", "__latest__"), |         ("authentik_stages_user_write", "0002_auto_20200918_1653"), | ||||||
|         ("authentik_stages_user_login", "__latest__"), |         ("authentik_stages_user_login", "__latest__"), | ||||||
|         ("authentik_stages_password", "0002_passwordstage_change_flow"), |         ("authentik_stages_password", "0002_passwordstage_change_flow"), | ||||||
|         ("authentik_policies", "0001_initial"), |         ("authentik_policies", "0001_initial"), | ||||||
|  | |||||||
| @ -0,0 +1,22 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-06-27 16:20 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_flows", "0020_flow_compatibility_mode"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="flowstagebinding", | ||||||
|  |             name="invalid_response_action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 choices=[("retry", "Retry"), ("continue", "Continue")], | ||||||
|  |                 default="retry", | ||||||
|  |                 help_text="Configure how the flow executor should handle an invalid response to a challenge. RETRY returns the error message and a similar challenge to the executor while CONTINUE continues with the next stage.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,26 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-07-03 13:13 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_flows", "0021_flowstagebinding_invalid_response_action"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="flowstagebinding", | ||||||
|  |             name="invalid_response_action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("retry", "Retry"), | ||||||
|  |                     ("restart", "Restart"), | ||||||
|  |                     ("restart_with_context", "Restart With Context"), | ||||||
|  |                 ], | ||||||
|  |                 default="retry", | ||||||
|  |                 help_text="Configure how the flow executor should handle an invalid response to a challenge. RETRY returns the error message and a similar challenge to the executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT restarts the flow while keeping the current context.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										24
									
								
								authentik/flows/migrations/0023_alter_flow_background.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								authentik/flows/migrations/0023_alter_flow_background.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | # Generated by Django 3.2.5 on 2021-07-09 17:27 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_flows", "0022_alter_flowstagebinding_invalid_response_action"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="flow", | ||||||
|  |             name="background", | ||||||
|  |             field=models.FileField( | ||||||
|  |                 default=None, | ||||||
|  |                 help_text="Background shown during execution", | ||||||
|  |                 max_length=500, | ||||||
|  |                 null=True, | ||||||
|  |                 upload_to="flow-backgrounds/", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -27,6 +27,14 @@ class NotConfiguredAction(models.TextChoices): | |||||||
|     CONFIGURE = "configure" |     CONFIGURE = "configure" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class InvalidResponseAction(models.TextChoices): | ||||||
|  |     """Configure how the flow executor should handle invalid responses to challenges""" | ||||||
|  |  | ||||||
|  |     RETRY = "retry" | ||||||
|  |     RESTART = "restart" | ||||||
|  |     RESTART_WITH_CONTEXT = "restart_with_context" | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowDesignation(models.TextChoices): | class FlowDesignation(models.TextChoices): | ||||||
|     """Designation of what a Flow should be used for. At a later point, this |     """Designation of what a Flow should be used for. At a later point, this | ||||||
|     should be replaced by a database entry.""" |     should be replaced by a database entry.""" | ||||||
| @ -113,6 +121,7 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|         default=None, |         default=None, | ||||||
|         null=True, |         null=True, | ||||||
|         help_text=_("Background shown during execution"), |         help_text=_("Background shown during execution"), | ||||||
|  |         max_length=500, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     compatibility_mode = models.BooleanField( |     compatibility_mode = models.BooleanField( | ||||||
| @ -201,6 +210,17 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel): | |||||||
|         help_text=_("Evaluate policies when the Stage is present to the user."), |         help_text=_("Evaluate policies when the Stage is present to the user."), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     invalid_response_action = models.TextField( | ||||||
|  |         choices=InvalidResponseAction.choices, | ||||||
|  |         default=InvalidResponseAction.RETRY, | ||||||
|  |         help_text=_( | ||||||
|  |             "Configure how the flow executor should handle an invalid response to a " | ||||||
|  |             "challenge. RETRY returns the error message and a similar challenge to the " | ||||||
|  |             "executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT " | ||||||
|  |             "restarts the flow while keeping the current context." | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     order = models.IntegerField() |     order = models.IntegerField() | ||||||
|  |  | ||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  | |||||||
| @ -52,33 +52,41 @@ class FlowPlan: | |||||||
|  |  | ||||||
|     flow_pk: str |     flow_pk: str | ||||||
|  |  | ||||||
|     stages: list[Stage] = field(default_factory=list) |     bindings: list[FlowStageBinding] = field(default_factory=list) | ||||||
|     context: dict[str, Any] = field(default_factory=dict) |     context: dict[str, Any] = field(default_factory=dict) | ||||||
|     markers: list[StageMarker] = field(default_factory=list) |     markers: list[StageMarker] = field(default_factory=list) | ||||||
|  |  | ||||||
|     def append(self, stage: Stage, marker: Optional[StageMarker] = None): |     def append_stage(self, stage: Stage, marker: Optional[StageMarker] = None): | ||||||
|         """Append `stage` to all stages, optionall with stage marker""" |         """Append `stage` to all stages, optionall with stage marker""" | ||||||
|         self.stages.append(stage) |         return self.append(FlowStageBinding(stage=stage), marker) | ||||||
|  |  | ||||||
|  |     def append(self, binding: FlowStageBinding, marker: Optional[StageMarker] = None): | ||||||
|  |         """Append `stage` to all stages, optionall with stage marker""" | ||||||
|  |         self.bindings.append(binding) | ||||||
|         self.markers.append(marker or StageMarker()) |         self.markers.append(marker or StageMarker()) | ||||||
|  |  | ||||||
|     def insert(self, stage: Stage, marker: Optional[StageMarker] = None): |     def insert_stage(self, stage: Stage, marker: Optional[StageMarker] = None): | ||||||
|         """Insert stage into plan, as immediate next stage""" |         """Insert stage into plan, as immediate next stage""" | ||||||
|         self.stages.insert(1, stage) |         self.bindings.insert(1, FlowStageBinding(stage=stage, order=0)) | ||||||
|         self.markers.insert(1, marker or StageMarker()) |         self.markers.insert(1, marker or StageMarker()) | ||||||
|  |  | ||||||
|     def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]: |     def next(self, http_request: Optional[HttpRequest]) -> Optional[FlowStageBinding]: | ||||||
|         """Return next pending stage from the bottom of the list""" |         """Return next pending stage from the bottom of the list""" | ||||||
|         if not self.has_stages: |         if not self.has_stages: | ||||||
|             return None |             return None | ||||||
|         stage = self.stages[0] |         binding = self.bindings[0] | ||||||
|         marker = self.markers[0] |         marker = self.markers[0] | ||||||
|  |  | ||||||
|         if marker.__class__ is not StageMarker: |         if marker.__class__ is not StageMarker: | ||||||
|             LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker) |             LOGGER.debug( | ||||||
|         marked_stage = marker.process(self, stage, http_request) |                 "f(plan_inst): stage has marker", binding=binding, marker=marker | ||||||
|  |             ) | ||||||
|  |         marked_stage = marker.process(self, binding, http_request) | ||||||
|         if not marked_stage: |         if not marked_stage: | ||||||
|             LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage) |             LOGGER.debug( | ||||||
|             self.stages.remove(stage) |                 "f(plan_inst): marker returned none, next stage", binding=binding | ||||||
|  |             ) | ||||||
|  |             self.bindings.remove(binding) | ||||||
|             self.markers.remove(marker) |             self.markers.remove(marker) | ||||||
|             if not self.has_stages: |             if not self.has_stages: | ||||||
|                 return None |                 return None | ||||||
| @ -89,12 +97,12 @@ class FlowPlan: | |||||||
|     def pop(self): |     def pop(self): | ||||||
|         """Pop next pending stage from bottom of list""" |         """Pop next pending stage from bottom of list""" | ||||||
|         self.markers.pop(0) |         self.markers.pop(0) | ||||||
|         self.stages.pop(0) |         self.bindings.pop(0) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def has_stages(self) -> bool: |     def has_stages(self) -> bool: | ||||||
|         """Check if there are any stages left in this plan""" |         """Check if there are any stages left in this plan""" | ||||||
|         return len(self.markers) + len(self.stages) > 0 |         return len(self.markers) + len(self.bindings) > 0 | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowPlanner: | class FlowPlanner: | ||||||
| @ -161,7 +169,7 @@ class FlowPlanner: | |||||||
|             plan = self._build_plan(user, request, default_context) |             plan = self._build_plan(user, request, default_context) | ||||||
|             cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT) |             cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT) | ||||||
|             GAUGE_FLOWS_CACHED.update() |             GAUGE_FLOWS_CACHED.update() | ||||||
|             if not plan.stages and not self.allow_empty_flows: |             if not plan.bindings and not self.allow_empty_flows: | ||||||
|                 raise EmptyFlowException() |                 raise EmptyFlowException() | ||||||
|             return plan |             return plan | ||||||
|  |  | ||||||
| @ -216,9 +224,9 @@ class FlowPlanner: | |||||||
|                         "f(plan): stage has re-evaluate marker", |                         "f(plan): stage has re-evaluate marker", | ||||||
|                         stage=binding.stage, |                         stage=binding.stage, | ||||||
|                     ) |                     ) | ||||||
|                     marker = ReevaluateMarker(binding=binding, user=user) |                     marker = ReevaluateMarker(binding=binding) | ||||||
|                 if stage: |                 if stage: | ||||||
|                     plan.append(stage, marker) |                     plan.append(binding, marker) | ||||||
|             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) |             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(plan): finished building", |             "f(plan): finished building", | ||||||
|  | |||||||
| @ -16,7 +16,8 @@ from authentik.flows.challenge import ( | |||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.models import InvalidResponseAction | ||||||
|  | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.views import FlowExecutorView | from authentik.flows.views import FlowExecutorView | ||||||
|  |  | ||||||
| PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | ||||||
| @ -69,7 +70,13 @@ class ChallengeStageView(StageView): | |||||||
|         """Return a challenge for the frontend to solve""" |         """Return a challenge for the frontend to solve""" | ||||||
|         challenge = self._get_challenge(*args, **kwargs) |         challenge = self._get_challenge(*args, **kwargs) | ||||||
|         if not challenge.is_valid(): |         if not challenge.is_valid(): | ||||||
|             LOGGER.warning(challenge.errors, stage_view=self, challenge=challenge) |             LOGGER.warning( | ||||||
|  |                 "f(ch): Invalid challenge", | ||||||
|  |                 binding=self.executor.current_binding, | ||||||
|  |                 errors=challenge.errors, | ||||||
|  |                 stage_view=self, | ||||||
|  |                 challenge=challenge, | ||||||
|  |             ) | ||||||
|         return HttpChallengeResponse(challenge) |         return HttpChallengeResponse(challenge) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
| @ -77,15 +84,36 @@ class ChallengeStageView(StageView): | |||||||
|         """Handle challenge response""" |         """Handle challenge response""" | ||||||
|         challenge: ChallengeResponse = self.get_response_instance(data=request.data) |         challenge: ChallengeResponse = self.get_response_instance(data=request.data) | ||||||
|         if not challenge.is_valid(): |         if not challenge.is_valid(): | ||||||
|  |             if self.executor.current_binding.invalid_response_action in [ | ||||||
|  |                 InvalidResponseAction.RESTART, | ||||||
|  |                 InvalidResponseAction.RESTART_WITH_CONTEXT, | ||||||
|  |             ]: | ||||||
|  |                 keep_context = ( | ||||||
|  |                     self.executor.current_binding.invalid_response_action | ||||||
|  |                     == InvalidResponseAction.RESTART_WITH_CONTEXT | ||||||
|  |                 ) | ||||||
|  |                 LOGGER.debug( | ||||||
|  |                     "f(ch): Invalid response, restarting flow", | ||||||
|  |                     binding=self.executor.current_binding, | ||||||
|  |                     stage_view=self, | ||||||
|  |                     keep_context=keep_context, | ||||||
|  |                 ) | ||||||
|  |                 return self.executor.restart_flow(keep_context) | ||||||
|             return self.challenge_invalid(challenge) |             return self.challenge_invalid(challenge) | ||||||
|         return self.challenge_valid(challenge) |         return self.challenge_valid(challenge) | ||||||
|  |  | ||||||
|  |     def format_title(self) -> str: | ||||||
|  |         """Allow usage of placeholder in flow title.""" | ||||||
|  |         return self.executor.flow.title % { | ||||||
|  |             "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "") | ||||||
|  |         } | ||||||
|  |  | ||||||
|     def _get_challenge(self, *args, **kwargs) -> Challenge: |     def _get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         challenge = self.get_challenge(*args, **kwargs) |         challenge = self.get_challenge(*args, **kwargs) | ||||||
|         if "flow_info" not in challenge.initial_data: |         if "flow_info" not in challenge.initial_data: | ||||||
|             flow_info = ContextualFlowInfo( |             flow_info = ContextualFlowInfo( | ||||||
|                 data={ |                 data={ | ||||||
|                     "title": self.executor.flow.title, |                     "title": self.format_title(), | ||||||
|                     "background": self.executor.flow.background_url, |                     "background": self.executor.flow.background_url, | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |                     "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|                 } |                 } | ||||||
| @ -126,5 +154,10 @@ class ChallengeStageView(StageView): | |||||||
|                 ) |                 ) | ||||||
|         challenge_response.initial_data["response_errors"] = full_errors |         challenge_response.initial_data["response_errors"] = full_errors | ||||||
|         if not challenge_response.is_valid(): |         if not challenge_response.is_valid(): | ||||||
|             LOGGER.warning(challenge_response.errors) |             LOGGER.warning( | ||||||
|  |                 "f(ch): invalid challenge response", | ||||||
|  |                 binding=self.executor.current_binding, | ||||||
|  |                 errors=challenge_response.errors, | ||||||
|  |                 stage_view=self, | ||||||
|  |             ) | ||||||
|         return HttpChallengeResponse(challenge_response) |         return HttpChallengeResponse(challenge_response) | ||||||
|  | |||||||
| @ -182,8 +182,8 @@ class TestFlowPlanner(TestCase): | |||||||
|             planner = FlowPlanner(flow) |             planner = FlowPlanner(flow) | ||||||
|             plan = planner.plan(request) |             plan = planner.plan(request) | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
|  | |||||||
| @ -11,15 +11,23 @@ from authentik.core.models import User | |||||||
| from authentik.flows.challenge import ChallengeTypes | from authentik.flows.challenge import ChallengeTypes | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | from authentik.flows.models import ( | ||||||
|  |     Flow, | ||||||
|  |     FlowDesignation, | ||||||
|  |     FlowStageBinding, | ||||||
|  |     InvalidResponseAction, | ||||||
|  | ) | ||||||
| from authentik.flows.planner import FlowPlan, FlowPlanner | from authentik.flows.planner import FlowPlan, FlowPlanner | ||||||
| from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView | from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView | ||||||
| from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
|  | from authentik.policies.reputation.models import ReputationPolicy | ||||||
| from authentik.policies.types import PolicyResult | from authentik.policies.types import PolicyResult | ||||||
|  | from authentik.stages.deny.models import DenyStage | ||||||
| from authentik.stages.dummy.models import DummyStage | from authentik.stages.dummy.models import DummyStage | ||||||
|  | from authentik.stages.identification.models import IdentificationStage, UserFields | ||||||
|  |  | ||||||
| POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) | POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) | ||||||
| POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | ||||||
| @ -52,8 +60,9 @@ class TestFlowExecutor(TestCase): | |||||||
|             designation=FlowDesignation.AUTHENTICATION, |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|         ) |         ) | ||||||
|         stage = DummyStage.objects.create(name="dummy") |         stage = DummyStage.objects.create(name="dummy") | ||||||
|  |         binding = FlowStageBinding(target=flow, stage=stage, order=0) | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=flow.pk.hex + "a", stages=[stage], markers=[StageMarker()] |             flow_pk=flow.pk.hex + "a", bindings=[binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
| @ -163,7 +172,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         # Check that two stages are in plan |         # Check that two stages are in plan | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] |         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|         self.assertEqual(len(plan.stages), 2) |         self.assertEqual(len(plan.bindings), 2) | ||||||
|         # Second request, submit form, one stage left |         # Second request, submit form, one stage left | ||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         # Second request redirects to the same URL |         # Second request redirects to the same URL | ||||||
| @ -172,7 +181,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         # Check that two stages are in plan |         # Check that two stages are in plan | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] |         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|         self.assertEqual(len(plan.stages), 1) |         self.assertEqual(len(plan.bindings), 1) | ||||||
|  |  | ||||||
|     @patch( |     @patch( | ||||||
|         "authentik.flows.views.to_stage_response", |         "authentik.flows.views.to_stage_response", | ||||||
| @ -213,8 +222,8 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -267,9 +276,9 @@ class TestFlowExecutor(TestCase): | |||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|             self.assertEqual(plan.stages[2], binding3.stage) |             self.assertEqual(plan.bindings[2], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -281,8 +290,8 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding2.stage) |             self.assertEqual(plan.bindings[0], binding2) | ||||||
|             self.assertEqual(plan.stages[1], binding3.stage) |             self.assertEqual(plan.bindings[1], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], StageMarker) |             self.assertIsInstance(plan.markers[1], StageMarker) | ||||||
| @ -338,9 +347,9 @@ class TestFlowExecutor(TestCase): | |||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|             self.assertEqual(plan.stages[2], binding3.stage) |             self.assertEqual(plan.bindings[2], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -352,8 +361,8 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding2.stage) |             self.assertEqual(plan.bindings[0], binding2) | ||||||
|             self.assertEqual(plan.stages[1], binding3.stage) |             self.assertEqual(plan.bindings[1], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], StageMarker) |             self.assertIsInstance(plan.markers[1], StageMarker) | ||||||
| @ -364,7 +373,7 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding3.stage) |             self.assertEqual(plan.bindings[0], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|  |  | ||||||
| @ -438,10 +447,10 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|             self.assertEqual(plan.stages[2], binding3.stage) |             self.assertEqual(plan.bindings[2], binding3) | ||||||
|             self.assertEqual(plan.stages[3], binding4.stage) |             self.assertEqual(plan.bindings[3], binding4) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -512,3 +521,78 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|         stage_view = StageView(executor) |         stage_view = StageView(executor) | ||||||
|         self.assertEqual(ident, stage_view.get_pending_user(for_display=True).username) |         self.assertEqual(ident, stage_view.get_pending_user(for_display=True).username) | ||||||
|  |  | ||||||
|  |     def test_invalid_restart(self): | ||||||
|  |         """Test flow that restarts on invalid entry""" | ||||||
|  |         flow = Flow.objects.create( | ||||||
|  |             name="restart-on-invalid", | ||||||
|  |             slug="restart-on-invalid", | ||||||
|  |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|  |         ) | ||||||
|  |         # Stage 0 is a deny stage that is added dynamically | ||||||
|  |         # when the reputation policy says so | ||||||
|  |         deny_stage = DenyStage.objects.create(name="deny") | ||||||
|  |         reputation_policy = ReputationPolicy.objects.create( | ||||||
|  |             name="reputation", threshold=-1, check_ip=False | ||||||
|  |         ) | ||||||
|  |         deny_binding = FlowStageBinding.objects.create( | ||||||
|  |             target=flow, | ||||||
|  |             stage=deny_stage, | ||||||
|  |             order=0, | ||||||
|  |             evaluate_on_plan=False, | ||||||
|  |             re_evaluate_policies=True, | ||||||
|  |         ) | ||||||
|  |         PolicyBinding.objects.create( | ||||||
|  |             policy=reputation_policy, target=deny_binding, order=0 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Stage 1 is an identification stage | ||||||
|  |         ident_stage = IdentificationStage.objects.create( | ||||||
|  |             name="ident", | ||||||
|  |             user_fields=[UserFields.E_MAIL], | ||||||
|  |         ) | ||||||
|  |         FlowStageBinding.objects.create( | ||||||
|  |             target=flow, | ||||||
|  |             stage=ident_stage, | ||||||
|  |             order=1, | ||||||
|  |             invalid_response_action=InvalidResponseAction.RESTART_WITH_CONTEXT, | ||||||
|  |         ) | ||||||
|  |         exec_url = reverse( | ||||||
|  |             "authentik_api:flow-executor", kwargs={"flow_slug": flow.slug} | ||||||
|  |         ) | ||||||
|  |         # First request, run the planner | ||||||
|  |         response = self.client.get(exec_url) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             force_str(response.content), | ||||||
|  |             { | ||||||
|  |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|  |                 "component": "ak-stage-identification", | ||||||
|  |                 "flow_info": { | ||||||
|  |                     "background": flow.background_url, | ||||||
|  |                     "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|  |                     "title": "", | ||||||
|  |                 }, | ||||||
|  |                 "password_fields": False, | ||||||
|  |                 "primary_action": "Log in", | ||||||
|  |                 "sources": [], | ||||||
|  |                 "user_fields": [UserFields.E_MAIL], | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         response = self.client.post( | ||||||
|  |             exec_url, {"uid_field": "invalid-string"}, follow=True | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             force_str(response.content), | ||||||
|  |             { | ||||||
|  |                 "component": "ak-stage-access-denied", | ||||||
|  |                 "error_message": None, | ||||||
|  |                 "flow_info": { | ||||||
|  |                     "background": flow.background_url, | ||||||
|  |                     "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|  |                     "title": "", | ||||||
|  |                 }, | ||||||
|  |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -40,15 +40,11 @@ def transaction_rollback(): | |||||||
| class FlowImporter: | class FlowImporter: | ||||||
|     """Import Flow from json""" |     """Import Flow from json""" | ||||||
|  |  | ||||||
|     __import: FlowBundle |  | ||||||
|  |  | ||||||
|     __pk_map: dict[Any, Model] |  | ||||||
|  |  | ||||||
|     logger: BoundLogger |     logger: BoundLogger | ||||||
|  |  | ||||||
|     def __init__(self, json_input: str): |     def __init__(self, json_input: str): | ||||||
|  |         self.__pk_map: dict[Any, Model] = {} | ||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
|         self.__pk_map = {} |  | ||||||
|         import_dict = loads(json_input) |         import_dict = loads(json_input) | ||||||
|         try: |         try: | ||||||
|             self.__import = from_dict(FlowBundle, import_dict) |             self.__import = from_dict(FlowBundle, import_dict) | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ from typing import Any, Optional | |||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.mixins import LoginRequiredMixin | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
|  | from django.core.cache import cache | ||||||
| from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| @ -37,7 +38,13 @@ from authentik.flows.challenge import ( | |||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | from authentik.flows.models import ( | ||||||
|  |     ConfigurableStage, | ||||||
|  |     Flow, | ||||||
|  |     FlowDesignation, | ||||||
|  |     FlowStageBinding, | ||||||
|  |     Stage, | ||||||
|  | ) | ||||||
| from authentik.flows.planner import ( | from authentik.flows.planner import ( | ||||||
|     PLAN_CONTEXT_PENDING_USER, |     PLAN_CONTEXT_PENDING_USER, | ||||||
|     PLAN_CONTEXT_REDIRECT, |     PLAN_CONTEXT_REDIRECT, | ||||||
| @ -107,6 +114,7 @@ class FlowExecutorView(APIView): | |||||||
|     flow: Flow |     flow: Flow | ||||||
|  |  | ||||||
|     plan: Optional[FlowPlan] = None |     plan: Optional[FlowPlan] = None | ||||||
|  |     current_binding: FlowStageBinding | ||||||
|     current_stage: Stage |     current_stage: Stage | ||||||
|     current_stage_view: View |     current_stage_view: View | ||||||
|  |  | ||||||
| @ -126,7 +134,7 @@ class FlowExecutorView(APIView): | |||||||
|         message = exc.__doc__ if exc.__doc__ else str(exc) |         message = exc.__doc__ if exc.__doc__ else str(exc) | ||||||
|         return self.stage_invalid(error_message=message) |         return self.stage_invalid(error_message=message) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument, too-many-return-statements | ||||||
|     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: |     def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: | ||||||
|         # Early check if theres an active Plan for the current session |         # Early check if theres an active Plan for the current session | ||||||
|         if SESSION_KEY_PLAN in self.request.session: |         if SESSION_KEY_PLAN in self.request.session: | ||||||
| @ -159,11 +167,23 @@ class FlowExecutorView(APIView): | |||||||
|         request.session[SESSION_KEY_GET] = QueryDict(request.GET.get("query", "")) |         request.session[SESSION_KEY_GET] = QueryDict(request.GET.get("query", "")) | ||||||
|         # We don't save the Plan after getting the next stage |         # We don't save the Plan after getting the next stage | ||||||
|         # as it hasn't been successfully passed yet |         # as it hasn't been successfully passed yet | ||||||
|         next_stage = self.plan.next(self.request) |         try: | ||||||
|         if not next_stage: |             # This is the first time we actually access any attribute on the selected plan | ||||||
|  |             # if the cached plan is from an older version, it might have different attributes | ||||||
|  |             # in which case we just delete the plan and invalidate everything | ||||||
|  |             next_binding = self.plan.next(self.request) | ||||||
|  |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|  |             self._logger.warning( | ||||||
|  |                 "f(exec): found incompatible flow plan, invalidating run", exc=exc | ||||||
|  |             ) | ||||||
|  |             keys = cache.keys("flow_*") | ||||||
|  |             cache.delete_many(keys) | ||||||
|  |             return self.stage_invalid() | ||||||
|  |         if not next_binding: | ||||||
|             self._logger.debug("f(exec): no more stages, flow is done.") |             self._logger.debug("f(exec): no more stages, flow is done.") | ||||||
|             return self._flow_done() |             return self._flow_done() | ||||||
|         self.current_stage = next_stage |         self.current_binding = next_binding | ||||||
|  |         self.current_stage = next_binding.stage | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(exec): Current stage", |             "f(exec): Current stage", | ||||||
|             current_stage=self.current_stage, |             current_stage=self.current_stage, | ||||||
| @ -268,8 +288,31 @@ class FlowExecutorView(APIView): | |||||||
|         planner = FlowPlanner(self.flow) |         planner = FlowPlanner(self.flow) | ||||||
|         plan = planner.plan(self.request) |         plan = planner.plan(self.request) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         try: | ||||||
|  |             # Call the has_stages getter to check that | ||||||
|  |             # there are no issues with the class we might've gotten | ||||||
|  |             # from the cache. If there are errors, just delete all cached flows | ||||||
|  |             _ = plan.has_stages | ||||||
|  |         except Exception:  # pylint: disable=broad-except | ||||||
|  |             keys = cache.keys("flow_*") | ||||||
|  |             cache.delete_many(keys) | ||||||
|  |             return self._initiate_plan() | ||||||
|         return plan |         return plan | ||||||
|  |  | ||||||
|  |     def restart_flow(self, keep_context=False) -> HttpResponse: | ||||||
|  |         """Restart the currently active flow, optionally keeping the current context""" | ||||||
|  |         planner = FlowPlanner(self.flow) | ||||||
|  |         default_context = None | ||||||
|  |         if keep_context: | ||||||
|  |             default_context = self.plan.context | ||||||
|  |         plan = planner.plan(self.request, default_context) | ||||||
|  |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         kwargs = self.kwargs | ||||||
|  |         kwargs.update({"flow_slug": self.flow.slug}) | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_api:flow-executor", self.request.GET, **kwargs | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def _flow_done(self) -> HttpResponse: |     def _flow_done(self) -> HttpResponse: | ||||||
|         """User Successfully passed all stages""" |         """User Successfully passed all stages""" | ||||||
|         # Since this is wrapped by the ExecutorShell, the next argument is saved in the session |         # Since this is wrapped by the ExecutorShell, the next argument is saved in the session | ||||||
| @ -293,10 +336,10 @@ class FlowExecutorView(APIView): | |||||||
|         ) |         ) | ||||||
|         self.plan.pop() |         self.plan.pop() | ||||||
|         self.request.session[SESSION_KEY_PLAN] = self.plan |         self.request.session[SESSION_KEY_PLAN] = self.plan | ||||||
|         if self.plan.stages: |         if self.plan.bindings: | ||||||
|             self._logger.debug( |             self._logger.debug( | ||||||
|                 "f(exec): Continuing with next stage", |                 "f(exec): Continuing with next stage", | ||||||
|                 remaining=len(self.plan.stages), |                 remaining=len(self.plan.bindings), | ||||||
|             ) |             ) | ||||||
|             kwargs = self.kwargs |             kwargs = self.kwargs | ||||||
|             kwargs.update({"flow_slug": self.flow.slug}) |             kwargs.update({"flow_slug": self.flow.slug}) | ||||||
|  | |||||||
| @ -26,10 +26,9 @@ class ConfigLoader: | |||||||
|  |  | ||||||
|     loaded_file = [] |     loaded_file = [] | ||||||
|  |  | ||||||
|     __config = {} |  | ||||||
|  |  | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|  |         self.__config = {} | ||||||
|         base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../..")) |         base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../..")) | ||||||
|         for path in SEARCH_PATHS: |         for path in SEARCH_PATHS: | ||||||
|             # Check if path is relative, and if so join with base_dir |             # Check if path is relative, and if so join with base_dir | ||||||
|  | |||||||
| @ -13,7 +13,10 @@ web: | |||||||
|  |  | ||||||
| redis: | redis: | ||||||
|   host: localhost |   host: localhost | ||||||
|  |   port: 6379 | ||||||
|   password: '' |   password: '' | ||||||
|  |   tls: false | ||||||
|  |   tls_reqs: "none" | ||||||
|   cache_db: 0 |   cache_db: 0 | ||||||
|   message_queue_db: 1 |   message_queue_db: 1 | ||||||
|   ws_db: 2 |   ws_db: 2 | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import re | |||||||
| from textwrap import indent | from textwrap import indent | ||||||
| from typing import Any, Iterable, Optional | from typing import Any, Iterable, Optional | ||||||
|  |  | ||||||
|  | from django.core.exceptions import FieldError | ||||||
| from requests import Session | from requests import Session | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from sentry_sdk.hub import Hub | from sentry_sdk.hub import Hub | ||||||
| @ -29,10 +30,10 @@ class BaseEvaluator: | |||||||
|         # update website/docs/expressions/_objects.md |         # update website/docs/expressions/_objects.md | ||||||
|         # update website/docs/expressions/_functions.md |         # update website/docs/expressions/_functions.md | ||||||
|         self._globals = { |         self._globals = { | ||||||
|             "regex_match": BaseEvaluator.expr_filter_regex_match, |             "regex_match": BaseEvaluator.expr_regex_match, | ||||||
|             "regex_replace": BaseEvaluator.expr_filter_regex_replace, |             "regex_replace": BaseEvaluator.expr_regex_replace, | ||||||
|             "ak_is_group_member": BaseEvaluator.expr_func_is_group_member, |             "ak_is_group_member": BaseEvaluator.expr_is_group_member, | ||||||
|             "ak_user_by": BaseEvaluator.expr_func_user_by, |             "ak_user_by": BaseEvaluator.expr_user_by, | ||||||
|             "ak_logger": get_logger(), |             "ak_logger": get_logger(), | ||||||
|             "requests": Session(), |             "requests": Session(), | ||||||
|         } |         } | ||||||
| @ -40,25 +41,28 @@ class BaseEvaluator: | |||||||
|         self._filename = "BaseEvalautor" |         self._filename = "BaseEvalautor" | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_filter_regex_match(value: Any, regex: str) -> bool: |     def expr_regex_match(value: Any, regex: str) -> bool: | ||||||
|         """Expression Filter to run re.search""" |         """Expression Filter to run re.search""" | ||||||
|         return re.search(regex, value) is None |         return re.search(regex, value) is not None | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_filter_regex_replace(value: Any, regex: str, repl: str) -> str: |     def expr_regex_replace(value: Any, regex: str, repl: str) -> str: | ||||||
|         """Expression Filter to run re.sub""" |         """Expression Filter to run re.sub""" | ||||||
|         return re.sub(regex, repl, value) |         return re.sub(regex, repl, value) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_func_user_by(**filters) -> Optional[User]: |     def expr_user_by(**filters) -> Optional[User]: | ||||||
|         """Get user by filters""" |         """Get user by filters""" | ||||||
|  |         try: | ||||||
|             users = User.objects.filter(**filters) |             users = User.objects.filter(**filters) | ||||||
|             if users: |             if users: | ||||||
|                 return users.first() |                 return users.first() | ||||||
|             return None |             return None | ||||||
|  |         except FieldError: | ||||||
|  |             return None | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_func_is_group_member(user: User, **group_filters) -> bool: |     def expr_is_group_member(user: User, **group_filters) -> bool: | ||||||
|         """Check if `user` is member of group with name `group_name`""" |         """Check if `user` is member of group with name `group_name`""" | ||||||
|         return user.ak_groups.filter(**group_filters).exists() |         return user.ak_groups.filter(**group_filters).exists() | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										32
									
								
								authentik/lib/tests/test_evaluator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								authentik/lib/tests/test_evaluator.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | """Test Evaluator base functions""" | ||||||
|  | from django.test import TestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
|  | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestEvaluator(TestCase): | ||||||
|  |     """Test Evaluator base functions""" | ||||||
|  |  | ||||||
|  |     def test_regex_match(self): | ||||||
|  |         """Test expr_regex_match""" | ||||||
|  |         self.assertFalse(BaseEvaluator.expr_regex_match("foo", "bar")) | ||||||
|  |         self.assertTrue(BaseEvaluator.expr_regex_match("foo", "foo")) | ||||||
|  |  | ||||||
|  |     def test_regex_replace(self): | ||||||
|  |         """Test expr_regex_replace""" | ||||||
|  |         self.assertEqual(BaseEvaluator.expr_regex_replace("foo", "o", "a"), "faa") | ||||||
|  |  | ||||||
|  |     def test_user_by(self): | ||||||
|  |         """Test expr_user_by""" | ||||||
|  |         self.assertIsNotNone(BaseEvaluator.expr_user_by(username="akadmin")) | ||||||
|  |         self.assertIsNone(BaseEvaluator.expr_user_by(username="bar")) | ||||||
|  |         self.assertIsNone(BaseEvaluator.expr_user_by(foo="bar")) | ||||||
|  |  | ||||||
|  |     def test_is_group_member(self): | ||||||
|  |         """Test expr_is_group_member""" | ||||||
|  |         self.assertFalse( | ||||||
|  |             BaseEvaluator.expr_is_group_member( | ||||||
|  |                 User.objects.get(username="akadmin"), name="test" | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
| @ -2,10 +2,12 @@ | |||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | ||||||
| USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | OUTPOST_TOKEN_HEADER = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN"  # nosec | ||||||
| DEFAULT_IP = "255.255.255.255" | DEFAULT_IP = "255.255.255.255" | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| def _get_client_ip_from_meta(meta: dict[str, Any]) -> str: | def _get_client_ip_from_meta(meta: dict[str, Any]) -> str: | ||||||
| @ -27,13 +29,25 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: | |||||||
|     """Get the actual remote IP when set by an outpost. Only |     """Get the actual remote IP when set by an outpost. Only | ||||||
|     allowed when the request is authenticated, by a user with USER_ATTRIBUTE_CAN_OVERRIDE_IP set |     allowed when the request is authenticated, by a user with USER_ATTRIBUTE_CAN_OVERRIDE_IP set | ||||||
|     to outpost""" |     to outpost""" | ||||||
|     if not hasattr(request, "user"): |     from authentik.core.models import ( | ||||||
|  |         USER_ATTRIBUTE_CAN_OVERRIDE_IP, | ||||||
|  |         Token, | ||||||
|  |         TokenIntents, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     if ( | ||||||
|  |         OUTPOST_REMOTE_IP_HEADER not in request.META | ||||||
|  |         or OUTPOST_TOKEN_HEADER not in request.META | ||||||
|  |     ): | ||||||
|         return None |         return None | ||||||
|     if not request.user.is_authenticated: |     tokens = Token.filter_not_expired( | ||||||
|  |         key=request.META.get(OUTPOST_TOKEN_HEADER), intent=TokenIntents.INTENT_API | ||||||
|  |     ) | ||||||
|  |     if not tokens.exists(): | ||||||
|  |         LOGGER.warning("Attempted remote-ip override without token") | ||||||
|         return None |         return None | ||||||
|     if OUTPOST_REMOTE_IP_HEADER not in request.META: |     user = tokens.first().user | ||||||
|         return None |     if user.group_attributes().get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): | ||||||
|     if request.user.group_attributes().get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): |  | ||||||
|         return None |         return None | ||||||
|     return request.META[OUTPOST_REMOTE_IP_HEADER] |     return request.META[OUTPOST_REMOTE_IP_HEADER] | ||||||
|  |  | ||||||
|  | |||||||
| @ -51,7 +51,7 @@ class OutpostSerializer(ModelSerializer): | |||||||
|                 raise ValidationError( |                 raise ValidationError( | ||||||
|                     ( |                     ( | ||||||
|                         f"Outpost type {self.initial_data['type']} can't be used with " |                         f"Outpost type {self.initial_data['type']} can't be used with " | ||||||
|                         f"{type(provider)} providers." |                         f"{provider.__class__.__name__} providers." | ||||||
|                     ) |                     ) | ||||||
|                 ) |                 ) | ||||||
|         return providers |         return providers | ||||||
| @ -77,6 +77,7 @@ class OutpostSerializer(ModelSerializer): | |||||||
|             "service_connection_obj", |             "service_connection_obj", | ||||||
|             "token_identifier", |             "token_identifier", | ||||||
|             "config", |             "config", | ||||||
|  |             "managed", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = {"type": {"required": True}} |         extra_kwargs = {"type": {"required": True}} | ||||||
|  |  | ||||||
|  | |||||||
| @ -69,7 +69,7 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|         self.last_uid = self.channel_name |         self.last_uid = self.channel_name | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def disconnect(self, close_code): |     def disconnect(self, code): | ||||||
|         if self.outpost and self.last_uid: |         if self.outpost and self.last_uid: | ||||||
|             state = OutpostState.for_instance_uid(self.outpost, self.last_uid) |             state = OutpostState.for_instance_uid(self.outpost, self.last_uid) | ||||||
|             if self.channel_name in state.channel_ids: |             if self.channel_name in state.channel_ids: | ||||||
|  | |||||||
| @ -36,8 +36,10 @@ class DockerController(BaseController): | |||||||
|  |  | ||||||
|     def _get_env(self) -> dict[str, str]: |     def _get_env(self) -> dict[str, str]: | ||||||
|         return { |         return { | ||||||
|             "AUTHENTIK_HOST": self.outpost.config.authentik_host, |             "AUTHENTIK_HOST": self.outpost.config.authentik_host.lower(), | ||||||
|             "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure), |             "AUTHENTIK_INSECURE": str( | ||||||
|  |                 self.outpost.config.authentik_host_insecure | ||||||
|  |             ).lower(), | ||||||
|             "AUTHENTIK_TOKEN": self.outpost.token.key, |             "AUTHENTIK_TOKEN": self.outpost.token.key, | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @ -45,11 +47,34 @@ class DockerController(BaseController): | |||||||
|         """Check if container's env is equal to what we would set. Return true if container needs |         """Check if container's env is equal to what we would set. Return true if container needs | ||||||
|         to be rebuilt.""" |         to be rebuilt.""" | ||||||
|         should_be = self._get_env() |         should_be = self._get_env() | ||||||
|         container_env = container.attrs.get("Config", {}).get("Env", {}) |         container_env = container.attrs.get("Config", {}).get("Env", []) | ||||||
|         for key, expected_value in should_be.items(): |         for key, expected_value in should_be.items(): | ||||||
|             if key not in container_env: |             entry = f"{key.upper()}={expected_value}" | ||||||
|                 continue |             if entry not in container_env: | ||||||
|             if container_env[key] != expected_value: |                 return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     def _comp_ports(self, container: Container) -> bool: | ||||||
|  |         """Check that the container has the correct ports exposed. Return true if container needs | ||||||
|  |         to be rebuilt.""" | ||||||
|  |         # with TEST enabled, we use host-network | ||||||
|  |         if settings.TEST: | ||||||
|  |             return False | ||||||
|  |         # When the container isn't running, the API doesn't report any port mappings | ||||||
|  |         if container.status != "running": | ||||||
|  |             return False | ||||||
|  |         # {'3389/tcp': [ | ||||||
|  |         #   {'HostIp': '0.0.0.0', 'HostPort': '389'}, | ||||||
|  |         #   {'HostIp': '::', 'HostPort': '389'} | ||||||
|  |         # ]} | ||||||
|  |         for port in self.deployment_ports: | ||||||
|  |             key = f"{port.inner_port or port.port}/{port.protocol.lower()}" | ||||||
|  |             if key not in container.ports: | ||||||
|  |                 return True | ||||||
|  |             host_matching = False | ||||||
|  |             for host_port in container.ports[key]: | ||||||
|  |                 host_matching = host_port.get("HostPort") == str(port.port) | ||||||
|  |             if not host_matching: | ||||||
|                 return True |                 return True | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
| @ -58,7 +83,7 @@ class DockerController(BaseController): | |||||||
|         try: |         try: | ||||||
|             return self.client.containers.get(container_name), False |             return self.client.containers.get(container_name), False | ||||||
|         except NotFound: |         except NotFound: | ||||||
|             self.logger.info("Container does not exist, creating") |             self.logger.info("(Re-)creating container...") | ||||||
|             image_name = self.get_container_image() |             image_name = self.get_container_image() | ||||||
|             self.client.images.pull(image_name) |             self.client.images.pull(image_name) | ||||||
|             container_args = { |             container_args = { | ||||||
| @ -86,6 +111,7 @@ class DockerController(BaseController): | |||||||
|         try: |         try: | ||||||
|             container, has_been_created = self._get_container() |             container, has_been_created = self._get_container() | ||||||
|             if has_been_created: |             if has_been_created: | ||||||
|  |                 container.start() | ||||||
|                 return None |                 return None | ||||||
|             # Check if the container is out of date, delete it and retry |             # Check if the container is out of date, delete it and retry | ||||||
|             if len(container.image.tags) > 0: |             if len(container.image.tags) > 0: | ||||||
| @ -98,6 +124,11 @@ class DockerController(BaseController): | |||||||
|                     ) |                     ) | ||||||
|                     self.down() |                     self.down() | ||||||
|                     return self.up() |                     return self.up() | ||||||
|  |             # Check container's ports | ||||||
|  |             if self._comp_ports(container): | ||||||
|  |                 self.logger.info("Container has mis-matched ports, re-creating...") | ||||||
|  |                 self.down() | ||||||
|  |                 return self.up() | ||||||
|             # Check that container values match our values |             # Check that container values match our values | ||||||
|             if self._comp_env(container): |             if self._comp_env(container): | ||||||
|                 self.logger.info("Container has outdated config, re-creating...") |                 self.logger.info("Container has outdated config, re-creating...") | ||||||
| @ -138,6 +169,7 @@ class DockerController(BaseController): | |||||||
|                 self.logger.info("Container is not running, restarting...") |                 self.logger.info("Container is not running, restarting...") | ||||||
|                 container.start() |                 container.start() | ||||||
|                 return None |                 return None | ||||||
|  |             self.logger.info("Container is running") | ||||||
|             return None |             return None | ||||||
|         except DockerException as exc: |         except DockerException as exc: | ||||||
|             raise ControllerException(str(exc)) from exc |             raise ControllerException(str(exc)) from exc | ||||||
|  | |||||||
							
								
								
									
										18
									
								
								authentik/outposts/managed.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								authentik/outposts/managed.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | |||||||
|  | """Outpost managed objects""" | ||||||
|  | from authentik.managed.manager import EnsureExists, ObjectManager | ||||||
|  | from authentik.outposts.models import Outpost, OutpostType | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OutpostManager(ObjectManager): | ||||||
|  |     """Outpost managed objects""" | ||||||
|  |  | ||||||
|  |     def reconcile(self): | ||||||
|  |         return [ | ||||||
|  |             EnsureExists( | ||||||
|  |                 Outpost, | ||||||
|  |                 "goauthentik.io/outposts/inbuilt", | ||||||
|  |                 name="authentik Bundeled Outpost", | ||||||
|  |                 object_field="name", | ||||||
|  |                 type=OutpostType.PROXY, | ||||||
|  |             ), | ||||||
|  |         ] | ||||||
							
								
								
									
										24
									
								
								authentik/outposts/migrations/0017_outpost_managed.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								authentik/outposts/migrations/0017_outpost_managed.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-06-23 19:25 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_outposts", "0016_alter_outpost_type"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="outpost", | ||||||
|  |             name="managed", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 default=None, | ||||||
|  |                 help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.", | ||||||
|  |                 null=True, | ||||||
|  |                 unique=True, | ||||||
|  |                 verbose_name="Managed by authentik", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -28,12 +28,19 @@ from structlog.stdlib import get_logger | |||||||
| from urllib3.exceptions import HTTPError | from urllib3.exceptions import HTTPError | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import ENV_GIT_HASH_KEY, __version__ | ||||||
| from authentik.core.models import USER_ATTRIBUTE_SA, Provider, Token, TokenIntents, User | from authentik.core.models import ( | ||||||
|  |     USER_ATTRIBUTE_CAN_OVERRIDE_IP, | ||||||
|  |     USER_ATTRIBUTE_SA, | ||||||
|  |     Provider, | ||||||
|  |     Token, | ||||||
|  |     TokenIntents, | ||||||
|  |     User, | ||||||
|  | ) | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.models import InheritanceForeignKey | from authentik.lib.models import InheritanceForeignKey | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.lib.utils.http import USER_ATTRIBUTE_CAN_OVERRIDE_IP | from authentik.managed.models import ManagedModel | ||||||
| from authentik.outposts.controllers.k8s.utils import get_namespace | from authentik.outposts.controllers.k8s.utils import get_namespace | ||||||
| from authentik.outposts.docker_tls import DockerInlineTLS | from authentik.outposts.docker_tls import DockerInlineTLS | ||||||
|  |  | ||||||
| @ -281,7 +288,7 @@ class KubernetesServiceConnection(OutpostServiceConnection): | |||||||
|         verbose_name_plural = _("Kubernetes Service-Connections") |         verbose_name_plural = _("Kubernetes Service-Connections") | ||||||
|  |  | ||||||
|  |  | ||||||
| class Outpost(models.Model): | class Outpost(ManagedModel): | ||||||
|     """Outpost instance which manages a service user and token""" |     """Outpost instance which manages a service user and token""" | ||||||
|  |  | ||||||
|     uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) |     uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) | ||||||
| @ -405,7 +412,10 @@ class Outpost(models.Model): | |||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: |     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||||
|         """Get an iterator of all objects the user needs read access to""" |         """Get an iterator of all objects the user needs read access to""" | ||||||
|         objects: list[Union[models.Model, str]] = [self] |         objects: list[Union[models.Model, str]] = [ | ||||||
|  |             self, | ||||||
|  |             "authentik_events.add_event", | ||||||
|  |         ] | ||||||
|         for provider in ( |         for provider in ( | ||||||
|             Provider.objects.filter(outpost=self).select_related().select_subclasses() |             Provider.objects.filter(outpost=self).select_related().select_subclasses() | ||||||
|         ): |         ): | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ CELERY_BEAT_SCHEDULE = { | |||||||
|     }, |     }, | ||||||
|     "outposts_service_connection_check": { |     "outposts_service_connection_check": { | ||||||
|         "task": "authentik.outposts.tasks.outpost_service_connection_monitor", |         "task": "authentik.outposts.tasks.outpost_service_connection_monitor", | ||||||
|         "schedule": crontab(minute="*/60"), |         "schedule": crontab(minute="*/5"), | ||||||
|         "options": {"queue": "authentik_scheduled"}, |         "options": {"queue": "authentik_scheduled"}, | ||||||
|     }, |     }, | ||||||
|     "outpost_token_ensurer": { |     "outpost_token_ensurer": { | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """authentik outpost signals""" | """authentik outpost signals""" | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.signals import post_save, pre_delete, pre_save | from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -46,6 +46,14 @@ def pre_save_outpost(sender, instance: Outpost, **_): | |||||||
|         outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) |         outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(m2m_changed, sender=Outpost.providers.through) | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | def m2m_changed_update(sender, instance: Model, action: str, **_): | ||||||
|  |     """Update outpost on m2m change, when providers are added or removed""" | ||||||
|  |     if action in ["post_add", "post_remove", "post_clear"]: | ||||||
|  |         outpost_post_save.delay(class_to_path(instance.__class__), instance.pk) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def post_save_update(sender, instance: Model, **_): | def post_save_update(sender, instance: Model, **_): | ||||||
|  | |||||||
| @ -82,13 +82,13 @@ class PolicyBindingSerializer(ModelSerializer): | |||||||
|             "timeout", |             "timeout", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|     def validate(self, data: OrderedDict) -> OrderedDict: |     def validate(self, attrs: OrderedDict) -> OrderedDict: | ||||||
|         """Check that either policy, group or user is set.""" |         """Check that either policy, group or user is set.""" | ||||||
|         count = sum( |         count = sum( | ||||||
|             [ |             [ | ||||||
|                 bool(data.get("policy", None)), |                 bool(attrs.get("policy", None)), | ||||||
|                 bool(data.get("group", None)), |                 bool(attrs.get("group", None)), | ||||||
|                 bool(data.get("user", None)), |                 bool(attrs.get("user", None)), | ||||||
|             ] |             ] | ||||||
|         ) |         ) | ||||||
|         invalid = count > 1 |         invalid = count > 1 | ||||||
| @ -97,7 +97,7 @@ class PolicyBindingSerializer(ModelSerializer): | |||||||
|             raise ValidationError("Only one of 'policy', 'group' or 'user' can be set.") |             raise ValidationError("Only one of 'policy', 'group' or 'user' can be set.") | ||||||
|         if empty: |         if empty: | ||||||
|             raise ValidationError("One of 'policy', 'group' or 'user' must be set.") |             raise ValidationError("One of 'policy', 'group' or 'user' must be set.") | ||||||
|         return data |         return attrs | ||||||
|  |  | ||||||
|  |  | ||||||
| class PolicyBindingViewSet(UsedByMixin, ModelViewSet): | class PolicyBindingViewSet(UsedByMixin, ModelViewSet): | ||||||
|  | |||||||
| @ -62,12 +62,6 @@ class PolicyEngine: | |||||||
|     # Allow objects with no policies attached to pass |     # Allow objects with no policies attached to pass | ||||||
|     empty_result: bool |     empty_result: bool | ||||||
|  |  | ||||||
|     __pbm: PolicyBindingModel |  | ||||||
|     __cached_policies: list[PolicyResult] |  | ||||||
|     __processes: list[PolicyProcessInfo] |  | ||||||
|  |  | ||||||
|     __expected_result_count: int |  | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None |         self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None | ||||||
|     ): |     ): | ||||||
| @ -83,8 +77,8 @@ class PolicyEngine: | |||||||
|         self.request.obj = pbm |         self.request.obj = pbm | ||||||
|         if request: |         if request: | ||||||
|             self.request.set_http_request(request) |             self.request.set_http_request(request) | ||||||
|         self.__cached_policies = [] |         self.__cached_policies: list[PolicyResult] = [] | ||||||
|         self.__processes = [] |         self.__processes: list[PolicyProcessInfo] = [] | ||||||
|         self.use_cache = True |         self.use_cache = True | ||||||
|         self.__expected_result_count = 0 |         self.__expected_result_count = 0 | ||||||
|  |  | ||||||
|  | |||||||
| @ -0,0 +1,49 @@ | |||||||
|  | # Generated by Django 3.2.5 on 2021-07-14 19:15 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies_event_matcher", "0017_alter_eventmatcherpolicy_action"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="eventmatcherpolicy", | ||||||
|  |             name="action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 blank=True, | ||||||
|  |                 choices=[ | ||||||
|  |                     ("login", "Login"), | ||||||
|  |                     ("login_failed", "Login Failed"), | ||||||
|  |                     ("logout", "Logout"), | ||||||
|  |                     ("user_write", "User Write"), | ||||||
|  |                     ("suspicious_request", "Suspicious Request"), | ||||||
|  |                     ("password_set", "Password Set"), | ||||||
|  |                     ("secret_view", "Secret View"), | ||||||
|  |                     ("secret_rotate", "Secret Rotate"), | ||||||
|  |                     ("invitation_used", "Invite Used"), | ||||||
|  |                     ("authorize_application", "Authorize Application"), | ||||||
|  |                     ("source_linked", "Source Linked"), | ||||||
|  |                     ("impersonation_started", "Impersonation Started"), | ||||||
|  |                     ("impersonation_ended", "Impersonation Ended"), | ||||||
|  |                     ("policy_execution", "Policy Execution"), | ||||||
|  |                     ("policy_exception", "Policy Exception"), | ||||||
|  |                     ("property_mapping_exception", "Property Mapping Exception"), | ||||||
|  |                     ("system_task_execution", "System Task Execution"), | ||||||
|  |                     ("system_task_exception", "System Task Exception"), | ||||||
|  |                     ("system_exception", "System Exception"), | ||||||
|  |                     ("configuration_error", "Configuration Error"), | ||||||
|  |                     ("model_created", "Model Created"), | ||||||
|  |                     ("model_updated", "Model Updated"), | ||||||
|  |                     ("model_deleted", "Model Deleted"), | ||||||
|  |                     ("email_sent", "Email Sent"), | ||||||
|  |                     ("update_available", "Update Available"), | ||||||
|  |                     ("custom_", "Custom Prefix"), | ||||||
|  |                 ], | ||||||
|  |                 help_text="Match created events with this action type. When left empty, all action types will be matched.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -33,21 +33,21 @@ class ReputationPolicy(Policy): | |||||||
|  |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: |     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||||
|         remote_ip = get_client_ip(request.http_request) |         remote_ip = get_client_ip(request.http_request) | ||||||
|         passing = True |         passing = False | ||||||
|         if self.check_ip: |         if self.check_ip: | ||||||
|             score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) |             score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) | ||||||
|             passing = passing and score <= self.threshold |             passing += passing or score <= self.threshold | ||||||
|             LOGGER.debug("Score for IP", ip=remote_ip, score=score, passing=passing) |             LOGGER.debug("Score for IP", ip=remote_ip, score=score, passing=passing) | ||||||
|         if self.check_username: |         if self.check_username: | ||||||
|             score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) |             score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) | ||||||
|             passing = passing and score <= self.threshold |             passing += passing or score <= self.threshold | ||||||
|             LOGGER.debug( |             LOGGER.debug( | ||||||
|                 "Score for Username", |                 "Score for Username", | ||||||
|                 username=request.user.username, |                 username=request.user.username, | ||||||
|                 score=score, |                 score=score, | ||||||
|                 passing=passing, |                 passing=passing, | ||||||
|             ) |             ) | ||||||
|         return PolicyResult(passing) |         return PolicyResult(bool(passing)) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -21,12 +21,15 @@ def update_score(request: HttpRequest, username: str, amount: int): | |||||||
|     """Update score for IP and User""" |     """Update score for IP and User""" | ||||||
|     remote_ip = get_client_ip(request) |     remote_ip = get_client_ip(request) | ||||||
|  |  | ||||||
|  |     try: | ||||||
|         # We only update the cache here, as its faster than writing to the DB |         # We only update the cache here, as its faster than writing to the DB | ||||||
|         cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0, CACHE_TIMEOUT) |         cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0, CACHE_TIMEOUT) | ||||||
|         cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) |         cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) | ||||||
|  |  | ||||||
|         cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT) |         cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT) | ||||||
|         cache.incr(CACHE_KEY_USER_PREFIX + username, amount) |         cache.incr(CACHE_KEY_USER_PREFIX + username, amount) | ||||||
|  |     except ValueError as exc: | ||||||
|  |         LOGGER.warning("failed to set reputation", exc=exc) | ||||||
|  |  | ||||||
|     LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) |     LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) | ||||||
|  |  | ||||||
|  | |||||||
| @ -17,6 +17,10 @@ class LDAPProviderSerializer(ProviderSerializer): | |||||||
|         fields = ProviderSerializer.Meta.fields + [ |         fields = ProviderSerializer.Meta.fields + [ | ||||||
|             "base_dn", |             "base_dn", | ||||||
|             "search_group", |             "search_group", | ||||||
|  |             "certificate", | ||||||
|  |             "tls_server_name", | ||||||
|  |             "uid_start_number", | ||||||
|  |             "gid_start_number", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -44,6 +48,10 @@ class LDAPOutpostConfigSerializer(ModelSerializer): | |||||||
|             "bind_flow_slug", |             "bind_flow_slug", | ||||||
|             "application_slug", |             "application_slug", | ||||||
|             "search_group", |             "search_group", | ||||||
|  |             "certificate", | ||||||
|  |             "tls_server_name", | ||||||
|  |             "uid_start_number", | ||||||
|  |             "gid_start_number", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,4 +11,5 @@ class LDAPDockerController(DockerController): | |||||||
|         super().__init__(outpost, connection) |         super().__init__(outpost, connection) | ||||||
|         self.deployment_ports = [ |         self.deployment_ports = [ | ||||||
|             DeploymentPort(389, "ldap", "tcp", 3389), |             DeploymentPort(389, "ldap", "tcp", 3389), | ||||||
|  |             DeploymentPort(636, "ldaps", "tcp", 6636), | ||||||
|         ] |         ] | ||||||
|  | |||||||
| @ -11,4 +11,5 @@ class LDAPKubernetesController(KubernetesController): | |||||||
|         super().__init__(outpost, connection) |         super().__init__(outpost, connection) | ||||||
|         self.deployment_ports = [ |         self.deployment_ports = [ | ||||||
|             DeploymentPort(389, "ldap", "tcp", 3389), |             DeploymentPort(389, "ldap", "tcp", 3389), | ||||||
|  |             DeploymentPort(636, "ldaps", "tcp", 6636), | ||||||
|         ] |         ] | ||||||
|  | |||||||
| @ -0,0 +1,30 @@ | |||||||
|  | # Generated by Django 3.2.5 on 2021-07-13 11:38 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_crypto", "0002_create_self_signed_kp"), | ||||||
|  |         ("authentik_providers_ldap", "0002_ldapprovider_search_group"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="ldapprovider", | ||||||
|  |             name="certificate", | ||||||
|  |             field=models.ForeignKey( | ||||||
|  |                 blank=True, | ||||||
|  |                 null=True, | ||||||
|  |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  |                 to="authentik_crypto.certificatekeypair", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="ldapprovider", | ||||||
|  |             name="tls_server_name", | ||||||
|  |             field=models.TextField(blank=True, default=""), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,29 @@ | |||||||
|  | # Generated by Django 3.2.5 on 2021-07-13 21:15 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_providers_ldap", "0003_auto_20210713_1138"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="ldapprovider", | ||||||
|  |             name="gid_start_number", | ||||||
|  |             field=models.IntegerField( | ||||||
|  |                 default=4000, | ||||||
|  |                 help_text="The start for gidNumbers, this number is added to a number generated from the group.Pk to make sure that the numbers aren't too low for POSIX groups. Default is 4000 to ensure that we don't collide with local groups or users primary groups gidNumber", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="ldapprovider", | ||||||
|  |             name="uid_start_number", | ||||||
|  |             field=models.IntegerField( | ||||||
|  |                 default=2000, | ||||||
|  |                 help_text="The start for uidNumbers, this number is added to the user.Pk to make sure that the numbers aren't too low for POSIX users. Default is 2000 to ensure that we don't collide with local users uidNumber", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ | |||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
|  |  | ||||||
| from authentik.core.models import Group, Provider | from authentik.core.models import Group, Provider | ||||||
|  | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.outposts.models import OutpostModel | from authentik.outposts.models import OutpostModel | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -28,6 +29,36 @@ class LDAPProvider(OutpostModel, Provider): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     tls_server_name = models.TextField( | ||||||
|  |         default="", | ||||||
|  |         blank=True, | ||||||
|  |     ) | ||||||
|  |     certificate = models.ForeignKey( | ||||||
|  |         CertificateKeyPair, | ||||||
|  |         on_delete=models.SET_NULL, | ||||||
|  |         null=True, | ||||||
|  |         blank=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     uid_start_number = models.IntegerField( | ||||||
|  |         default=2000, | ||||||
|  |         help_text=_( | ||||||
|  |             "The start for uidNumbers, this number is added to the user.Pk to make sure that the " | ||||||
|  |             "numbers aren't too low for POSIX users. Default is 2000 to ensure that we don't " | ||||||
|  |             "collide with local users uidNumber" | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     gid_start_number = models.IntegerField( | ||||||
|  |         default=4000, | ||||||
|  |         help_text=_( | ||||||
|  |             "The start for gidNumbers, this number is added to a number generated from the " | ||||||
|  |             "group.Pk to make sure that the numbers aren't too low for POSIX groups. Default " | ||||||
|  |             "is 4000 to ensure that we don't collide with local groups or users " | ||||||
|  |             "primary groups gidNumber" | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def launch_url(self) -> Optional[str]: |     def launch_url(self) -> Optional[str]: | ||||||
|         """LDAP never has a launch URL""" |         """LDAP never has a launch URL""" | ||||||
|  | |||||||
| @ -51,6 +51,7 @@ class RefreshTokenModelSerializer(ExpiringBaseGrantModelSerializer): | |||||||
|             "expires", |             "expires", | ||||||
|             "scope", |             "scope", | ||||||
|             "id_token", |             "id_token", | ||||||
|  |             "revoked", | ||||||
|         ] |         ] | ||||||
|         depth = 2 |         depth = 2 | ||||||
|  |  | ||||||
|  | |||||||
| @ -109,6 +109,7 @@ class Migration(migrations.Migration): | |||||||
|                     "redirect_uris", |                     "redirect_uris", | ||||||
|                     models.TextField( |                     models.TextField( | ||||||
|                         default="", |                         default="", | ||||||
|  |                         blank=True, | ||||||
|                         help_text="Enter each URI on a new line.", |                         help_text="Enter each URI on a new line.", | ||||||
|                         verbose_name="Redirect URIs", |                         verbose_name="Redirect URIs", | ||||||
|                     ), |                     ), | ||||||
|  | |||||||
| @ -0,0 +1,23 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-07-03 13:13 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_providers_oauth2", "0014_alter_oauth2provider_rsa_key"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="authorizationcode", | ||||||
|  |             name="revoked", | ||||||
|  |             field=models.BooleanField(default=False), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="refreshtoken", | ||||||
|  |             name="revoked", | ||||||
|  |             field=models.BooleanField(default=False), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -0,0 +1,18 @@ | |||||||
|  | # Generated by Django 3.2.5 on 2021-07-20 22:32 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_providers_oauth2", "0015_auto_20210703_1313"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="authorizationcode", | ||||||
|  |             name="nonce", | ||||||
|  |             field=models.TextField(default=None, null=True, verbose_name="Nonce"), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -158,6 +158,7 @@ class OAuth2Provider(Provider): | |||||||
|     ) |     ) | ||||||
|     redirect_uris = models.TextField( |     redirect_uris = models.TextField( | ||||||
|         default="", |         default="", | ||||||
|  |         blank=True, | ||||||
|         verbose_name=_("Redirect URIs"), |         verbose_name=_("Redirect URIs"), | ||||||
|         help_text=_("Enter each URI on a new line."), |         help_text=_("Enter each URI on a new line."), | ||||||
|     ) |     ) | ||||||
| @ -278,7 +279,7 @@ class OAuth2Provider(Provider): | |||||||
|         """Guess launch_url based on first redirect_uri""" |         """Guess launch_url based on first redirect_uri""" | ||||||
|         if self.redirect_uris == "": |         if self.redirect_uris == "": | ||||||
|             return None |             return None | ||||||
|         main_url = self.redirect_uris.split("\n")[0] |         main_url = self.redirect_uris.split("\n", maxsplit=1)[0] | ||||||
|         launch_url = urlparse(main_url) |         launch_url = urlparse(main_url) | ||||||
|         return main_url.replace(launch_url.path, "") |         return main_url.replace(launch_url.path, "") | ||||||
|  |  | ||||||
| @ -318,6 +319,7 @@ class BaseGrantModel(models.Model): | |||||||
|     provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE) |     provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE) | ||||||
|     user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) |     user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) | ||||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) |     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||||
|  |     revoked = models.BooleanField(default=False) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def scope(self) -> list[str]: |     def scope(self) -> list[str]: | ||||||
| @ -336,7 +338,7 @@ class AuthorizationCode(ExpiringModel, BaseGrantModel): | |||||||
|     """OAuth2 Authorization Code""" |     """OAuth2 Authorization Code""" | ||||||
|  |  | ||||||
|     code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) |     code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) | ||||||
|     nonce = models.TextField(blank=True, default="", verbose_name=_("Nonce")) |     nonce = models.TextField(null=True, default=None, verbose_name=_("Nonce")) | ||||||
|     is_open_id = models.BooleanField( |     is_open_id = models.BooleanField( | ||||||
|         default=False, verbose_name=_("Is Authentication?") |         default=False, verbose_name=_("Is Authentication?") | ||||||
|     ) |     ) | ||||||
| @ -473,9 +475,7 @@ class RefreshToken(ExpiringModel, BaseGrantModel): | |||||||
|         # Convert datetimes into timestamps. |         # Convert datetimes into timestamps. | ||||||
|         now = int(time.time()) |         now = int(time.time()) | ||||||
|         iat_time = now |         iat_time = now | ||||||
|         exp_time = int( |         exp_time = int(dateformat.format(self.expires, "U")) | ||||||
|             now + timedelta_from_string(self.provider.token_validity).seconds |  | ||||||
|         ) |  | ||||||
|         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time |         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time | ||||||
|         auth_events = Event.objects.filter( |         auth_events = Event.objects.filter( | ||||||
|             action=EventAction.LOGIN, user=get_user(user) |             action=EventAction.LOGIN, user=get_user(user) | ||||||
|  | |||||||
| @ -67,7 +67,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             ) |             ) | ||||||
|             OAuthAuthorizationParams.from_request(request) |             OAuthAuthorizationParams.from_request(request) | ||||||
|  |  | ||||||
|     def test_redirect_uri(self): |     def test_invalid_redirect_uri(self): | ||||||
|         """test missing/invalid redirect URI""" |         """test missing/invalid redirect URI""" | ||||||
|         OAuth2Provider.objects.create( |         OAuth2Provider.objects.create( | ||||||
|             name="test", |             name="test", | ||||||
| @ -91,6 +91,28 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             ) |             ) | ||||||
|             OAuthAuthorizationParams.from_request(request) |             OAuthAuthorizationParams.from_request(request) | ||||||
|  |  | ||||||
|  |     def test_empty_redirect_uri(self): | ||||||
|  |         """test empty redirect URI (configure in provider)""" | ||||||
|  |         OAuth2Provider.objects.create( | ||||||
|  |             name="test", | ||||||
|  |             client_id="test", | ||||||
|  |             authorization_flow=Flow.objects.first(), | ||||||
|  |         ) | ||||||
|  |         with self.assertRaises(RedirectUriError): | ||||||
|  |             request = self.factory.get( | ||||||
|  |                 "/", data={"response_type": "code", "client_id": "test"} | ||||||
|  |             ) | ||||||
|  |             OAuthAuthorizationParams.from_request(request) | ||||||
|  |         request = self.factory.get( | ||||||
|  |             "/", | ||||||
|  |             data={ | ||||||
|  |                 "response_type": "code", | ||||||
|  |                 "client_id": "test", | ||||||
|  |                 "redirect_uri": "http://localhost", | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         OAuthAuthorizationParams.from_request(request) | ||||||
|  |  | ||||||
|     def test_response_type(self): |     def test_response_type(self): | ||||||
|         """test response_type""" |         """test response_type""" | ||||||
|         OAuth2Provider.objects.create( |         OAuth2Provider.objects.create( | ||||||
|  | |||||||
| @ -6,6 +6,8 @@ from django.urls import reverse | |||||||
| from django.utils.encoding import force_str | from django.utils.encoding import force_str | ||||||
|  |  | ||||||
| from authentik.core.models import Application, User | from authentik.core.models import Application, User | ||||||
|  | from authentik.crypto.models import CertificateKeyPair | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
| from authentik.providers.oauth2.constants import ( | from authentik.providers.oauth2.constants import ( | ||||||
|     GRANT_TYPE_AUTHORIZATION_CODE, |     GRANT_TYPE_AUTHORIZATION_CODE, | ||||||
| @ -39,7 +41,8 @@ class TestToken(OAuthTestCase): | |||||||
|             client_id=generate_client_id(), |             client_id=generate_client_id(), | ||||||
|             client_secret=generate_client_secret(), |             client_secret=generate_client_secret(), | ||||||
|             authorization_flow=Flow.objects.first(), |             authorization_flow=Flow.objects.first(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://testserver", | ||||||
|  |             rsa_key=CertificateKeyPair.objects.first(), | ||||||
|         ) |         ) | ||||||
|         header = b64encode( |         header = b64encode( | ||||||
|             f"{provider.client_id}:{provider.client_secret}".encode() |             f"{provider.client_id}:{provider.client_secret}".encode() | ||||||
| @ -53,11 +56,13 @@ class TestToken(OAuthTestCase): | |||||||
|             data={ |             data={ | ||||||
|                 "grant_type": GRANT_TYPE_AUTHORIZATION_CODE, |                 "grant_type": GRANT_TYPE_AUTHORIZATION_CODE, | ||||||
|                 "code": code.code, |                 "code": code.code, | ||||||
|                 "redirect_uri": "http://local.invalid", |                 "redirect_uri": "http://testserver", | ||||||
|             }, |             }, | ||||||
|             HTTP_AUTHORIZATION=f"Basic {header}", |             HTTP_AUTHORIZATION=f"Basic {header}", | ||||||
|         ) |         ) | ||||||
|         params = TokenParams.from_request(request) |         params = TokenParams.parse( | ||||||
|  |             request, provider, provider.client_id, provider.client_secret | ||||||
|  |         ) | ||||||
|         self.assertEqual(params.provider, provider) |         self.assertEqual(params.provider, provider) | ||||||
|  |  | ||||||
|     def test_request_refresh_token(self): |     def test_request_refresh_token(self): | ||||||
| @ -68,6 +73,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_client_secret(), |             client_secret=generate_client_secret(), | ||||||
|             authorization_flow=Flow.objects.first(), |             authorization_flow=Flow.objects.first(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|  |             rsa_key=CertificateKeyPair.objects.first(), | ||||||
|         ) |         ) | ||||||
|         header = b64encode( |         header = b64encode( | ||||||
|             f"{provider.client_id}:{provider.client_secret}".encode() |             f"{provider.client_id}:{provider.client_secret}".encode() | ||||||
| @ -87,7 +93,9 @@ class TestToken(OAuthTestCase): | |||||||
|             }, |             }, | ||||||
|             HTTP_AUTHORIZATION=f"Basic {header}", |             HTTP_AUTHORIZATION=f"Basic {header}", | ||||||
|         ) |         ) | ||||||
|         params = TokenParams.from_request(request) |         params = TokenParams.parse( | ||||||
|  |             request, provider, provider.client_id, provider.client_secret | ||||||
|  |         ) | ||||||
|         self.assertEqual(params.provider, provider) |         self.assertEqual(params.provider, provider) | ||||||
|  |  | ||||||
|     def test_auth_code_view(self): |     def test_auth_code_view(self): | ||||||
| @ -98,6 +106,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_client_secret(), |             client_secret=generate_client_secret(), | ||||||
|             authorization_flow=Flow.objects.first(), |             authorization_flow=Flow.objects.first(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|  |             rsa_key=CertificateKeyPair.objects.first(), | ||||||
|         ) |         ) | ||||||
|         # Needs to be assigned to an application for iss to be set |         # Needs to be assigned to an application for iss to be set | ||||||
|         self.app.provider = provider |         self.app.provider = provider | ||||||
| @ -141,6 +150,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_client_secret(), |             client_secret=generate_client_secret(), | ||||||
|             authorization_flow=Flow.objects.first(), |             authorization_flow=Flow.objects.first(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|  |             rsa_key=CertificateKeyPair.objects.first(), | ||||||
|         ) |         ) | ||||||
|         # Needs to be assigned to an application for iss to be set |         # Needs to be assigned to an application for iss to be set | ||||||
|         self.app.provider = provider |         self.app.provider = provider | ||||||
| @ -193,6 +203,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_client_secret(), |             client_secret=generate_client_secret(), | ||||||
|             authorization_flow=Flow.objects.first(), |             authorization_flow=Flow.objects.first(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|  |             rsa_key=CertificateKeyPair.objects.first(), | ||||||
|         ) |         ) | ||||||
|         header = b64encode( |         header = b64encode( | ||||||
|             f"{provider.client_id}:{provider.client_secret}".encode() |             f"{provider.client_id}:{provider.client_secret}".encode() | ||||||
| @ -230,3 +241,65 @@ class TestToken(OAuthTestCase): | |||||||
|                 ), |                 ), | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def test_refresh_token_revoke(self): | ||||||
|  |         """test request param""" | ||||||
|  |         provider = OAuth2Provider.objects.create( | ||||||
|  |             name="test", | ||||||
|  |             client_id=generate_client_id(), | ||||||
|  |             client_secret=generate_client_secret(), | ||||||
|  |             authorization_flow=Flow.objects.first(), | ||||||
|  |             redirect_uris="http://testserver", | ||||||
|  |             rsa_key=CertificateKeyPair.objects.first(), | ||||||
|  |         ) | ||||||
|  |         # Needs to be assigned to an application for iss to be set | ||||||
|  |         self.app.provider = provider | ||||||
|  |         self.app.save() | ||||||
|  |         header = b64encode( | ||||||
|  |             f"{provider.client_id}:{provider.client_secret}".encode() | ||||||
|  |         ).decode() | ||||||
|  |         user = User.objects.get(username="akadmin") | ||||||
|  |         token: RefreshToken = RefreshToken.objects.create( | ||||||
|  |             provider=provider, | ||||||
|  |             user=user, | ||||||
|  |             refresh_token=generate_client_id(), | ||||||
|  |         ) | ||||||
|  |         # Create initial refresh token | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             data={ | ||||||
|  |                 "grant_type": GRANT_TYPE_REFRESH_TOKEN, | ||||||
|  |                 "refresh_token": token.refresh_token, | ||||||
|  |                 "redirect_uri": "http://testserver", | ||||||
|  |             }, | ||||||
|  |             HTTP_AUTHORIZATION=f"Basic {header}", | ||||||
|  |         ) | ||||||
|  |         new_token: RefreshToken = ( | ||||||
|  |             RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first() | ||||||
|  |         ) | ||||||
|  |         # Post again with initial token -> get new refresh token | ||||||
|  |         # and revoke old one | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             data={ | ||||||
|  |                 "grant_type": GRANT_TYPE_REFRESH_TOKEN, | ||||||
|  |                 "refresh_token": new_token.refresh_token, | ||||||
|  |                 "redirect_uri": "http://local.invalid", | ||||||
|  |             }, | ||||||
|  |             HTTP_AUTHORIZATION=f"Basic {header}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         # Post again with old token, is now revoked and should error | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             data={ | ||||||
|  |                 "grant_type": GRANT_TYPE_REFRESH_TOKEN, | ||||||
|  |                 "refresh_token": new_token.refresh_token, | ||||||
|  |                 "redirect_uri": "http://local.invalid", | ||||||
|  |             }, | ||||||
|  |             HTTP_AUTHORIZATION=f"Basic {header}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertTrue( | ||||||
|  |             Event.objects.filter(action=EventAction.SUSPICIOUS_REQUEST).exists() | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ from django.http.response import HttpResponseRedirect | |||||||
| from django.utils.cache import patch_vary_headers | from django.utils.cache import patch_vary_headers | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.providers.oauth2.errors import BearerTokenError | from authentik.providers.oauth2.errors import BearerTokenError | ||||||
| from authentik.providers.oauth2.models import RefreshToken | from authentik.providers.oauth2.models import RefreshToken | ||||||
|  |  | ||||||
| @ -50,7 +51,7 @@ def cors_allow(request: HttpRequest, response: HttpResponse, *allowed_origins: s | |||||||
|     if not allowed: |     if not allowed: | ||||||
|         LOGGER.warning( |         LOGGER.warning( | ||||||
|             "CORS: Origin is not an allowed origin", |             "CORS: Origin is not an allowed origin", | ||||||
|             requested=origin, |             requested=received_origin, | ||||||
|             allowed=allowed_origins, |             allowed=allowed_origins, | ||||||
|         ) |         ) | ||||||
|         return response |         return response | ||||||
| @ -132,22 +133,31 @@ def protected_resource_view(scopes: list[str]): | |||||||
|                     raise BearerTokenError("invalid_token") |                     raise BearerTokenError("invalid_token") | ||||||
|  |  | ||||||
|                 try: |                 try: | ||||||
|                     kwargs["token"] = RefreshToken.objects.get( |                     token: RefreshToken = RefreshToken.objects.get( | ||||||
|                         access_token=access_token |                         access_token=access_token | ||||||
|                     ) |                     ) | ||||||
|                 except RefreshToken.DoesNotExist: |                 except RefreshToken.DoesNotExist: | ||||||
|                     LOGGER.debug("Token does not exist", access_token=access_token) |                     LOGGER.debug("Token does not exist", access_token=access_token) | ||||||
|                     raise BearerTokenError("invalid_token") |                     raise BearerTokenError("invalid_token") | ||||||
|  |  | ||||||
|                 if kwargs["token"].is_expired: |                 if token.is_expired: | ||||||
|                     LOGGER.debug("Token has expired", access_token=access_token) |                     LOGGER.debug("Token has expired", access_token=access_token) | ||||||
|                     raise BearerTokenError("invalid_token") |                     raise BearerTokenError("invalid_token") | ||||||
|  |  | ||||||
|                 if not set(scopes).issubset(set(kwargs["token"].scope)): |                 if token.revoked: | ||||||
|  |                     LOGGER.warning("Revoked token was used", access_token=access_token) | ||||||
|  |                     Event.new( | ||||||
|  |                         action=EventAction.SUSPICIOUS_REQUEST, | ||||||
|  |                         message="Revoked refresh token was used", | ||||||
|  |                         token=access_token, | ||||||
|  |                     ).from_http(request) | ||||||
|  |                     raise BearerTokenError("invalid_token") | ||||||
|  |  | ||||||
|  |                 if not set(scopes).issubset(set(token.scope)): | ||||||
|                     LOGGER.warning( |                     LOGGER.warning( | ||||||
|                         "Scope missmatch.", |                         "Scope missmatch.", | ||||||
|                         required=set(scopes), |                         required=set(scopes), | ||||||
|                         token_has=set(kwargs["token"].scope), |                         token_has=set(token.scope), | ||||||
|                     ) |                     ) | ||||||
|                     raise BearerTokenError("insufficient_scope") |                     raise BearerTokenError("insufficient_scope") | ||||||
|             except BearerTokenError as error: |             except BearerTokenError as error: | ||||||
| @ -156,7 +166,7 @@ def protected_resource_view(scopes: list[str]): | |||||||
|                     "WWW-Authenticate" |                     "WWW-Authenticate" | ||||||
|                 ] = f'error="{error.code}", error_description="{error.description}"' |                 ] = f'error="{error.code}", error_description="{error.description}"' | ||||||
|                 return response |                 return response | ||||||
|  |             kwargs["token"] = token | ||||||
|             return view(request, *args, **kwargs) |             return view(request, *args, **kwargs) | ||||||
|  |  | ||||||
|         return view_wrapper |         return view_wrapper | ||||||
|  | |||||||
| @ -156,20 +156,23 @@ class OAuthAuthorizationParams: | |||||||
|  |  | ||||||
|     def check_redirect_uri(self): |     def check_redirect_uri(self): | ||||||
|         """Redirect URI validation.""" |         """Redirect URI validation.""" | ||||||
|  |         allowed_redirect_urls = self.provider.redirect_uris.split() | ||||||
|         if not self.redirect_uri: |         if not self.redirect_uri: | ||||||
|             LOGGER.warning("Missing redirect uri.") |             LOGGER.warning("Missing redirect uri.") | ||||||
|             raise RedirectUriError("", self.provider.redirect_uris.split()) |             raise RedirectUriError("", allowed_redirect_urls) | ||||||
|         if self.redirect_uri.lower() not in [ |         if len(allowed_redirect_urls) < 1: | ||||||
|             x.lower() for x in self.provider.redirect_uris.split() |             LOGGER.warning( | ||||||
|         ]: |                 "Provider has no allowed redirect_uri set, allowing all.", | ||||||
|  |                 allow=self.redirect_uri.lower(), | ||||||
|  |             ) | ||||||
|  |             return | ||||||
|  |         if self.redirect_uri.lower() not in [x.lower() for x in allowed_redirect_urls]: | ||||||
|             LOGGER.warning( |             LOGGER.warning( | ||||||
|                 "Invalid redirect uri", |                 "Invalid redirect uri", | ||||||
|                 redirect_uri=self.redirect_uri, |                 redirect_uri=self.redirect_uri, | ||||||
|                 excepted=self.provider.redirect_uris.split(), |                 excepted=allowed_redirect_urls, | ||||||
|             ) |  | ||||||
|             raise RedirectUriError( |  | ||||||
|                 self.redirect_uri, self.provider.redirect_uris.split() |  | ||||||
|             ) |             ) | ||||||
|  |             raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) | ||||||
|         if self.request: |         if self.request: | ||||||
|             raise AuthorizeError( |             raise AuthorizeError( | ||||||
|                 self.redirect_uri, "request_not_supported", self.grant_type, self.state |                 self.redirect_uri, "request_not_supported", self.grant_type, self.state | ||||||
| @ -189,6 +192,10 @@ class OAuthAuthorizationParams: | |||||||
|  |  | ||||||
|     def check_nonce(self): |     def check_nonce(self): | ||||||
|         """Nonce parameter validation.""" |         """Nonce parameter validation.""" | ||||||
|  |         # https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation | ||||||
|  |         # Nonce is only required for Implicit flows | ||||||
|  |         if self.grant_type != GrantTypes.IMPLICIT: | ||||||
|  |             return | ||||||
|         if not self.nonce: |         if not self.nonce: | ||||||
|             self.nonce = self.state |             self.nonce = self.state | ||||||
|             LOGGER.warning("Using state as nonce for OpenID Request") |             LOGGER.warning("Using state as nonce for OpenID Request") | ||||||
| @ -374,9 +381,9 @@ class OAuthFulfillmentStage(StageView): | |||||||
|             query_fragment["code"] = code.code |             query_fragment["code"] = code.code | ||||||
|  |  | ||||||
|         query_fragment["token_type"] = "bearer" |         query_fragment["token_type"] = "bearer" | ||||||
|         query_fragment["expires_in"] = timedelta_from_string( |         query_fragment["expires_in"] = int( | ||||||
|             self.provider.token_validity |             timedelta_from_string(self.provider.token_validity).total_seconds() | ||||||
|         ).seconds |         ) | ||||||
|         query_fragment["state"] = self.params.state if self.params.state else "" |         query_fragment["state"] = self.params.state if self.params.state else "" | ||||||
|  |  | ||||||
|         return query_fragment |         return query_fragment | ||||||
| @ -468,14 +475,14 @@ class AuthorizationFlowInitView(PolicyAccessView): | |||||||
|         # OpenID clients can specify a `prompt` parameter, and if its set to consent we |         # OpenID clients can specify a `prompt` parameter, and if its set to consent we | ||||||
|         # need to inject a consent stage |         # need to inject a consent stage | ||||||
|         if PROMPT_CONSNET in self.params.prompt: |         if PROMPT_CONSNET in self.params.prompt: | ||||||
|             if not any(isinstance(x, ConsentStageView) for x in plan.stages): |             if not any(isinstance(x.stage, ConsentStageView) for x in plan.bindings): | ||||||
|                 # Plan does not have any consent stage, so we add an in-memory one |                 # Plan does not have any consent stage, so we add an in-memory one | ||||||
|                 stage = ConsentStage( |                 stage = ConsentStage( | ||||||
|                     name="OAuth2 Provider In-memory consent stage", |                     name="OAuth2 Provider In-memory consent stage", | ||||||
|                     mode=ConsentMode.ALWAYS_REQUIRE, |                     mode=ConsentMode.ALWAYS_REQUIRE, | ||||||
|                 ) |                 ) | ||||||
|                 plan.append(stage) |                 plan.append_stage(stage) | ||||||
|         plan.append(in_memory_stage(OAuthFulfillmentStage)) |         plan.append_stage(in_memory_stage(OAuthFulfillmentStage)) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from django.http import HttpRequest, HttpResponse | |||||||
| from django.views import View | from django.views import View | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.providers.oauth2.constants import ( | from authentik.providers.oauth2.constants import ( | ||||||
|     GRANT_TYPE_AUTHORIZATION_CODE, |     GRANT_TYPE_AUTHORIZATION_CODE, | ||||||
| @ -30,6 +31,7 @@ LOGGER = get_logger() | |||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
|  | # pylint: disable=too-many-instance-attributes | ||||||
| class TokenParams: | class TokenParams: | ||||||
|     """Token params""" |     """Token params""" | ||||||
|  |  | ||||||
| @ -40,6 +42,8 @@ class TokenParams: | |||||||
|     state: str |     state: str | ||||||
|     scope: list[str] |     scope: list[str] | ||||||
|  |  | ||||||
|  |     provider: OAuth2Provider | ||||||
|  |  | ||||||
|     authorization_code: Optional[AuthorizationCode] = None |     authorization_code: Optional[AuthorizationCode] = None | ||||||
|     refresh_token: Optional[RefreshToken] = None |     refresh_token: Optional[RefreshToken] = None | ||||||
|  |  | ||||||
| @ -47,35 +51,34 @@ class TokenParams: | |||||||
|  |  | ||||||
|     raw_code: InitVar[str] = "" |     raw_code: InitVar[str] = "" | ||||||
|     raw_token: InitVar[str] = "" |     raw_token: InitVar[str] = "" | ||||||
|  |     request: InitVar[Optional[HttpRequest]] = None | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_request(request: HttpRequest) -> "TokenParams": |     def parse( | ||||||
|         """Extract Token Parameters from http request""" |         request: HttpRequest, | ||||||
|         client_id, client_secret = extract_client_auth(request) |         provider: OAuth2Provider, | ||||||
|  |         client_id: str, | ||||||
|  |         client_secret: str, | ||||||
|  |     ) -> "TokenParams": | ||||||
|  |         """Parse params for request""" | ||||||
|         return TokenParams( |         return TokenParams( | ||||||
|  |             # Init vars | ||||||
|  |             raw_code=request.POST.get("code", ""), | ||||||
|  |             raw_token=request.POST.get("refresh_token", ""), | ||||||
|  |             request=request, | ||||||
|  |             # Regular params | ||||||
|  |             provider=provider, | ||||||
|             client_id=client_id, |             client_id=client_id, | ||||||
|             client_secret=client_secret, |             client_secret=client_secret, | ||||||
|             redirect_uri=request.POST.get("redirect_uri", ""), |             redirect_uri=request.POST.get("redirect_uri", ""), | ||||||
|             grant_type=request.POST.get("grant_type", ""), |             grant_type=request.POST.get("grant_type", ""), | ||||||
|             raw_code=request.POST.get("code", ""), |  | ||||||
|             raw_token=request.POST.get("refresh_token", ""), |  | ||||||
|             state=request.POST.get("state", ""), |             state=request.POST.get("state", ""), | ||||||
|             scope=request.POST.get("scope", "").split(), |             scope=request.POST.get("scope", "").split(), | ||||||
|             # PKCE parameter. |             # PKCE parameter. | ||||||
|             code_verifier=request.POST.get("code_verifier"), |             code_verifier=request.POST.get("code_verifier"), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def __post_init__(self, raw_code, raw_token): |     def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest): | ||||||
|         try: |  | ||||||
|             provider: OAuth2Provider = OAuth2Provider.objects.get( |  | ||||||
|                 client_id=self.client_id |  | ||||||
|             ) |  | ||||||
|             self.provider = provider |  | ||||||
|         except OAuth2Provider.DoesNotExist: |  | ||||||
|             LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id) |  | ||||||
|             raise TokenError("invalid_client") |  | ||||||
|  |  | ||||||
|         if self.provider.client_type == ClientTypes.CONFIDENTIAL: |         if self.provider.client_type == ClientTypes.CONFIDENTIAL: | ||||||
|             if self.provider.client_secret != self.client_secret: |             if self.provider.client_secret != self.client_secret: | ||||||
|                 LOGGER.warning( |                 LOGGER.warning( | ||||||
| @ -87,7 +90,6 @@ class TokenParams: | |||||||
|  |  | ||||||
|         if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: |         if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: | ||||||
|             self.__post_init_code(raw_code) |             self.__post_init_code(raw_code) | ||||||
|  |  | ||||||
|         elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN: |         elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN: | ||||||
|             if not raw_token: |             if not raw_token: | ||||||
|                 LOGGER.warning("Missing refresh token") |                 LOGGER.warning("Missing refresh token") | ||||||
| @ -107,7 +109,14 @@ class TokenParams: | |||||||
|                     token=raw_token, |                     token=raw_token, | ||||||
|                 ) |                 ) | ||||||
|                 raise TokenError("invalid_grant") |                 raise TokenError("invalid_grant") | ||||||
|  |             if self.refresh_token.revoked: | ||||||
|  |                 LOGGER.warning("Refresh token is revoked", token=raw_token) | ||||||
|  |                 Event.new( | ||||||
|  |                     action=EventAction.SUSPICIOUS_REQUEST, | ||||||
|  |                     message="Revoked refresh token was used", | ||||||
|  |                     token=raw_token, | ||||||
|  |                 ).from_http(request) | ||||||
|  |                 raise TokenError("invalid_grant") | ||||||
|         else: |         else: | ||||||
|             LOGGER.warning("Invalid grant type", grant_type=self.grant_type) |             LOGGER.warning("Invalid grant type", grant_type=self.grant_type) | ||||||
|             raise TokenError("unsupported_grant_type") |             raise TokenError("unsupported_grant_type") | ||||||
| @ -159,13 +168,14 @@ class TokenParams: | |||||||
| class TokenView(View): | class TokenView(View): | ||||||
|     """Generate tokens for clients""" |     """Generate tokens for clients""" | ||||||
|  |  | ||||||
|  |     provider: Optional[OAuth2Provider] = None | ||||||
|     params: Optional[TokenParams] = None |     params: Optional[TokenParams] = None | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: |     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||||
|         response = super().dispatch(request, *args, **kwargs) |         response = super().dispatch(request, *args, **kwargs) | ||||||
|         allowed_origins = [] |         allowed_origins = [] | ||||||
|         if self.params: |         if self.provider: | ||||||
|             allowed_origins = self.params.provider.redirect_uris.split("\n") |             allowed_origins = self.provider.redirect_uris.split("\n") | ||||||
|         cors_allow(self.request, response, *allowed_origins) |         cors_allow(self.request, response, *allowed_origins) | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
| @ -175,19 +185,32 @@ class TokenView(View): | |||||||
|     def post(self, request: HttpRequest) -> HttpResponse: |     def post(self, request: HttpRequest) -> HttpResponse: | ||||||
|         """Generate tokens for clients""" |         """Generate tokens for clients""" | ||||||
|         try: |         try: | ||||||
|             self.params = TokenParams.from_request(request) |             client_id, client_secret = extract_client_auth(request) | ||||||
|  |             try: | ||||||
|  |                 self.provider = OAuth2Provider.objects.get(client_id=client_id) | ||||||
|  |             except OAuth2Provider.DoesNotExist: | ||||||
|  |                 LOGGER.warning( | ||||||
|  |                     "OAuth2Provider does not exist", client_id=self.client_id | ||||||
|  |                 ) | ||||||
|  |                 raise TokenError("invalid_client") | ||||||
|  |  | ||||||
|  |             if not self.provider: | ||||||
|  |                 raise ValueError | ||||||
|  |             self.params = TokenParams.parse( | ||||||
|  |                 request, self.provider, client_id, client_secret | ||||||
|  |             ) | ||||||
|  |  | ||||||
|             if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: |             if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: | ||||||
|                 return TokenResponse(self.create_code_response_dic()) |                 return TokenResponse(self.create_code_response()) | ||||||
|             if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: |             if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: | ||||||
|                 return TokenResponse(self.create_refresh_response_dic()) |                 return TokenResponse(self.create_refresh_response()) | ||||||
|             raise ValueError(f"Invalid grant_type: {self.params.grant_type}") |             raise ValueError(f"Invalid grant_type: {self.params.grant_type}") | ||||||
|         except TokenError as error: |         except TokenError as error: | ||||||
|             return TokenResponse(error.create_dict(), status=400) |             return TokenResponse(error.create_dict(), status=400) | ||||||
|         except UserAuthError as error: |         except UserAuthError as error: | ||||||
|             return TokenResponse(error.create_dict(), status=403) |             return TokenResponse(error.create_dict(), status=403) | ||||||
|  |  | ||||||
|     def create_code_response_dic(self) -> dict[str, Any]: |     def create_code_response(self) -> dict[str, Any]: | ||||||
|         """See https://tools.ietf.org/html/rfc6749#section-4.1""" |         """See https://tools.ietf.org/html/rfc6749#section-4.1""" | ||||||
|  |  | ||||||
|         refresh_token = self.params.authorization_code.provider.create_refresh_token( |         refresh_token = self.params.authorization_code.provider.create_refresh_token( | ||||||
| @ -211,19 +234,19 @@ class TokenView(View): | |||||||
|         # We don't need to store the code anymore. |         # We don't need to store the code anymore. | ||||||
|         self.params.authorization_code.delete() |         self.params.authorization_code.delete() | ||||||
|  |  | ||||||
|         response_dict = { |         return { | ||||||
|             "access_token": refresh_token.access_token, |             "access_token": refresh_token.access_token, | ||||||
|             "refresh_token": refresh_token.refresh_token, |             "refresh_token": refresh_token.refresh_token, | ||||||
|             "token_type": "bearer", |             "token_type": "bearer", | ||||||
|             "expires_in": timedelta_from_string( |             "expires_in": int( | ||||||
|  |                 timedelta_from_string( | ||||||
|                     self.params.provider.token_validity |                     self.params.provider.token_validity | ||||||
|             ).seconds, |                 ).total_seconds() | ||||||
|  |             ), | ||||||
|             "id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()), |             "id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()), | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return response_dict |     def create_refresh_response(self) -> dict[str, Any]: | ||||||
|  |  | ||||||
|     def create_refresh_response_dic(self) -> dict[str, Any]: |  | ||||||
|         """See https://tools.ietf.org/html/rfc6749#section-6""" |         """See https://tools.ietf.org/html/rfc6749#section-6""" | ||||||
|  |  | ||||||
|         unauthorized_scopes = set(self.params.scope) - set( |         unauthorized_scopes = set(self.params.scope) - set( | ||||||
| @ -251,17 +274,18 @@ class TokenView(View): | |||||||
|             # Store the refresh_token. |             # Store the refresh_token. | ||||||
|             refresh_token.save() |             refresh_token.save() | ||||||
|  |  | ||||||
|         # Forget the old token. |         # Mark old token as revoked | ||||||
|         self.params.refresh_token.delete() |         self.params.refresh_token.revoked = True | ||||||
|  |         self.params.refresh_token.save() | ||||||
|  |  | ||||||
|         dic = { |         return { | ||||||
|             "access_token": refresh_token.access_token, |             "access_token": refresh_token.access_token, | ||||||
|             "refresh_token": refresh_token.refresh_token, |             "refresh_token": refresh_token.refresh_token, | ||||||
|             "token_type": "bearer", |             "token_type": "bearer", | ||||||
|             "expires_in": timedelta_from_string( |             "expires_in": int( | ||||||
|  |                 timedelta_from_string( | ||||||
|                     refresh_token.provider.token_validity |                     refresh_token.provider.token_validity | ||||||
|             ).seconds, |                 ).total_seconds() | ||||||
|  |             ), | ||||||
|             "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), |             "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return dic |  | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """authentik OAuth2 OpenID Userinfo views""" | """authentik OAuth2 OpenID Userinfo views""" | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
|  | from deepmerge import always_merger | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.http.response import HttpResponseBadRequest | from django.http.response import HttpResponseBadRequest | ||||||
| from django.views import View | from django.views import View | ||||||
| @ -78,7 +79,7 @@ class UserInfoView(View): | |||||||
|                 ) |                 ) | ||||||
|                 continue |                 continue | ||||||
|             LOGGER.debug("updated scope", scope=scope) |             LOGGER.debug("updated scope", scope=scope) | ||||||
|             final_claims.update(value) |             always_merger.merge(final_claims, value) | ||||||
|         return final_claims |         return final_claims | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: |     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """SAML AuthNRequest Parser and dataclass""" | """SAML AuthNRequest Parser and dataclass""" | ||||||
| from base64 import b64decode | from base64 import b64decode | ||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
| from typing import Optional | from typing import Optional, Union | ||||||
| from urllib.parse import quote_plus | from urllib.parse import quote_plus | ||||||
|  |  | ||||||
| import xmlsec | import xmlsec | ||||||
| @ -54,7 +54,9 @@ class AuthNRequestParser: | |||||||
|     def __init__(self, provider: SAMLProvider): |     def __init__(self, provider: SAMLProvider): | ||||||
|         self.provider = provider |         self.provider = provider | ||||||
|  |  | ||||||
|     def _parse_xml(self, decoded_xml: str, relay_state: Optional[str]) -> AuthNRequest: |     def _parse_xml( | ||||||
|  |         self, decoded_xml: Union[str, bytes], relay_state: Optional[str] | ||||||
|  |     ) -> AuthNRequest: | ||||||
|         root = ElementTree.fromstring(decoded_xml) |         root = ElementTree.fromstring(decoded_xml) | ||||||
|  |  | ||||||
|         request_acs_url = root.attrib["AssertionConsumerServiceURL"] |         request_acs_url = root.attrib["AssertionConsumerServiceURL"] | ||||||
| @ -79,10 +81,12 @@ class AuthNRequestParser: | |||||||
|  |  | ||||||
|         return auth_n_request |         return auth_n_request | ||||||
|  |  | ||||||
|     def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest: |     def parse( | ||||||
|  |         self, saml_request: str, relay_state: Optional[str] = None | ||||||
|  |     ) -> AuthNRequest: | ||||||
|         """Validate and parse raw request with enveloped signautre.""" |         """Validate and parse raw request with enveloped signautre.""" | ||||||
|         try: |         try: | ||||||
|             decoded_xml = b64decode(saml_request.encode()).decode() |             decoded_xml = b64decode(saml_request.encode()) | ||||||
|         except UnicodeDecodeError: |         except UnicodeDecodeError: | ||||||
|             raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) |             raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) | ||||||
|  |  | ||||||
| @ -93,8 +97,9 @@ class AuthNRequestParser: | |||||||
|         signature_nodes = root.xpath( |         signature_nodes = root.xpath( | ||||||
|             "/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP |             "/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP | ||||||
|         ) |         ) | ||||||
|         if len(signature_nodes) != 1: |         # No signatures, no verifier configured -> decode xml directly | ||||||
|             raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) |         if len(signature_nodes) < 1 and not verifier: | ||||||
|  |             return self._parse_xml(decoded_xml, relay_state) | ||||||
|  |  | ||||||
|         signature_node = signature_nodes[0] |         signature_node = signature_nodes[0] | ||||||
|  |  | ||||||
|  | |||||||
| @ -14,13 +14,29 @@ from authentik.providers.saml.processors.assertion import AssertionProcessor | |||||||
| from authentik.providers.saml.processors.request_parser import AuthNRequestParser | from authentik.providers.saml.processors.request_parser import AuthNRequestParser | ||||||
| from authentik.sources.saml.exceptions import MismatchedRequestID | from authentik.sources.saml.exceptions import MismatchedRequestID | ||||||
| from authentik.sources.saml.models import SAMLSource | from authentik.sources.saml.models import SAMLSource | ||||||
| from authentik.sources.saml.processors.constants import SAML_NAME_ID_FORMAT_UNSPECIFIED | from authentik.sources.saml.processors.constants import ( | ||||||
|  |     SAML_NAME_ID_FORMAT_EMAIL, | ||||||
|  |     SAML_NAME_ID_FORMAT_UNSPECIFIED, | ||||||
|  | ) | ||||||
| from authentik.sources.saml.processors.request import ( | from authentik.sources.saml.processors.request import ( | ||||||
|     SESSION_REQUEST_ID, |     SESSION_REQUEST_ID, | ||||||
|     RequestProcessor, |     RequestProcessor, | ||||||
| ) | ) | ||||||
| from authentik.sources.saml.processors.response import ResponseProcessor | from authentik.sources.saml.processors.response import ResponseProcessor | ||||||
|  |  | ||||||
|  | POST_REQUEST = ( | ||||||
|  |     "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOkF1dGhuUmVxdWVzdCB4bWxuczpzYW1sMn" | ||||||
|  |     "A9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgQXNzZXJ0aW9uQ29uc3VtZXJTZXJ2aWNlVVJMPSJo" | ||||||
|  |     "dHRwczovL2V1LWNlbnRyYWwtMS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9hY3MvMmQ3MzdmOTYtNT" | ||||||
|  |     "VmYi00MDM1LTk1M2UtNWUyNDEzNGViNzc4IiBEZXN0aW5hdGlvbj0iaHR0cHM6Ly9pZC5iZXJ5anUub3JnL2FwcGxpY2F0" | ||||||
|  |     "aW9uL3NhbWwvYXdzLXNzby9zc28vYmluZGluZy9wb3N0LyIgSUQ9ImF3c19MRHhMR2V1YnBjNWx4MTJneENnUzZ1UGJpeD" | ||||||
|  |     "F5ZDVyZSIgSXNzdWVJbnN0YW50PSIyMDIxLTA3LTA2VDE0OjIzOjA2LjM4OFoiIFByb3RvY29sQmluZGluZz0idXJuOm9h" | ||||||
|  |     "c2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmJpbmRpbmdzOkhUVFAtUE9TVCIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIH" | ||||||
|  |     "htbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5odHRwczovL2V1LWNlbnRyYWwt" | ||||||
|  |     "MS5zaWduaW4uYXdzLmFtYXpvbi5jb20vcGxhdGZvcm0vc2FtbC9kLTk5NjcyZjgyNzg8L3NhbWwyOklzc3Vlcj48c2FtbD" | ||||||
|  |     "JwOk5hbWVJRFBvbGljeSBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OmVtYWls" | ||||||
|  |     "QWRkcmVzcyIvPjwvc2FtbDJwOkF1dGhuUmVxdWVzdD4=" | ||||||
|  | ) | ||||||
| REDIRECT_REQUEST = ( | REDIRECT_REQUEST = ( | ||||||
|     "fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iu" |     "fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iu" | ||||||
|     "EjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+H" |     "EjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+H" | ||||||
| @ -208,3 +224,22 @@ class TestAuthNRequest(TestCase): | |||||||
|         self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab") |         self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab") | ||||||
|         self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED) |         self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED) | ||||||
|         self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE) |         self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE) | ||||||
|  |  | ||||||
|  |     def test_signed_static(self): | ||||||
|  |         """Test post request with static request""" | ||||||
|  |         provider = SAMLProvider( | ||||||
|  |             name="aws", | ||||||
|  |             authorization_flow=Flow.objects.get( | ||||||
|  |                 slug="default-provider-authorization-implicit-consent" | ||||||
|  |             ), | ||||||
|  |             acs_url=( | ||||||
|  |                 "https://eu-central-1.signin.aws.amazon.com/platform/" | ||||||
|  |                 "saml/acs/2d737f96-55fb-4035-953e-5e24134eb778" | ||||||
|  |             ), | ||||||
|  |             audience="https://10.120.20.200/saml-sp/SAML2/POST", | ||||||
|  |             issuer="https://10.120.20.200/saml-sp/SAML2/POST", | ||||||
|  |             signing_kp=CertificateKeyPair.objects.first(), | ||||||
|  |         ) | ||||||
|  |         parsed_request = AuthNRequestParser(provider).parse(POST_REQUEST) | ||||||
|  |         self.assertEqual(parsed_request.id, "aws_LDxLGeubpc5lx12gxCgS6uPbix1yd5re") | ||||||
|  |         self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL) | ||||||
|  | |||||||
| @ -79,7 +79,7 @@ class SAMLSSOView(PolicyAccessView): | |||||||
|                 PLAN_CONTEXT_CONSENT_PERMISSIONS: [], |                 PLAN_CONTEXT_CONSENT_PERMISSIONS: [], | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         plan.append(in_memory_stage(SAMLFlowFinalView)) |         plan.append_stage(in_memory_stage(SAMLFlowFinalView)) | ||||||
|         request.session[SESSION_KEY_PLAN] = plan |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ class MessageConsumer(JsonWebsocketConsumer): | |||||||
|         cache.set(f"user_{self.session_key}_messages_{self.channel_name}", True, None) |         cache.set(f"user_{self.session_key}_messages_{self.channel_name}", True, None) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def disconnect(self, close_code): |     def disconnect(self, code): | ||||||
|         cache.delete(f"user_{self.session_key}_messages_{self.channel_name}") |         cache.delete(f"user_{self.session_key}_messages_{self.channel_name}") | ||||||
|  |  | ||||||
|     def event_update(self, event: dict): |     def event_update(self, event: dict): | ||||||
|  | |||||||
							
								
								
									
										94
									
								
								authentik/root/middleware.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								authentik/root/middleware.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,94 @@ | |||||||
|  | """Dynamically set SameSite depending if the upstream connection is TLS or not""" | ||||||
|  | import time | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
|  | from django.contrib.sessions.backends.base import UpdateError | ||||||
|  | from django.contrib.sessions.exceptions import SessionInterrupted | ||||||
|  | from django.contrib.sessions.middleware import ( | ||||||
|  |     SessionMiddleware as UpstreamSessionMiddleware, | ||||||
|  | ) | ||||||
|  | from django.http.request import HttpRequest | ||||||
|  | from django.http.response import HttpResponse | ||||||
|  | from django.utils.cache import patch_vary_headers | ||||||
|  | from django.utils.http import http_date | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SessionMiddleware(UpstreamSessionMiddleware): | ||||||
|  |     """Dynamically set SameSite depending if the upstream connection is TLS or not""" | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def is_secure(request: HttpRequest) -> bool: | ||||||
|  |         """Check if request is TLS'd or localhost""" | ||||||
|  |         if request.is_secure(): | ||||||
|  |             return True | ||||||
|  |         host, _, _ = request.get_host().partition(":") | ||||||
|  |         if host == "localhost" and settings.DEBUG: | ||||||
|  |             # Since go does not consider localhost with http a secure origin | ||||||
|  |             # we can't set the secure flag. | ||||||
|  |             user_agent = request.META.get("HTTP_USER_AGENT", "") | ||||||
|  |             if user_agent.startswith("authentik-outpost@"): | ||||||
|  |                 return False | ||||||
|  |             return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     def process_response( | ||||||
|  |         self, request: HttpRequest, response: HttpResponse | ||||||
|  |     ) -> HttpResponse: | ||||||
|  |         """ | ||||||
|  |         If request.session was modified, or if the configuration is to save the | ||||||
|  |         session every time, save the changes and set a session cookie or delete | ||||||
|  |         the session cookie if the session has been emptied. | ||||||
|  |         """ | ||||||
|  |         try: | ||||||
|  |             accessed = request.session.accessed | ||||||
|  |             modified = request.session.modified | ||||||
|  |             empty = request.session.is_empty() | ||||||
|  |         except AttributeError: | ||||||
|  |             return response | ||||||
|  |         # Set SameSite based on whether or not the request is secure | ||||||
|  |         secure = SessionMiddleware.is_secure(request) | ||||||
|  |         same_site = "None" if secure else "Lax" | ||||||
|  |         # First check if we need to delete this cookie. | ||||||
|  |         # The session should be deleted only if the session is entirely empty. | ||||||
|  |         if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: | ||||||
|  |             response.delete_cookie( | ||||||
|  |                 settings.SESSION_COOKIE_NAME, | ||||||
|  |                 path=settings.SESSION_COOKIE_PATH, | ||||||
|  |                 domain=settings.SESSION_COOKIE_DOMAIN, | ||||||
|  |                 samesite=same_site, | ||||||
|  |             ) | ||||||
|  |             patch_vary_headers(response, ("Cookie",)) | ||||||
|  |         else: | ||||||
|  |             if accessed: | ||||||
|  |                 patch_vary_headers(response, ("Cookie",)) | ||||||
|  |             if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: | ||||||
|  |                 if request.session.get_expire_at_browser_close(): | ||||||
|  |                     max_age = None | ||||||
|  |                     expires = None | ||||||
|  |                 else: | ||||||
|  |                     max_age = request.session.get_expiry_age() | ||||||
|  |                     expires_time = time.time() + max_age | ||||||
|  |                     expires = http_date(expires_time) | ||||||
|  |                 # Save the session data and refresh the client cookie. | ||||||
|  |                 # Skip session save for 500 responses, refs #3881. | ||||||
|  |                 if response.status_code != 500: | ||||||
|  |                     try: | ||||||
|  |                         request.session.save() | ||||||
|  |                     except UpdateError: | ||||||
|  |                         raise SessionInterrupted( | ||||||
|  |                             "The request's session was deleted before the " | ||||||
|  |                             "request completed. The user may have logged " | ||||||
|  |                             "out in a concurrent request, for example." | ||||||
|  |                         ) | ||||||
|  |                     response.set_cookie( | ||||||
|  |                         settings.SESSION_COOKIE_NAME, | ||||||
|  |                         request.session.session_key, | ||||||
|  |                         max_age=max_age, | ||||||
|  |                         expires=expires, | ||||||
|  |                         domain=settings.SESSION_COOKIE_DOMAIN, | ||||||
|  |                         path=settings.SESSION_COOKIE_PATH, | ||||||
|  |                         secure=secure, | ||||||
|  |                         httponly=settings.SESSION_COOKIE_HTTPONLY or None, | ||||||
|  |                         samesite=same_site, | ||||||
|  |                     ) | ||||||
|  |         return response | ||||||
| @ -153,6 +153,7 @@ SPECTACULAR_SETTINGS = { | |||||||
|         "url": "https://github.com/goauthentik/authentik/blob/master/LICENSE", |         "url": "https://github.com/goauthentik/authentik/blob/master/LICENSE", | ||||||
|     }, |     }, | ||||||
|     "ENUM_NAME_OVERRIDES": { |     "ENUM_NAME_OVERRIDES": { | ||||||
|  |         "EventActions": "authentik.events.models.EventAction", | ||||||
|         "ChallengeChoices": "authentik.flows.challenge.ChallengeTypes", |         "ChallengeChoices": "authentik.flows.challenge.ChallengeTypes", | ||||||
|         "FlowDesignationEnum": "authentik.flows.models.FlowDesignation", |         "FlowDesignationEnum": "authentik.flows.models.FlowDesignation", | ||||||
|         "PolicyEngineMode": "authentik.policies.models.PolicyEngineMode", |         "PolicyEngineMode": "authentik.policies.models.PolicyEngineMode", | ||||||
| @ -187,12 +188,19 @@ REST_FRAMEWORK = { | |||||||
|     "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", |     "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", | ||||||
| } | } | ||||||
|  |  | ||||||
|  | REDIS_PROTOCOL_PREFIX = "redis://" | ||||||
|  | REDIS_CELERY_TLS_REQUIREMENTS = "" | ||||||
|  | if CONFIG.y_bool("redis.tls", False): | ||||||
|  |     REDIS_PROTOCOL_PREFIX = "rediss://" | ||||||
|  |     REDIS_CELERY_TLS_REQUIREMENTS = f"?ssl_cert_reqs={CONFIG.y('redis.tls_reqs')}" | ||||||
|  |  | ||||||
| CACHES = { | CACHES = { | ||||||
|     "default": { |     "default": { | ||||||
|         "BACKEND": "django_redis.cache.RedisCache", |         "BACKEND": "django_redis.cache.RedisCache", | ||||||
|         "LOCATION": ( |         "LOCATION": ( | ||||||
|             f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" |             f"{REDIS_PROTOCOL_PREFIX}:" | ||||||
|             f"/{CONFIG.y('redis.cache_db')}" |             f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:" | ||||||
|  |             f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.cache_db')}" | ||||||
|         ), |         ), | ||||||
|         "TIMEOUT": int(CONFIG.y("redis.cache_timeout", 300)), |         "TIMEOUT": int(CONFIG.y("redis.cache_timeout", 300)), | ||||||
|         "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, |         "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, | ||||||
| @ -202,14 +210,16 @@ DJANGO_REDIS_IGNORE_EXCEPTIONS = True | |||||||
| DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True | DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True | ||||||
| SESSION_ENGINE = "django.contrib.sessions.backends.cache" | SESSION_ENGINE = "django.contrib.sessions.backends.cache" | ||||||
| SESSION_CACHE_ALIAS = "default" | SESSION_CACHE_ALIAS = "default" | ||||||
| SESSION_COOKIE_SAMESITE = "lax" | # Configured via custom SessionMiddleware | ||||||
|  | # SESSION_COOKIE_SAMESITE = "None" | ||||||
|  | # SESSION_COOKIE_SECURE = True | ||||||
| SESSION_EXPIRE_AT_BROWSER_CLOSE = True | SESSION_EXPIRE_AT_BROWSER_CLOSE = True | ||||||
|  |  | ||||||
| MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage" | MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage" | ||||||
|  |  | ||||||
| MIDDLEWARE = [ | MIDDLEWARE = [ | ||||||
|     "django_prometheus.middleware.PrometheusBeforeMiddleware", |     "django_prometheus.middleware.PrometheusBeforeMiddleware", | ||||||
|     "django.contrib.sessions.middleware.SessionMiddleware", |     "authentik.root.middleware.SessionMiddleware", | ||||||
|     "django.contrib.auth.middleware.AuthenticationMiddleware", |     "django.contrib.auth.middleware.AuthenticationMiddleware", | ||||||
|     "authentik.core.middleware.RequestIDMiddleware", |     "authentik.core.middleware.RequestIDMiddleware", | ||||||
|     "authentik.tenants.middleware.TenantMiddleware", |     "authentik.tenants.middleware.TenantMiddleware", | ||||||
| @ -249,8 +259,9 @@ CHANNEL_LAYERS = { | |||||||
|         "BACKEND": "channels_redis.core.RedisChannelLayer", |         "BACKEND": "channels_redis.core.RedisChannelLayer", | ||||||
|         "CONFIG": { |         "CONFIG": { | ||||||
|             "hosts": [ |             "hosts": [ | ||||||
|                 f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" |                 f"{REDIS_PROTOCOL_PREFIX}:" | ||||||
|                 f"/{CONFIG.y('redis.ws_db')}" |                 f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:" | ||||||
|  |                 f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.ws_db')}" | ||||||
|             ], |             ], | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
| @ -328,12 +339,16 @@ CELERY_BEAT_SCHEDULE = { | |||||||
| CELERY_TASK_CREATE_MISSING_QUEUES = True | CELERY_TASK_CREATE_MISSING_QUEUES = True | ||||||
| CELERY_TASK_DEFAULT_QUEUE = "authentik" | CELERY_TASK_DEFAULT_QUEUE = "authentik" | ||||||
| CELERY_BROKER_URL = ( | CELERY_BROKER_URL = ( | ||||||
|     f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}" |     f"{REDIS_PROTOCOL_PREFIX}:" | ||||||
|     f":6379/{CONFIG.y('redis.message_queue_db')}" |     f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:" | ||||||
|  |     f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.message_queue_db')}" | ||||||
|  |     f"{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||||
| ) | ) | ||||||
| CELERY_RESULT_BACKEND = ( | CELERY_RESULT_BACKEND = ( | ||||||
|     f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}" |     f"{REDIS_PROTOCOL_PREFIX}:" | ||||||
|     f":6379/{CONFIG.y('redis.message_queue_db')}" |     f"{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:" | ||||||
|  |     f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.message_queue_db')}" | ||||||
|  |     f"{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| # Database backup | # Database backup | ||||||
| @ -361,11 +376,12 @@ if CONFIG.y("postgresql.s3_backup"): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
| # Sentry integration | # Sentry integration | ||||||
|  | SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8" | ||||||
| _ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False) | _ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False) | ||||||
| if _ERROR_REPORTING: | if _ERROR_REPORTING: | ||||||
|     # pylint: disable=abstract-class-instantiated |     # pylint: disable=abstract-class-instantiated | ||||||
|     sentry_init( |     sentry_init( | ||||||
|         dsn="https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8", |         dsn=SENTRY_DSN, | ||||||
|         integrations=[ |         integrations=[ | ||||||
|             DjangoIntegration(transaction_style="function_name"), |             DjangoIntegration(transaction_style="function_name"), | ||||||
|             CeleryIntegration(), |             CeleryIntegration(), | ||||||
| @ -400,9 +416,8 @@ MEDIA_URL = "/media/" | |||||||
|  |  | ||||||
| TEST = False | TEST = False | ||||||
| TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner" | TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner" | ||||||
|  | # We can't check TEST here as its set later by the test runner | ||||||
| LOG_LEVEL = CONFIG.y("log_level").upper() if not TEST else "DEBUG" | LOG_LEVEL = CONFIG.y("log_level").upper() if "TF_BUILD" not in os.environ else "DEBUG" | ||||||
|  |  | ||||||
|  |  | ||||||
| structlog.configure_once( | structlog.configure_once( | ||||||
|     processors=[ |     processors=[ | ||||||
|  | |||||||
| @ -60,14 +60,21 @@ class LDAPPasswordChanger: | |||||||
|     def check_ad_password_complexity_enabled(self) -> bool: |     def check_ad_password_complexity_enabled(self) -> bool: | ||||||
|         """Check if DOMAIN_PASSWORD_COMPLEX is enabled""" |         """Check if DOMAIN_PASSWORD_COMPLEX is enabled""" | ||||||
|         root_dn = self.get_domain_root_dn() |         root_dn = self.get_domain_root_dn() | ||||||
|  |         try: | ||||||
|             root_attrs = self._source.connection.extend.standard.paged_search( |             root_attrs = self._source.connection.extend.standard.paged_search( | ||||||
|                 search_base=root_dn, |                 search_base=root_dn, | ||||||
|                 search_filter="(objectClass=*)", |                 search_filter="(objectClass=*)", | ||||||
|                 search_scope=ldap3.BASE, |                 search_scope=ldap3.BASE, | ||||||
|                 attributes=["pwdProperties"], |                 attributes=["pwdProperties"], | ||||||
|             ) |             ) | ||||||
|  |         except ldap3.core.exceptions.LDAPAttributeError: | ||||||
|  |             return False | ||||||
|         root_attrs = list(root_attrs)[0] |         root_attrs = list(root_attrs)[0] | ||||||
|         pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"]) |         raw_pwd_properties = root_attrs.get("attributes", {}).get("pwdProperties", None) | ||||||
|  |         if raw_pwd_properties is None: | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         pwd_properties = PwdProperties(raw_pwd_properties) | ||||||
|         if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties: |         if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties: | ||||||
|             return True |             return True | ||||||
|  |  | ||||||
|  | |||||||
| @ -36,7 +36,8 @@ class SourceType: | |||||||
| class SourceTypeManager: | class SourceTypeManager: | ||||||
|     """Manager to hold all Source types.""" |     """Manager to hold all Source types.""" | ||||||
|  |  | ||||||
|     __sources: list[SourceType] = [] |     def __init__(self) -> None: | ||||||
|  |         self.__sources: list[SourceType] = [] | ||||||
|  |  | ||||||
|     def type(self): |     def type(self): | ||||||
|         """Class decorator to register classes inline.""" |         """Class decorator to register classes inline.""" | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """OAuth Callback Views""" | """OAuth Callback Views""" | ||||||
|  | from json import JSONDecodeError | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| @ -10,6 +11,7 @@ from django.views.generic import View | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.sources.flow_manager import SourceFlowManager | from authentik.core.sources.flow_manager import SourceFlowManager | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | ||||||
| from authentik.sources.oauth.views.base import OAuthClientMixin | from authentik.sources.oauth.views.base import OAuthClientMixin | ||||||
|  |  | ||||||
| @ -42,9 +44,17 @@ class OAuthCallback(OAuthClientMixin, View): | |||||||
|         if "error" in token: |         if "error" in token: | ||||||
|             return self.handle_login_failure(token["error"]) |             return self.handle_login_failure(token["error"]) | ||||||
|         # Fetch profile info |         # Fetch profile info | ||||||
|  |         try: | ||||||
|             raw_info = client.get_profile_info(token) |             raw_info = client.get_profile_info(token) | ||||||
|             if raw_info is None: |             if raw_info is None: | ||||||
|                 return self.handle_login_failure("Could not retrieve profile.") |                 return self.handle_login_failure("Could not retrieve profile.") | ||||||
|  |         except JSONDecodeError as exc: | ||||||
|  |             Event.new( | ||||||
|  |                 EventAction.CONFIGURATION_ERROR, | ||||||
|  |                 message="Failed to JSON-decode profile.", | ||||||
|  |                 raw_profile=exc.doc, | ||||||
|  |             ).from_http(self.request) | ||||||
|  |             return self.handle_login_failure("Could not retrieve profile.") | ||||||
|         identifier = self.get_user_id(raw_info) |         identifier = self.get_user_id(raw_info) | ||||||
|         if identifier is None: |         if identifier is None: | ||||||
|             return self.handle_login_failure("Could not determine id.") |             return self.handle_login_failure("Could not determine id.") | ||||||
|  | |||||||
| @ -90,7 +90,7 @@ class InitiateView(View): | |||||||
|         planner.allow_empty_flows = True |         planner.allow_empty_flows = True | ||||||
|         plan = planner.plan(self.request, kwargs) |         plan = planner.plan(self.request, kwargs) | ||||||
|         for stage in stages_to_append: |         for stage in stages_to_append: | ||||||
|             plan.append(stage) |             plan.append_stage(stage) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -63,7 +63,7 @@ class AuthenticatorDuoStageView(ChallengeStageView): | |||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|                 "activation_barcode": enroll["activation_barcode"], |                 "activation_barcode": enroll["activation_barcode"], | ||||||
|                 "activation_code": enroll["activation_code"], |                 "activation_code": enroll["activation_code"], | ||||||
|                 "stage_uuid": stage.stage_uuid, |                 "stage_uuid": str(stage.stage_uuid), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -74,12 +74,12 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): | |||||||
|             duo, self.stage.request, self.stage.get_pending_user() |             duo, self.stage.request, self.stage.get_pending_user() | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def validate(self, data: dict): |     def validate(self, attrs: dict): | ||||||
|         # Checking if the given data is from a valid device class is done above |         # Checking if the given data is from a valid device class is done above | ||||||
|         # Here we only check if the any data was sent at all |         # Here we only check if the any data was sent at all | ||||||
|         if "code" not in data and "webauthn" not in data and "duo" not in data: |         if "code" not in attrs and "webauthn" not in attrs and "duo" not in attrs: | ||||||
|             raise ValidationError("Empty response") |             raise ValidationError("Empty response") | ||||||
|         return data |         return attrs | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatorValidateStageView(ChallengeStageView): | class AuthenticatorValidateStageView(ChallengeStageView): | ||||||
| @ -148,7 +148,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|                 stage = Stage.objects.get_subclass(pk=stage.configuration_stage.pk) |                 stage = Stage.objects.get_subclass(pk=stage.configuration_stage.pk) | ||||||
|                 # plan.insert inserts at 1 index, so when stage_ok pops 0, |                 # plan.insert inserts at 1 index, so when stage_ok pops 0, | ||||||
|                 # the configuration stage is next |                 # the configuration stage is next | ||||||
|                 self.executor.plan.insert(stage) |                 self.executor.plan.insert_stage(stage) | ||||||
|                 return self.executor.stage_ok() |                 return self.executor.stage_ok() | ||||||
|         return super().get(request, *args, **kwargs) |         return super().get(request, *args, **kwargs) | ||||||
|  |  | ||||||
| @ -163,7 +163,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def challenge_valid( |     def challenge_valid( | ||||||
|         self, challenge: AuthenticatorValidationChallengeResponse |         self, response: AuthenticatorValidationChallengeResponse | ||||||
|     ) -> HttpResponse: |     ) -> HttpResponse: | ||||||
|         # All validation is done by the serializer |         # All validation is done by the serializer | ||||||
|         return self.executor.stage_ok() |         return self.executor.stage_ok() | ||||||
|  | |||||||
| @ -36,12 +36,14 @@ class TestCaptchaStage(TestCase): | |||||||
|             public_key=RECAPTCHA_PUBLIC_KEY, |             public_key=RECAPTCHA_PUBLIC_KEY, | ||||||
|             private_key=RECAPTCHA_PRIVATE_KEY, |             private_key=RECAPTCHA_PRIVATE_KEY, | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) |         self.binding = FlowStageBinding.objects.create( | ||||||
|  |             target=self.flow, stage=self.stage, order=2 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_valid(self): |     def test_valid(self): | ||||||
|         """Test valid captcha""" |         """Test valid captcha""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|  | |||||||
| @ -39,9 +39,11 @@ class TestConsentStage(TestCase): | |||||||
|         stage = ConsentStage.objects.create( |         stage = ConsentStage.objects.create( | ||||||
|             name="consent", mode=ConsentMode.ALWAYS_REQUIRE |             name="consent", mode=ConsentMode.ALWAYS_REQUIRE | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=flow, stage=stage, order=2) |         binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) | ||||||
|  |  | ||||||
|         plan = FlowPlan(flow_pk=flow.pk.hex, stages=[stage], markers=[StageMarker()]) |         plan = FlowPlan( | ||||||
|  |             flow_pk=flow.pk.hex, bindings=[binding], markers=[StageMarker()] | ||||||
|  |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
| @ -69,11 +71,11 @@ class TestConsentStage(TestCase): | |||||||
|             designation=FlowDesignation.AUTHENTICATION, |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|         ) |         ) | ||||||
|         stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) |         stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) | ||||||
|         FlowStageBinding.objects.create(target=flow, stage=stage, order=2) |         binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) | ||||||
|  |  | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=flow.pk.hex, |             flow_pk=flow.pk.hex, | ||||||
|             stages=[stage], |             bindings=[binding], | ||||||
|             markers=[StageMarker()], |             markers=[StageMarker()], | ||||||
|             context={PLAN_CONTEXT_APPLICATION: self.application}, |             context={PLAN_CONTEXT_APPLICATION: self.application}, | ||||||
|         ) |         ) | ||||||
| @ -110,11 +112,11 @@ class TestConsentStage(TestCase): | |||||||
|         stage = ConsentStage.objects.create( |         stage = ConsentStage.objects.create( | ||||||
|             name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" |             name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=flow, stage=stage, order=2) |         binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) | ||||||
|  |  | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=flow.pk.hex, |             flow_pk=flow.pk.hex, | ||||||
|             stages=[stage], |             bindings=[binding], | ||||||
|             markers=[StageMarker()], |             markers=[StageMarker()], | ||||||
|             context={PLAN_CONTEXT_APPLICATION: self.application}, |             context={PLAN_CONTEXT_APPLICATION: self.application}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -26,12 +26,14 @@ class TestUserDenyStage(TestCase): | |||||||
|             designation=FlowDesignation.AUTHENTICATION, |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|         ) |         ) | ||||||
|         self.stage = DenyStage.objects.create(name="logout") |         self.stage = DenyStage.objects.create(name="logout") | ||||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) |         self.binding = FlowStageBinding.objects.create( | ||||||
|  |             target=self.flow, stage=self.stage, order=2 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_valid_password(self): |     def test_valid_password(self): | ||||||
|         """Test with a valid pending user and backend""" |         """Test with a valid pending user and backend""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|  | |||||||
| @ -38,7 +38,7 @@ class EmailChallengeResponse(ChallengeResponse): | |||||||
|  |  | ||||||
|     component = CharField(default="ak-stage-email") |     component = CharField(default="ak-stage-email") | ||||||
|  |  | ||||||
|     def validate(self, data): |     def validate(self, attrs): | ||||||
|         raise ValidationError("") |         raise ValidationError("") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	