Compare commits
	
		
			3 Commits
		
	
	
		
			version/20
			...
			version-20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 18778ce0d9 | |||
| 14973fb595 | |||
| 9171bd6d6f | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2021.5.1-rc5 | ||||
| current_version = 2021.4.6 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||
| @ -19,14 +19,20 @@ values = | ||||
|  | ||||
| [bumpversion:file:website/docs/installation/docker-compose.md] | ||||
|  | ||||
| [bumpversion:file:website/docs/installation/kubernetes.md] | ||||
|  | ||||
| [bumpversion:file:docker-compose.yml] | ||||
|  | ||||
| [bumpversion:file:helm/values.yaml] | ||||
|  | ||||
| [bumpversion:file:helm/README.md] | ||||
|  | ||||
| [bumpversion:file:helm/Chart.yaml] | ||||
|  | ||||
| [bumpversion:file:.github/workflows/release.yml] | ||||
|  | ||||
| [bumpversion:file:authentik/__init__.py] | ||||
|  | ||||
| [bumpversion:file:internal/constants/constants.go] | ||||
|  | ||||
| [bumpversion:file:outpost/pkg/version.go] | ||||
|  | ||||
| [bumpversion:file:web/src/constants.ts] | ||||
|  | ||||
							
								
								
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,13 +1,5 @@ | ||||
| version: 2 | ||||
| updates: | ||||
| - package-ecosystem: "github-actions" | ||||
|   directory: "/" | ||||
|   schedule: | ||||
|     interval: daily | ||||
|     time: "04:00" | ||||
|   open-pull-requests-limit: 10 | ||||
|   assignees: | ||||
|   - BeryJu | ||||
| - package-ecosystem: gomod | ||||
|   directory: "/outpost" | ||||
|   schedule: | ||||
|  | ||||
							
								
								
									
										137
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										137
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,43 +3,32 @@ name: authentik-on-release | ||||
| on: | ||||
|   release: | ||||
|     types: [published, created] | ||||
|   push: | ||||
|     branches: | ||||
|       - version-* | ||||
|  | ||||
| jobs: | ||||
|   # Build | ||||
|   build-server: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.1.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|       - uses: actions/checkout@v1 | ||||
|       - name: Docker Login Registry | ||||
|         uses: docker/login-action@v1 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|       - name: prepare ts api client | ||||
|         run: | | ||||
|           docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | ||||
|         env: | ||||
|           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||
|         run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD | ||||
|       - name: Building Docker Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           push: ${{ github.event_name == 'release' }} | ||||
|           tags: | | ||||
|             beryju/authentik:2021.5.1-rc5, | ||||
|             beryju/authentik:latest, | ||||
|             ghcr.io/goauthentik/server:2021.5.1-rc5, | ||||
|             ghcr.io/goauthentik/server:latest | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           context: . | ||||
|         run: docker build | ||||
|           --no-cache | ||||
|           -t beryju/authentik:2021.4.6 | ||||
|           -t beryju/authentik:latest | ||||
|           -f Dockerfile . | ||||
|       - name: Push Docker Container to Registry (versioned) | ||||
|         run: docker push beryju/authentik:2021.4.6 | ||||
|       - name: Push Docker Container to Registry (latest) | ||||
|         run: docker push beryju/authentik:latest | ||||
|   build-proxy: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - uses: actions/checkout@v1 | ||||
|       - uses: actions/setup-go@v2 | ||||
|         with: | ||||
|           go-version: "^1.15" | ||||
| @ -48,71 +37,56 @@ jobs: | ||||
|           cd outpost | ||||
|           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||
|           swagger generate client -f ../swagger.yaml -A authentik -t pkg/ | ||||
|           go build -v ./cmd/proxy/server.go | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.1.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|           go build -v . | ||||
|       - name: Docker Login Registry | ||||
|         uses: docker/login-action@v1 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|         env: | ||||
|           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||
|         run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD | ||||
|       - name: Building Docker Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           push: ${{ github.event_name == 'release' }} | ||||
|           tags: | | ||||
|             beryju/authentik-proxy:2021.5.1-rc5, | ||||
|             beryju/authentik-proxy:latest, | ||||
|             ghcr.io/goauthentik/proxy:2021.5.1-rc5, | ||||
|             ghcr.io/goauthentik/proxy:latest | ||||
|           context: outpost/ | ||||
|           file: outpost/proxy.Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|   build-ldap: | ||||
|         run: | | ||||
|           cd outpost/ | ||||
|           docker build \ | ||||
|           --no-cache \ | ||||
|           -t beryju/authentik-proxy:2021.4.6 \ | ||||
|           -t beryju/authentik-proxy:latest \ | ||||
|           -f proxy.Dockerfile . | ||||
|       - name: Push Docker Container to Registry (versioned) | ||||
|         run: docker push beryju/authentik-proxy:2021.4.6 | ||||
|       - name: Push Docker Container to Registry (latest) | ||||
|         run: docker push beryju/authentik-proxy:latest | ||||
|   build-static: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - uses: actions/setup-go@v2 | ||||
|         with: | ||||
|           go-version: "^1.15" | ||||
|       - name: prepare go api client | ||||
|       - uses: actions/checkout@v1 | ||||
|       - name: prepare ts api client | ||||
|         run: | | ||||
|           cd outpost | ||||
|           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||
|           swagger generate client -f ../swagger.yaml -A authentik -t pkg/ | ||||
|           go build -v ./cmd/ldap/server.go | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.1.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|           docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | ||||
|       - name: Docker Login Registry | ||||
|         uses: docker/login-action@v1 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKER_USERNAME }} | ||||
|           password: ${{ secrets.DOCKER_PASSWORD }} | ||||
|         env: | ||||
|           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||
|         run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD | ||||
|       - name: Building Docker Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           push: ${{ github.event_name == 'release' }} | ||||
|           tags: | | ||||
|             beryju/authentik-ldap:2021.5.1-rc5, | ||||
|             beryju/authentik-ldap:latest, | ||||
|             ghcr.io/goauthentik/ldap:2021.5.1-rc5, | ||||
|             ghcr.io/goauthentik/ldap:latest | ||||
|           context: outpost/ | ||||
|           file: outpost/ldap.Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|         run: | | ||||
|           cd web/ | ||||
|           docker build \ | ||||
|           --no-cache \ | ||||
|           -t beryju/authentik-static:2021.4.6 \ | ||||
|           -t beryju/authentik-static:latest \ | ||||
|           -f Dockerfile . | ||||
|       - name: Push Docker Container to Registry (versioned) | ||||
|         run: docker push beryju/authentik-static:2021.4.6 | ||||
|       - name: Push Docker Container to Registry (latest) | ||||
|         run: docker push beryju/authentik-static:latest | ||||
|   test-release: | ||||
|     if: ${{ github.event_name == 'release' }} | ||||
|     needs: | ||||
|       - build-server | ||||
|       - build-static | ||||
|       - build-proxy | ||||
|       - build-ldap | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - uses: actions/checkout@v1 | ||||
|       - name: Run test suite in final docker images | ||||
|         run: | | ||||
|           sudo apt-get install -y pwgen | ||||
| @ -123,19 +97,18 @@ jobs: | ||||
|           docker-compose start postgresql redis | ||||
|           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik" | ||||
|   sentry-release: | ||||
|     if: ${{ github.event_name == 'release' }} | ||||
|     needs: | ||||
|       - test-release | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - uses: actions/checkout@v1 | ||||
|       - name: Create a Sentry.io release | ||||
|         uses: getsentry/action-release@v1 | ||||
|         uses: tclindner/sentry-releases-action@v1.2.0 | ||||
|         env: | ||||
|           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | ||||
|           SENTRY_ORG: beryjuorg | ||||
|           SENTRY_PROJECT: authentik | ||||
|           SENTRY_URL: https://sentry.beryju.org | ||||
|         with: | ||||
|           version: authentik@2021.5.1-rc5 | ||||
|           tagName: 2021.4.6 | ||||
|           environment: beryjuorg-prod | ||||
|  | ||||
							
								
								
									
										24
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										24
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -10,10 +10,7 @@ jobs: | ||||
|     name: Create Release from Tag | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: prepare ts api client | ||||
|         run: | | ||||
|           docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | ||||
|       - uses: actions/checkout@master | ||||
|       - name: Pre-release test | ||||
|         run: | | ||||
|           sudo apt-get install -y pwgen | ||||
| @ -28,6 +25,15 @@ jobs: | ||||
|           docker-compose up --no-start | ||||
|           docker-compose start postgresql redis | ||||
|           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik" | ||||
|       - name: Install Helm | ||||
|         run: | | ||||
|           apt update && apt install -y curl | ||||
|           curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash | ||||
|       - name: Helm package | ||||
|         run: | | ||||
|           helm dependency update helm/ | ||||
|           helm package helm/ | ||||
|           mv authentik-*.tgz authentik-chart.tgz | ||||
|       - name: Extract version number | ||||
|         id: get_version | ||||
|         uses: actions/github-script@0.2.0 | ||||
| @ -45,3 +51,13 @@ jobs: | ||||
|           release_name: Release ${{ steps.get_version.outputs.result }} | ||||
|           draft: true | ||||
|           prerelease: false | ||||
|       - name: Upload packaged Helm Chart | ||||
|         id: upload-release-asset | ||||
|         uses: actions/upload-release-asset@v1.0.1 | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|         with: | ||||
|           upload_url: ${{ steps.create_release.outputs.upload_url }} | ||||
|           asset_path: ./authentik-chart.tgz | ||||
|           asset_name: authentik-chart.tgz | ||||
|           asset_content_type: application/gzip | ||||
|  | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -202,5 +202,3 @@ selenium_screenshots/ | ||||
| backups/ | ||||
| media/ | ||||
| *mmdb | ||||
|  | ||||
| .idea/ | ||||
|  | ||||
							
								
								
									
										35
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,4 +1,3 @@ | ||||
| # Stage 1: Lock python dependencies | ||||
| FROM python:3.9-slim-buster as locker | ||||
|  | ||||
| COPY ./Pipfile /app/ | ||||
| @ -10,34 +9,6 @@ RUN pip install pipenv && \ | ||||
|     pipenv lock -r > requirements.txt && \ | ||||
|     pipenv lock -rd > requirements-dev.txt | ||||
|  | ||||
| # Stage 2: Build webui | ||||
| FROM node as npm-builder | ||||
|  | ||||
| COPY ./web /static/ | ||||
|  | ||||
| ENV NODE_ENV=production | ||||
| RUN cd /static && npm i --production=false && npm run build | ||||
|  | ||||
| # Stage 3: Build go proxy | ||||
| FROM golang:1.16.4 AS builder | ||||
|  | ||||
| WORKDIR /work | ||||
|  | ||||
| COPY --from=npm-builder /static/robots.txt /work/web/robots.txt | ||||
| COPY --from=npm-builder /static/security.txt /work/web/security.txt | ||||
| COPY --from=npm-builder /static/dist/ /work/web/dist/ | ||||
| COPY --from=npm-builder /static/authentik/ /work/web/authentik/ | ||||
|  | ||||
| # RUN ls /work/web/static/authentik/ && exit 1 | ||||
| COPY ./cmd /work/cmd | ||||
| COPY ./web/static.go /work/web/static.go | ||||
| COPY ./internal /work/internal | ||||
| COPY ./go.mod /work/go.mod | ||||
| COPY ./go.sum /work/go.sum | ||||
|  | ||||
| RUN go build -o /work/authentik ./cmd/server/main.go | ||||
|  | ||||
| # Stage 4: Run | ||||
| FROM python:3.9-slim-buster | ||||
|  | ||||
| WORKDIR / | ||||
| @ -52,12 +23,11 @@ RUN apt-get update && \ | ||||
|     curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ | ||||
|     echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ | ||||
|     apt-get update && \ | ||||
|     apt-get install -y --no-install-recommends libpq-dev postgresql-client-12 postgresql-client-11 build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \ | ||||
|     apt-get install -y --no-install-recommends postgresql-client-12 postgresql-client-11 build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \ | ||||
|     apt-get clean && \ | ||||
|     pip install -r /requirements.txt --no-cache-dir && \ | ||||
|     apt-get remove --purge -y build-essential && \ | ||||
|     apt-get autoremove --purge -y && \ | ||||
|     apt-get clean && \ | ||||
|     rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ | ||||
|     # This is quite hacky, but docker has no guaranteed Group ID | ||||
|     # we could instead check for the GID of the socket and add the user dynamically, | ||||
|     # but then we have to drop permmissions later | ||||
| @ -74,7 +44,6 @@ COPY ./pyproject.toml / | ||||
| COPY ./xml /xml | ||||
| COPY ./manage.py / | ||||
| COPY ./lifecycle/ /lifecycle | ||||
| COPY --from=builder /work/authentik /authentik-proxy | ||||
|  | ||||
| USER authentik | ||||
| STOPSIGNAL SIGINT | ||||
|  | ||||
							
								
								
									
										12
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| all: lint-fix lint test gen | ||||
| all: lint-fix lint coverage gen | ||||
|  | ||||
| test-integration: | ||||
| 	k3d cluster create || exit 0 | ||||
| @ -8,7 +8,7 @@ test-integration: | ||||
| test-e2e: | ||||
| 	coverage run manage.py test --failfast -v 3 tests/e2e | ||||
|  | ||||
| test: | ||||
| coverage: | ||||
| 	coverage run manage.py test -v 3 authentik | ||||
| 	coverage html | ||||
| 	coverage report | ||||
| @ -22,7 +22,7 @@ lint: | ||||
| 	bandit -r authentik tests lifecycle -x node_modules | ||||
| 	pylint authentik tests lifecycle | ||||
|  | ||||
| gen: | ||||
| gen: coverage | ||||
| 	./manage.py generate_swagger -o swagger.yaml -f yaml | ||||
|  | ||||
| local-stack: | ||||
| @ -31,5 +31,7 @@ local-stack: | ||||
| 	docker-compose up -d | ||||
| 	docker-compose run --rm server migrate | ||||
|  | ||||
| run: | ||||
| 	go run -v cmd/server/main.go | ||||
| build-static: | ||||
| 	docker-compose -f scripts/ci.docker-compose.yml up -d | ||||
| 	docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default . | ||||
| 	docker-compose -f scripts/ci.docker-compose.yml down -v | ||||
|  | ||||
							
								
								
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							| @ -32,7 +32,7 @@ lxml = ">=4.6.3" | ||||
| packaging = "*" | ||||
| psycopg2-binary = "*" | ||||
| pycryptodome = "*" | ||||
| pyjwt = "*" | ||||
| pyjwkest = "*" | ||||
| pyyaml = "*" | ||||
| requests-oauthlib = "*" | ||||
| sentry-sdk = "*" | ||||
| @ -59,4 +59,3 @@ pylint-django = "*" | ||||
| pytest = "*" | ||||
| pytest-django = "*" | ||||
| selenium = "*" | ||||
| requests-mock = "*" | ||||
|  | ||||
							
								
								
									
										288
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										288
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "17be2923cf8d281e430ec1467aea723806ac6f7c58fc6553ede92317e43f4d14" | ||||
|             "sha256": "a9d504f00ee8820017f26a4fda2938de456cb72b4bc2f8735fc8c6a6c615d46a" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": { | ||||
| @ -88,10 +88,10 @@ | ||||
|         }, | ||||
|         "attrs": { | ||||
|             "hashes": [ | ||||
|                 "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", | ||||
|                 "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" | ||||
|                 "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", | ||||
|                 "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" | ||||
|             ], | ||||
|             "version": "==21.2.0" | ||||
|             "version": "==20.3.0" | ||||
|         }, | ||||
|         "autobahn": { | ||||
|             "hashes": [ | ||||
| @ -116,25 +116,25 @@ | ||||
|         }, | ||||
|         "boto3": { | ||||
|             "hashes": [ | ||||
|                 "sha256:2f0d76660d484ff4c8c2efe9171c1281b38681e6806f87cf100e822432eda11e", | ||||
|                 "sha256:cbaa8df5faf81730f117bfa0e3fcda68ec3fa9449a05847aa6140a3f4c087765" | ||||
|                 "sha256:1e55df93aa47a84e2a12a639c7f145e16e6e9ef959542d69d5526d50d2e92692", | ||||
|                 "sha256:eab42daaaf68cdad5b112d31dcb0684162098f6558ba7b64156be44f993525fa" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==1.17.69" | ||||
|             "version": "==1.17.54" | ||||
|         }, | ||||
|         "botocore": { | ||||
|             "hashes": [ | ||||
|                 "sha256:7e94d3777763ece33d282b437e3b05b5567b9af816bd7819dbe4eb9bc6db6082", | ||||
|                 "sha256:f755b19ddebda0f8ab7afc75ebcb5412dd802eca0a7e670f5fff8c5e58bc88b1" | ||||
|                 "sha256:20a864fc6570ba11d52532c72c3ccabab5c71a9b4a9418601a313d56f1d2ce5b", | ||||
|                 "sha256:37ec76ea2df8609540ba6cb0fe360ae1c589d2e1ee91eb642fd767823f3fcedd" | ||||
|             ], | ||||
|             "version": "==1.20.69" | ||||
|             "version": "==1.20.54" | ||||
|         }, | ||||
|         "cachetools": { | ||||
|             "hashes": [ | ||||
|                 "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001", | ||||
|                 "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff" | ||||
|                 "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2", | ||||
|                 "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9" | ||||
|             ], | ||||
|             "version": "==4.2.2" | ||||
|             "version": "==4.2.1" | ||||
|         }, | ||||
|         "cbor2": { | ||||
|             "hashes": [ | ||||
| @ -312,11 +312,11 @@ | ||||
|         }, | ||||
|         "django": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0a1d195ad65c52bf275b8277b3d49680bd1137a5f55039a806f25f6b9752ce3d", | ||||
|                 "sha256:18dd3145ddbd04bf189ff79b9954d08fda5171ea7b57bf705789fea766a07d50" | ||||
|                 "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927", | ||||
|                 "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==3.2.2" | ||||
|             "version": "==3.2" | ||||
|         }, | ||||
|         "django-dbbackup": { | ||||
|             "hashes": [ | ||||
| @ -351,11 +351,11 @@ | ||||
|         }, | ||||
|         "django-otp": { | ||||
|             "hashes": [ | ||||
|                 "sha256:75a815747a0542cc5442e3a6396dfd272c49a0866bee2149ac57ecc36ddd3961", | ||||
|                 "sha256:cc657a0e7266cda6ab42f861bdc3840ed24f7e441bc7f249916174dd1a6375a0" | ||||
|                 "sha256:381a15e65293b8b06d47b7d6b306e0b7af2e104137ac92f6c566d3b9b90b6244", | ||||
|                 "sha256:f4ab096b424c33ffe69453620356e1b7517f30dfb9ba13bfeaa1d1f20faddc13" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==1.0.5" | ||||
|             "version": "==1.0.3" | ||||
|         }, | ||||
|         "django-prometheus": { | ||||
|             "hashes": [ | ||||
| @ -437,14 +437,13 @@ | ||||
|         }, | ||||
|         "google-auth": { | ||||
|             "hashes": [ | ||||
|                 "sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f", | ||||
|                 "sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206" | ||||
|                 "sha256:010f011c4e27d3d5eb01106fba6aac39d164842dfcd8709955c4638f5b11ccf8", | ||||
|                 "sha256:f30a672a64d91cc2e3137765d088c5deec26416246f7a9e956eaf69a8d7ed49c" | ||||
|             ], | ||||
|             "version": "==1.30.0" | ||||
|             "version": "==1.29.0" | ||||
|         }, | ||||
|         "gunicorn": { | ||||
|             "hashes": [ | ||||
|                 "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", | ||||
|                 "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
| @ -505,23 +504,20 @@ | ||||
|         }, | ||||
|         "httptools": { | ||||
|             "hashes": [ | ||||
|                 "sha256:07659649fe6b3948b6490825f89abe5eb1cec79ebfaaa0b4bf30f3f33f3c2ba8", | ||||
|                 "sha256:08b79e09114e6ab5c3dbf560bba2cb2257ea38cdaeaf99b7cb80d8f92622fcd9", | ||||
|                 "sha256:1e35aa179b67086cc600a984924a88589b90793c9c1b260152ca4908786e09df", | ||||
|                 "sha256:31629e1f1b89959f8c0927bad12184dc07977dcf71e24f4772934aa490aa199b", | ||||
|                 "sha256:851026bd63ec0af7e7592890d97d15c92b62d9e17094353f19a52c8e2b33710a", | ||||
|                 "sha256:8fcca4b7efe353b13a24017211334c57d055a6e132c7adffed13a10d28efca57", | ||||
|                 "sha256:9abd788465aa46a0f288bd3a99e53edd184177d6379e2098fd6097bb359ad9d6", | ||||
|                 "sha256:aebdf0bd7bf7c90ae6b3be458692bf6e9e5b610b501f9f74c7979015a51db4c4", | ||||
|                 "sha256:bda99a5723e7eab355ce57435c70853fc137a65aebf2f1cd4d15d96e2956da7b", | ||||
|                 "sha256:c1c63d860749841024951b0a78e4dec6f543d23751ef061d6ab60064c7b8b524", | ||||
|                 "sha256:c4111a0a8a00eff1e495d43ea5230aaf64968a48ddba8ea2d5f982efae827404", | ||||
|                 "sha256:dce59ee45dd6ee6c434346a5ac527c44014326f560866b4b2f414a692ee1aca8", | ||||
|                 "sha256:f759717ca1b2ef498c67ba4169c2b33eecf943a89f5329abcff8b89d153eb500", | ||||
|                 "sha256:fb7199b8fb0c50a22e77260bb59017e0c075fa80cb03bb2c8692de76e7bb7fe7", | ||||
|                 "sha256:fbf7ecd31c39728f251b1c095fd27c84e4d21f60a1d079a0333472ff3ae59d34" | ||||
|                 "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be", | ||||
|                 "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d", | ||||
|                 "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce", | ||||
|                 "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2", | ||||
|                 "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6", | ||||
|                 "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f", | ||||
|                 "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009", | ||||
|                 "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce", | ||||
|                 "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a", | ||||
|                 "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c", | ||||
|                 "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4", | ||||
|                 "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437" | ||||
|             ], | ||||
|             "version": "==0.1.2" | ||||
|             "version": "==0.1.1" | ||||
|         }, | ||||
|         "hyperlink": { | ||||
|             "hashes": [ | ||||
| @ -607,24 +603,18 @@ | ||||
|                 "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d", | ||||
|                 "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3", | ||||
|                 "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2", | ||||
|                 "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae", | ||||
|                 "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f", | ||||
|                 "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927", | ||||
|                 "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3", | ||||
|                 "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7", | ||||
|                 "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59", | ||||
|                 "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f", | ||||
|                 "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade", | ||||
|                 "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96", | ||||
|                 "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468", | ||||
|                 "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b", | ||||
|                 "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4", | ||||
|                 "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354", | ||||
|                 "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83", | ||||
|                 "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04", | ||||
|                 "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16", | ||||
|                 "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791", | ||||
|                 "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a", | ||||
|                 "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51", | ||||
|                 "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1", | ||||
|                 "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a", | ||||
| @ -637,14 +627,10 @@ | ||||
|                 "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa", | ||||
|                 "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106", | ||||
|                 "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d", | ||||
|                 "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617", | ||||
|                 "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4", | ||||
|                 "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92", | ||||
|                 "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0", | ||||
|                 "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4", | ||||
|                 "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24", | ||||
|                 "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2", | ||||
|                 "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e", | ||||
|                 "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0", | ||||
|                 "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654", | ||||
|                 "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2", | ||||
| @ -919,6 +905,41 @@ | ||||
|             "index": "pypi", | ||||
|             "version": "==3.10.1" | ||||
|         }, | ||||
|         "pycryptodomex": { | ||||
|             "hashes": [ | ||||
|                 "sha256:00a584ee52bf5e27d540129ca9bf7c4a7e7447f24ff4a220faa1304ad0c09bcd", | ||||
|                 "sha256:04265a7a84ae002001249bd1de2823bcf46832bd4b58f6965567cb8a07cf4f00", | ||||
|                 "sha256:0bd35af6a18b724c689e56f2dbbdd8e409288be71952d271ba3d9614b31d188c", | ||||
|                 "sha256:20c45a30f3389148f94edb77f3b216c677a277942f62a2b81a1cc0b6b2dde7fc", | ||||
|                 "sha256:2959304d1ce31ab303d9fb5db2b294814278b35154d9b30bf7facc52d6088d0a", | ||||
|                 "sha256:36dab7f506948056ceba2d57c1ade74e898401960de697cefc02f3519bd26c1b", | ||||
|                 "sha256:37ec1b407ec032c7a0c1fdd2da12813f560bad38ae61ad9c7ce3c0573b3e5e30", | ||||
|                 "sha256:3b8eb85b3cc7f083d87978c264d10ff9de3b4bfc46f1c6fdc2792e7d7ebc87bb", | ||||
|                 "sha256:3dfce70c4e425607ae87b8eae67c9c7dbba59a33b62d70f79417aef0bc5c735b", | ||||
|                 "sha256:418f51c61eab52d9920f4ef468d22c89dab1be5ac796f71cf3802f6a6e667df0", | ||||
|                 "sha256:4195604f75cdc1db9bccdb9e44d783add3c817319c30aaff011670c9ed167690", | ||||
|                 "sha256:4344ab16faf6c2d9df2b6772995623698fb2d5f114dace4ab2ff335550cf71d5", | ||||
|                 "sha256:541cd3e3e252fb19a7b48f420b798b53483302b7fe4d9954c947605d0a263d62", | ||||
|                 "sha256:564063e3782474c92cbb333effd06e6eb718471783c6e67f28c63f0fc3ac7b23", | ||||
|                 "sha256:72f44b5be46faef2a1bf2a85902511b31f4dd7b01ce0c3978e92edb2cc812a82", | ||||
|                 "sha256:8a98e02cbf8f624add45deff444539bf26345b479fc04fa0937b23cd84078d91", | ||||
|                 "sha256:940db96449d7b2ebb2c7bf190be1514f3d67914bd37e54e8d30a182bd375a1a9", | ||||
|                 "sha256:961333e7ee896651f02d4692242aa36b787b8e8e0baa2256717b2b9d55ae0a3c", | ||||
|                 "sha256:9f713ffb4e27b5575bd917c70bbc3f7b348241a351015dbbc514c01b7061ff7e", | ||||
|                 "sha256:a6584ae58001d17bb4dc0faa8a426919c2c028ef4d90ceb4191802ca6edb8204", | ||||
|                 "sha256:c2b680987f418858e89dbb4f09c8c919ece62811780a27051ace72b2f69fb1be", | ||||
|                 "sha256:d8fae5ba3d34c868ae43614e0bd6fb61114b2687ac3255798791ce075d95aece", | ||||
|                 "sha256:dbd2c361db939a4252589baa94da4404d45e3fc70da1a31e541644cdf354336e", | ||||
|                 "sha256:e090a8609e2095aa86978559b140cf8968af99ee54b8791b29ff804838f29f10", | ||||
|                 "sha256:e4a1245e7b846e88ba63e7543483bda61b9acbaee61eadbead5a1ce479d94740", | ||||
|                 "sha256:ec9901d19cadb80d9235ee41cc58983f18660314a0eb3fc7b11b0522ac3b6c4a", | ||||
|                 "sha256:f2abeb4c4ce7584912f4d637b2c57f23720d35dd2892bfeb1b2c84b6fb7a8c88", | ||||
|                 "sha256:f3bb267df679f70a9f40f17d62d22fe12e8b75e490f41807e7560de4d3e6bf9f", | ||||
|                 "sha256:f933ecf4cb736c7af60a6a533db2bf569717f2318b265f92907acff1db43bc34", | ||||
|                 "sha256:fc9c55dc1ed57db76595f2d19a479fc1c3a1be2c9da8de798a93d286c5f65f38" | ||||
|             ], | ||||
|             "version": "==3.10.1" | ||||
|         }, | ||||
|         "pyhamcrest": { | ||||
|             "hashes": [ | ||||
|                 "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", | ||||
| @ -926,13 +947,12 @@ | ||||
|             ], | ||||
|             "version": "==2.0.2" | ||||
|         }, | ||||
|         "pyjwt": { | ||||
|         "pyjwkest": { | ||||
|             "hashes": [ | ||||
|                 "sha256:934d73fbba91b0483d3857d1aff50e96b2a892384ee2c17417ed3203f173fca1", | ||||
|                 "sha256:fba44e7898bbca160a2b2b501f492824fc8382485d3a6f11ba5d0c1937ce6130" | ||||
|                 "sha256:5560fd5ba08655f29ff6ad1df1e15dc05abc9d976fcbcec8d2b5167f49b70222" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==2.1.0" | ||||
|             "version": "==1.4.2" | ||||
|         }, | ||||
|         "pyopenssl": { | ||||
|             "hashes": [ | ||||
| @ -963,10 +983,10 @@ | ||||
|         }, | ||||
|         "python-dotenv": { | ||||
|             "hashes": [ | ||||
|                 "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544", | ||||
|                 "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f" | ||||
|                 "sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a", | ||||
|                 "sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2" | ||||
|             ], | ||||
|             "version": "==0.17.1" | ||||
|             "version": "==0.17.0" | ||||
|         }, | ||||
|         "pytz": { | ||||
|             "hashes": [ | ||||
| @ -1086,33 +1106,33 @@ | ||||
|         }, | ||||
|         "s3transfer": { | ||||
|             "hashes": [ | ||||
|                 "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc", | ||||
|                 "sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2" | ||||
|                 "sha256:af1af6384bd7fb8208b06480f9be73d0295d965c4c073a5c95ea5b6661dccc18", | ||||
|                 "sha256:f3dfd791cad2799403e3c8051810a7ca6ee1d2e630e5d2a8f9649d892bdb3db6" | ||||
|             ], | ||||
|             "version": "==0.4.2" | ||||
|             "version": "==0.4.0" | ||||
|         }, | ||||
|         "sentry-sdk": { | ||||
|             "hashes": [ | ||||
|                 "sha256:c1227d38dca315ba35182373f129c3e2722e8ed999e52584e6aca7d287870739", | ||||
|                 "sha256:c7d380a21281e15be3d9f67a3c4fbb4f800c481d88ff8d8931f39486dd7b4ada" | ||||
|                 "sha256:71de00c9711926816f750bc0f57ef2abbcb1bfbdf5378c601df7ec978f44857a", | ||||
|                 "sha256:9221e985f425913204989d0e0e1cbb719e8b7fa10540f1bc509f660c06a34e66" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==1.1.0" | ||||
|             "version": "==1.0.0" | ||||
|         }, | ||||
|         "service-identity": { | ||||
|             "hashes": [ | ||||
|                 "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34", | ||||
|                 "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db" | ||||
|                 "sha256:001c0707759cb3de7e49c078a7c0c9cd12594161d3bf06b9c254fdcb1a60dc36", | ||||
|                 "sha256:0858a54aabc5b459d1aafa8a518ed2081a285087f349fe3e55197989232e2e2d" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==21.1.0" | ||||
|             "version": "==18.1.0" | ||||
|         }, | ||||
|         "six": { | ||||
|             "hashes": [ | ||||
|                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", | ||||
|                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" | ||||
|                 "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", | ||||
|                 "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" | ||||
|             ], | ||||
|             "version": "==1.16.0" | ||||
|             "version": "==1.15.0" | ||||
|         }, | ||||
|         "sqlparse": { | ||||
|             "hashes": [ | ||||
| @ -1178,11 +1198,11 @@ | ||||
|         }, | ||||
|         "typing-extensions": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", | ||||
|                 "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", | ||||
|                 "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" | ||||
|                 "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", | ||||
|                 "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", | ||||
|                 "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" | ||||
|             ], | ||||
|             "version": "==3.10.0.0" | ||||
|             "version": "==3.7.4.3" | ||||
|         }, | ||||
|         "uritemplate": { | ||||
|             "hashes": [ | ||||
| @ -1259,10 +1279,10 @@ | ||||
|         }, | ||||
|         "websocket-client": { | ||||
|             "hashes": [ | ||||
|                 "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32", | ||||
|                 "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c" | ||||
|                 "sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663", | ||||
|                 "sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f" | ||||
|             ], | ||||
|             "version": "==0.59.0" | ||||
|             "version": "==0.58.0" | ||||
|         }, | ||||
|         "websockets": { | ||||
|             "hashes": [ | ||||
| @ -1293,20 +1313,22 @@ | ||||
|         }, | ||||
|         "xmlsec": { | ||||
|             "hashes": [ | ||||
|                 "sha256:17d2e66d4e3e601d210eed936b53c3eb44cddaef62f60b5c6ad5c18e948d926c", | ||||
|                 "sha256:2bc1b871b49d6580779805a4a1c2d835e834a2fa614fe40cf71931d11a8279cf", | ||||
|                 "sha256:52eded125c0d1ab72125105ef061370c6b06ab9bd37e29a61bc2f8a61205bae4", | ||||
|                 "sha256:72af9a5a747a5fe6e425d2be10daa43d18307dbe03498df3820fc3cd93daa148", | ||||
|                 "sha256:806855d505da24aeb77758a6f373b1473e5ed63bdbe346af90cc6d2b053e4716", | ||||
|                 "sha256:8746dd992aaec06ed8ff1615f4a8e2a32258e8af38f9a9f8acf3ee1fb34a5da6", | ||||
|                 "sha256:9d52b2b15d42292725e4f9d8a5b040e39cba0a9cd58059ac951e7310d6340bb9", | ||||
|                 "sha256:b380f3ebc042f71afab057632481d06e06f1ba4f90047d91ca92612a7d3d487b", | ||||
|                 "sha256:be0f475edd8e9c98f57449c97839f6a81946e79e4cccb81e4b5196a2cc40e044", | ||||
|                 "sha256:bf3c62d154f2222caf56d897ddfd53fd0aef560d5a2202447d90e015301a0a10", | ||||
|                 "sha256:fe6a5f05aba3ff47e105a308482b68f8b0fd80656eb1456a9c1e4de47d2c580f" | ||||
|                 "sha256:252f79ed4482d6eefcca62c3bfc99b8d95c07abd846262d854a207ec4d67fac5", | ||||
|                 "sha256:31884dc97cc34cf1681a0f239f613969e61f9a01f4c2d2a62e53d68216fe42d6", | ||||
|                 "sha256:32a669dfe447bccecdb4ef79221c0452ce6dad919f3a75daf512792141a54dac", | ||||
|                 "sha256:3d13d7b6cb921dbc4d60d00ad00081a038df73a1e69f5bcc3695deb1bf2093b0", | ||||
|                 "sha256:5e2f263a21fd146859911479ec35e40a57f519e650f56c775f91367d2a1b6e15", | ||||
|                 "sha256:61076be98da4c7cf842a78aa3f129a5039f2ba4992e02480eefe78028d317698", | ||||
|                 "sha256:69d7f965d6b74b3266f7baa99a0377d9c76acbf26c615b4ee8d2cbe17bf85528", | ||||
|                 "sha256:6d8bb24c3a4db398011f394e29b58cd34c9c26d76b772c5d418d8579df127234", | ||||
|                 "sha256:6d9d46d1f6b4985023469a1e334cb35c7c8fc6bd9d8b65ca52b923a7a6869c2a", | ||||
|                 "sha256:8a7ffdc4f7f760253aa4dd8d2037358eb33915ca1dcf1c2422b19fcf0ab68506", | ||||
|                 "sha256:927fc5755bb93dc09275bd5d818811e016290c194012d63f8e6f86b7ece3e468", | ||||
|                 "sha256:dcaa084c3700f775eba09d81a1432444f82d9ad6270320c56c1a733d71cceb3a", | ||||
|                 "sha256:f59698cc0366395ca79b48b080674973541aae290670c57d88f05d939a4c00da" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==1.3.10" | ||||
|             "version": "==1.3.9" | ||||
|         }, | ||||
|         "yarl": { | ||||
|             "hashes": [ | ||||
| @ -1417,17 +1439,17 @@ | ||||
|         }, | ||||
|         "astroid": { | ||||
|             "hashes": [ | ||||
|                 "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", | ||||
|                 "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" | ||||
|                 "sha256:ad63b8552c70939568966811a088ef0bc880f99a24a00834abd0e3681b514f91", | ||||
|                 "sha256:bea3f32799fbb8581f58431c12591bc20ce11cbc90ad82e2ea5717d94f2080d5" | ||||
|             ], | ||||
|             "version": "==2.5.6" | ||||
|             "version": "==2.5.3" | ||||
|         }, | ||||
|         "attrs": { | ||||
|             "hashes": [ | ||||
|                 "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", | ||||
|                 "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" | ||||
|                 "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", | ||||
|                 "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" | ||||
|             ], | ||||
|             "version": "==21.2.0" | ||||
|             "version": "==20.3.0" | ||||
|         }, | ||||
|         "bandit": { | ||||
|             "hashes": [ | ||||
| @ -1452,20 +1474,6 @@ | ||||
|             "index": "pypi", | ||||
|             "version": "==1.0.1" | ||||
|         }, | ||||
|         "certifi": { | ||||
|             "hashes": [ | ||||
|                 "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", | ||||
|                 "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" | ||||
|             ], | ||||
|             "version": "==2020.12.5" | ||||
|         }, | ||||
|         "chardet": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", | ||||
|                 "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" | ||||
|             ], | ||||
|             "version": "==4.0.0" | ||||
|         }, | ||||
|         "click": { | ||||
|             "hashes": [ | ||||
|                 "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", | ||||
| @ -1548,17 +1556,10 @@ | ||||
|         }, | ||||
|         "gitpython": { | ||||
|             "hashes": [ | ||||
|                 "sha256:05af150f47a5cca3f4b0af289b73aef8cf3c4fe2385015b06220cbcdee48bb6e", | ||||
|                 "sha256:a77824e516d3298b04fb36ec7845e92747df8fcfee9cacc32dd6239f9652f867" | ||||
|                 "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b", | ||||
|                 "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61" | ||||
|             ], | ||||
|             "version": "==3.1.15" | ||||
|         }, | ||||
|         "idna": { | ||||
|             "hashes": [ | ||||
|                 "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", | ||||
|                 "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" | ||||
|             ], | ||||
|             "version": "==2.10" | ||||
|             "version": "==3.1.14" | ||||
|         }, | ||||
|         "iniconfig": { | ||||
|             "hashes": [ | ||||
| @ -1632,10 +1633,10 @@ | ||||
|         }, | ||||
|         "pbr": { | ||||
|             "hashes": [ | ||||
|                 "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", | ||||
|                 "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" | ||||
|                 "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", | ||||
|                 "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" | ||||
|             ], | ||||
|             "version": "==5.6.0" | ||||
|             "version": "==5.5.1" | ||||
|         }, | ||||
|         "pluggy": { | ||||
|             "hashes": [ | ||||
| @ -1653,19 +1654,19 @@ | ||||
|         }, | ||||
|         "pylint": { | ||||
|             "hashes": [ | ||||
|                 "sha256:586d8fa9b1891f4b725f587ef267abe2a1bad89d6b184520c7f07a253dd6e217", | ||||
|                 "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b" | ||||
|                 "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a", | ||||
|                 "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==2.8.2" | ||||
|             "version": "==2.7.4" | ||||
|         }, | ||||
|         "pylint-django": { | ||||
|             "hashes": [ | ||||
|                 "sha256:aff49d9602a39c027b4ed7521a041438893205918f405800063b7ff692b7371b", | ||||
|                 "sha256:f63f717169b0c2e4e19c28f1c32c28290647330184fcb7427805ae9b6994f3fc" | ||||
|                 "sha256:a5a4515209a6237d1d390a4a307d53f53baaf4f058ecf4bb556c775d208f6b0d", | ||||
|                 "sha256:dc5ed27bb7662d73444ccd15a0b3964ed6ced6cc2712b85db616102062d2ec35" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==2.4.4" | ||||
|             "version": "==2.4.3" | ||||
|         }, | ||||
|         "pylint-plugin-utils": { | ||||
|             "hashes": [ | ||||
| @ -1683,11 +1684,11 @@ | ||||
|         }, | ||||
|         "pytest": { | ||||
|             "hashes": [ | ||||
|                 "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", | ||||
|                 "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" | ||||
|                 "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634", | ||||
|                 "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==6.2.4" | ||||
|             "version": "==6.2.3" | ||||
|         }, | ||||
|         "pytest-django": { | ||||
|             "hashes": [ | ||||
| @ -1778,21 +1779,6 @@ | ||||
|             ], | ||||
|             "version": "==2021.4.4" | ||||
|         }, | ||||
|         "requests": { | ||||
|             "hashes": [ | ||||
|                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", | ||||
|                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" | ||||
|             ], | ||||
|             "version": "==2.25.1" | ||||
|         }, | ||||
|         "requests-mock": { | ||||
|             "hashes": [ | ||||
|                 "sha256:33296f228d8c5df11a7988b741325422480baddfdf5dd9318fd0eb40c3ed8595", | ||||
|                 "sha256:5c8ef0254c14a84744be146e9799dc13ebc4f6186058112d9aeed96b131b58e2" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==1.9.2" | ||||
|         }, | ||||
|         "selenium": { | ||||
|             "hashes": [ | ||||
|                 "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", | ||||
| @ -1803,10 +1789,10 @@ | ||||
|         }, | ||||
|         "six": { | ||||
|             "hashes": [ | ||||
|                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", | ||||
|                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" | ||||
|                 "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", | ||||
|                 "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" | ||||
|             ], | ||||
|             "version": "==1.16.0" | ||||
|             "version": "==1.15.0" | ||||
|         }, | ||||
|         "smmap": { | ||||
|             "hashes": [ | ||||
| @ -1866,11 +1852,11 @@ | ||||
|         }, | ||||
|         "typing-extensions": { | ||||
|             "hashes": [ | ||||
|                 "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", | ||||
|                 "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", | ||||
|                 "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" | ||||
|                 "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", | ||||
|                 "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", | ||||
|                 "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" | ||||
|             ], | ||||
|             "version": "==3.10.0.0" | ||||
|             "version": "==3.7.4.3" | ||||
|         }, | ||||
|         "urllib3": { | ||||
|             "extras": [ | ||||
|  | ||||
| @ -11,7 +11,6 @@ | ||||
|  | ||||
|  | ||||
|  | ||||
| [Transifex](https://www.transifex.com/beryjuorg/authentik/) | ||||
|  | ||||
| ## What is authentik? | ||||
|  | ||||
|  | ||||
| @ -4,8 +4,8 @@ | ||||
|  | ||||
| | Version    | Supported          | | ||||
| | ---------- | ------------------ | | ||||
| | 2021.3.x   | :white_check_mark: | | ||||
| | 2021.4.x   | :white_check_mark: | | ||||
| | 2021.5.x   | :white_check_mark: | | ||||
|  | ||||
| ## Reporting a Vulnerability | ||||
|  | ||||
|  | ||||
| @ -1,3 +1,3 @@ | ||||
| """authentik""" | ||||
| __version__ = "2021.5.1-rc5" | ||||
| __version__ = "2021.4.6" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| """API Authentication""" | ||||
| from base64 import b64decode | ||||
| from base64 import b64decode, b64encode | ||||
| from binascii import Error | ||||
| from typing import Any, Optional, Union | ||||
|  | ||||
| @ -19,6 +19,14 @@ def token_from_header(raw_header: bytes) -> Optional[Token]: | ||||
|     auth_credentials = raw_header.decode() | ||||
|     if auth_credentials == "": | ||||
|         return None | ||||
|     # Legacy, accept basic auth thats fully encoded (2021.3 outposts) | ||||
|     if " " not in auth_credentials: | ||||
|         try: | ||||
|             plain = b64decode(auth_credentials.encode()).decode() | ||||
|             auth_type, body = plain.split() | ||||
|             auth_credentials = f"{auth_type} {b64encode(body.encode()).decode()}" | ||||
|         except (UnicodeDecodeError, Error): | ||||
|             raise AuthenticationFailed("Malformed header") | ||||
|     auth_type, auth_credentials = auth_credentials.split() | ||||
|     if auth_type.lower() not in ["basic", "bearer"]: | ||||
|         LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) | ||||
|  | ||||
| @ -3,7 +3,7 @@ | ||||
| {% load static %} | ||||
|  | ||||
| {% block title %} | ||||
| API Browser - {{ config.authentik.branding.title }} | ||||
| authentik API Browser | ||||
| {% endblock %} | ||||
|  | ||||
| {% block head %} | ||||
|  | ||||
| @ -1,16 +0,0 @@ | ||||
| """Test config API""" | ||||
| from json import loads | ||||
|  | ||||
| from django.urls import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
|  | ||||
| class TestConfig(APITestCase): | ||||
|     """Test config API""" | ||||
|  | ||||
|     def test_config(self): | ||||
|         """Test YAML generation""" | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:configs-list"), | ||||
|         ) | ||||
|         self.assertTrue(loads(response.content.decode())) | ||||
| @ -1,33 +0,0 @@ | ||||
| """test decorators api""" | ||||
| from django.urls import reverse | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Application, User | ||||
|  | ||||
|  | ||||
| class TestAPIDecorators(APITestCase): | ||||
|     """test decorators api""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.user = User.objects.create(username="test-user") | ||||
|  | ||||
|     def test_obj_perm_denied(self): | ||||
|         """Test object perm denied""" | ||||
|         self.client.force_login(self.user) | ||||
|         app = Application.objects.create(name="denied", slug="denied") | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|     def test_other_perm_denied(self): | ||||
|         """Test other perm denied""" | ||||
|         self.client.force_login(self.user) | ||||
|         app = Application.objects.create(name="denied", slug="denied") | ||||
|         assign_perm("authentik_core.view_application", self.user, app) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
| @ -47,7 +47,6 @@ from authentik.policies.reputation.api import ( | ||||
|     ReputationPolicyViewSet, | ||||
|     UserReputationViewSet, | ||||
| ) | ||||
| from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet | ||||
| from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet | ||||
| from authentik.providers.oauth2.api.scope import ScopeMappingViewSet | ||||
| from authentik.providers.oauth2.api.tokens import ( | ||||
| @ -64,7 +63,6 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet | ||||
| from authentik.sources.oauth.api.source_connection import ( | ||||
|     UserOAuthSourceConnectionViewSet, | ||||
| ) | ||||
| from authentik.sources.plex.api import PlexSourceViewSet | ||||
| from authentik.sources.saml.api import SAMLSourceViewSet | ||||
| from authentik.stages.authenticator_static.api import ( | ||||
|     AuthenticatorStaticStageViewSet, | ||||
| @ -122,7 +120,6 @@ router.register( | ||||
|     "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet | ||||
| ) | ||||
| router.register("outposts/proxy", ProxyOutpostConfigViewSet) | ||||
| router.register("outposts/ldap", LDAPOutpostConfigViewSet) | ||||
|  | ||||
| router.register("flows/instances", FlowViewSet) | ||||
| router.register("flows/bindings", FlowStageBindingViewSet) | ||||
| @ -139,7 +136,6 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS | ||||
| router.register("sources/ldap", LDAPSourceViewSet) | ||||
| router.register("sources/saml", SAMLSourceViewSet) | ||||
| router.register("sources/oauth", OAuthSourceViewSet) | ||||
| router.register("sources/plex", PlexSourceViewSet) | ||||
|  | ||||
| router.register("policies/all", PolicyViewSet) | ||||
| router.register("policies/bindings", PolicyBindingViewSet) | ||||
| @ -153,7 +149,6 @@ router.register("policies/reputation/ips", IPReputationViewSet) | ||||
| router.register("policies/reputation", ReputationPolicyViewSet) | ||||
|  | ||||
| router.register("providers/all", ProviderViewSet) | ||||
| router.register("providers/ldap", LDAPProviderViewSet) | ||||
| router.register("providers/proxy", ProxyProviderViewSet) | ||||
| router.register("providers/oauth2", OAuth2ProviderViewSet) | ||||
| router.register("providers/saml", SAMLProviderViewSet) | ||||
|  | ||||
| @ -91,23 +91,6 @@ class ApplicationViewSet(ModelViewSet): | ||||
|                 applications.append(application) | ||||
|         return applications | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         responses={ | ||||
|             204: "Access granted", | ||||
|             403: "Access denied", | ||||
|         } | ||||
|     ) | ||||
|     @action(detail=True, methods=["GET"]) | ||||
|     # pylint: disable=unused-argument | ||||
|     def check_access(self, request: Request, slug: str) -> Response: | ||||
|         """Check access to a single application by slug""" | ||||
|         application = self.get_object() | ||||
|         engine = PolicyEngine(application, self.request.user, self.request) | ||||
|         engine.build() | ||||
|         if engine.passing: | ||||
|             return Response(status=204) | ||||
|         return Response(status=403) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|  | ||||
| @ -1,9 +1,7 @@ | ||||
| """Groups API Viewset""" | ||||
| from django.db.models.query import QuerySet | ||||
| from rest_framework.fields import JSONField | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
|  | ||||
| from authentik.core.api.utils import is_dict | ||||
| from authentik.core.models import Group | ||||
| @ -28,16 +26,3 @@ class GroupViewSet(ModelViewSet): | ||||
|     search_fields = ["name", "is_superuser"] | ||||
|     filterset_fields = ["name", "is_superuser"] | ||||
|     ordering = ["name"] | ||||
|  | ||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||
|         for backend in list(self.filter_backends): | ||||
|             if backend == ObjectPermissionsFilter: | ||||
|                 continue | ||||
|             queryset = backend().filter_queryset(self.request, queryset, self) | ||||
|         return queryset | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|         if self.request.user.has_perm("authentik_core.view_group"): | ||||
|             return self._filter_queryset_for_list(queryset) | ||||
|         return super().filter_queryset(queryset) | ||||
|  | ||||
| @ -45,7 +45,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||
|             "verbose_name", | ||||
|             "verbose_name_plural", | ||||
|             "policy_engine_mode", | ||||
|             "user_matching_mode", | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,30 +1,18 @@ | ||||
| """User API Views""" | ||||
| from json import loads | ||||
|  | ||||
| from django.db.models.query import QuerySet | ||||
| from django.http.response import Http404 | ||||
| from django.urls import reverse_lazy | ||||
| from django.utils.http import urlencode | ||||
| from django_filters.filters import BooleanFilter, CharFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method | ||||
| from guardian.utils import get_anonymous_user | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField, JSONField, SerializerMethodField | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ( | ||||
|     BooleanField, | ||||
|     ListSerializer, | ||||
|     ModelSerializer, | ||||
|     ValidationError, | ||||
| ) | ||||
| from rest_framework.serializers import BooleanField, ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
|  | ||||
| from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.groups import GroupSerializer | ||||
| from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | ||||
| from authentik.core.middleware import ( | ||||
|     SESSION_IMPERSONATE_ORIGINAL_USER, | ||||
| @ -41,8 +29,6 @@ class UserSerializer(ModelSerializer): | ||||
|     is_superuser = BooleanField(read_only=True) | ||||
|     avatar = CharField(read_only=True) | ||||
|     attributes = JSONField(validators=[is_dict], required=False) | ||||
|     groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups") | ||||
|     uid = CharField(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
| @ -54,11 +40,9 @@ class UserSerializer(ModelSerializer): | ||||
|             "is_active", | ||||
|             "last_login", | ||||
|             "is_superuser", | ||||
|             "groups", | ||||
|             "email", | ||||
|             "avatar", | ||||
|             "attributes", | ||||
|             "uid", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| @ -100,44 +84,13 @@ class UserMetricsSerializer(PassiveSerializer): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class UsersFilter(FilterSet): | ||||
|     """Filter for users""" | ||||
|  | ||||
|     attributes = CharFilter( | ||||
|         field_name="attributes", | ||||
|         lookup_expr="", | ||||
|         label="Attributes", | ||||
|         method="filter_attributes", | ||||
|     ) | ||||
|  | ||||
|     is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") | ||||
|  | ||||
|     # pylint: disable=unused-argument | ||||
|     def filter_attributes(self, queryset, name, value): | ||||
|         """Filter attributes by query args""" | ||||
|         try: | ||||
|             value = loads(value) | ||||
|         except ValueError: | ||||
|             raise ValidationError(detail="filter: failed to parse JSON") | ||||
|         if not isinstance(value, dict): | ||||
|             raise ValidationError(detail="filter: value must be key:value mapping") | ||||
|         qs = {} | ||||
|         for key, _value in value.items(): | ||||
|             qs[f"attributes__{key}"] = _value | ||||
|         return queryset.filter(**qs) | ||||
|  | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = ["username", "name", "is_active", "is_superuser", "attributes"] | ||||
|  | ||||
|  | ||||
| class UserViewSet(ModelViewSet): | ||||
|     """User Viewset""" | ||||
|  | ||||
|     queryset = User.objects.none() | ||||
|     serializer_class = UserSerializer | ||||
|     search_fields = ["username", "name", "is_active"] | ||||
|     filterset_class = UsersFilter | ||||
|     filterset_fields = ["username", "name", "is_active"] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         return User.objects.all().exclude(pk=get_anonymous_user().pk) | ||||
| @ -191,16 +144,3 @@ class UserViewSet(ModelViewSet): | ||||
|             reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}" | ||||
|         ) | ||||
|         return Response({"link": link}) | ||||
|  | ||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||
|         for backend in list(self.filter_backends): | ||||
|             if backend == ObjectPermissionsFilter: | ||||
|                 continue | ||||
|             queryset = backend().filter_queryset(self.request, queryset, self) | ||||
|         return queryset | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|         if self.request.user.has_perm("authentik_core.view_group"): | ||||
|             return self._filter_queryset_for_list(queryset) | ||||
|         return super().filter_queryset(queryset) | ||||
|  | ||||
| @ -20,12 +20,10 @@ def is_dict(value: Any): | ||||
| class PassiveSerializer(Serializer): | ||||
|     """Base serializer class which doesn't implement create/update methods""" | ||||
|  | ||||
|     def create(self, validated_data: dict) -> Model:  # pragma: no cover | ||||
|     def create(self, validated_data: dict) -> Model: | ||||
|         return Model() | ||||
|  | ||||
|     def update( | ||||
|         self, instance: Model, validated_data: dict | ||||
|     ) -> Model:  # pragma: no cover | ||||
|     def update(self, instance: Model, validated_data: dict) -> Model: | ||||
|         return Model() | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,40 +0,0 @@ | ||||
| # Generated by Django 3.2 on 2021-05-03 17:06 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0019_source_managed"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="source", | ||||
|             name="user_matching_mode", | ||||
|             field=models.TextField( | ||||
|                 choices=[ | ||||
|                     ("identifier", "Use the source-specific identifier"), | ||||
|                     ( | ||||
|                         "email_link", | ||||
|                         "Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "email_deny", | ||||
|                         "Use the user's email address, but deny enrollment when the email address already exists.", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "username_link", | ||||
|                         "Link to a user with identical username address. Can have security implications when a username is used with another source.", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "username_deny", | ||||
|                         "Use the user's username, but deny enrollment when the username already exists.", | ||||
|                     ), | ||||
|                 ], | ||||
|                 default="identifier", | ||||
|                 help_text="How the source determines if an existing user should be authenticated or a new user enrolled.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -34,7 +34,6 @@ from authentik.policies.models import PolicyBindingModel | ||||
| LOGGER = get_logger() | ||||
| USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" | ||||
| USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" | ||||
| USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" | ||||
|  | ||||
| GRAVATAR_URL = "https://secure.gravatar.com" | ||||
| DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | ||||
| @ -241,30 +240,6 @@ class Application(PolicyBindingModel): | ||||
|         verbose_name_plural = _("Applications") | ||||
|  | ||||
|  | ||||
| class SourceUserMatchingModes(models.TextChoices): | ||||
|     """Different modes a source can handle new/returning users""" | ||||
|  | ||||
|     IDENTIFIER = "identifier", _("Use the source-specific identifier") | ||||
|     EMAIL_LINK = "email_link", _( | ||||
|         ( | ||||
|             "Link to a user with identical email address. Can have security implications " | ||||
|             "when a source doesn't validate email addresses." | ||||
|         ) | ||||
|     ) | ||||
|     EMAIL_DENY = "email_deny", _( | ||||
|         "Use the user's email address, but deny enrollment when the email address already exists." | ||||
|     ) | ||||
|     USERNAME_LINK = "username_link", _( | ||||
|         ( | ||||
|             "Link to a user with identical username address. Can have security implications " | ||||
|             "when a username is used with another source." | ||||
|         ) | ||||
|     ) | ||||
|     USERNAME_DENY = "username_deny", _( | ||||
|         "Use the user's username, but deny enrollment when the username already exists." | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|     """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" | ||||
|  | ||||
| @ -297,17 +272,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|         related_name="source_enrollment", | ||||
|     ) | ||||
|  | ||||
|     user_matching_mode = models.TextField( | ||||
|         choices=SourceUserMatchingModes.choices, | ||||
|         default=SourceUserMatchingModes.IDENTIFIER, | ||||
|         help_text=_( | ||||
|             ( | ||||
|                 "How the source determines if an existing user should be authenticated or " | ||||
|                 "a new user enrolled." | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     objects = InheritanceManager() | ||||
|  | ||||
|     @property | ||||
| @ -337,8 +301,6 @@ class UserSourceConnection(CreatedUpdatedModel): | ||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||
|     source = models.ForeignKey(Source, on_delete=models.CASCADE) | ||||
|  | ||||
|     objects = InheritanceManager() | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         unique_together = (("user", "source"),) | ||||
|  | ||||
| @ -1,286 +0,0 @@ | ||||
| """Source decision helper""" | ||||
| from enum import Enum | ||||
| from typing import Any, Optional, Type | ||||
|  | ||||
| from django.contrib import messages | ||||
| from django.db import IntegrityError | ||||
| from django.db.models.query_utils import Q | ||||
| from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest | ||||
| from django.shortcuts import redirect | ||||
| from django.urls import reverse | ||||
| from django.utils.translation import gettext as _ | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import ( | ||||
|     Source, | ||||
|     SourceUserMatchingModes, | ||||
|     User, | ||||
|     UserSourceConnection, | ||||
| ) | ||||
| from authentik.core.sources.stage import ( | ||||
|     PLAN_CONTEXT_SOURCES_CONNECTION, | ||||
|     PostUserEnrollmentStage, | ||||
| ) | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.models import Flow, Stage, in_memory_stage | ||||
| from authentik.flows.planner import ( | ||||
|     PLAN_CONTEXT_PENDING_USER, | ||||
|     PLAN_CONTEXT_REDIRECT, | ||||
|     PLAN_CONTEXT_SOURCE, | ||||
|     PLAN_CONTEXT_SSO, | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.policies.utils import delete_none_keys | ||||
| from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
|  | ||||
|  | ||||
| class Action(Enum): | ||||
|     """Actions that can be decided based on the request | ||||
|     and source settings""" | ||||
|  | ||||
|     LINK = "link" | ||||
|     AUTH = "auth" | ||||
|     ENROLL = "enroll" | ||||
|     DENY = "deny" | ||||
|  | ||||
|  | ||||
| class SourceFlowManager: | ||||
|     """Help sources decide what they should do after authorization. Based on source settings and | ||||
|     previous connections, authenticate the user, enroll a new user, link to an existing user | ||||
|     or deny the request.""" | ||||
|  | ||||
|     source: Source | ||||
|     request: HttpRequest | ||||
|  | ||||
|     identifier: str | ||||
|  | ||||
|     connection_type: Type[UserSourceConnection] = UserSourceConnection | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         source: Source, | ||||
|         request: HttpRequest, | ||||
|         identifier: str, | ||||
|         enroll_info: dict[str, Any], | ||||
|     ) -> None: | ||||
|         self.source = source | ||||
|         self.request = request | ||||
|         self.identifier = identifier | ||||
|         self.enroll_info = enroll_info | ||||
|         self._logger = get_logger().bind(source=source, identifier=identifier) | ||||
|  | ||||
|     # pylint: disable=too-many-return-statements | ||||
|     def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]: | ||||
|         """decide which action should be taken""" | ||||
|         new_connection = self.connection_type( | ||||
|             source=self.source, identifier=self.identifier | ||||
|         ) | ||||
|         # When request is authenticated, always link | ||||
|         if self.request.user.is_authenticated: | ||||
|             new_connection.user = self.request.user | ||||
|             new_connection = self.update_connection(new_connection, **kwargs) | ||||
|             new_connection.save() | ||||
|             return Action.LINK, new_connection | ||||
|  | ||||
|         existing_connections = self.connection_type.objects.filter( | ||||
|             source=self.source, identifier=self.identifier | ||||
|         ) | ||||
|         if existing_connections.exists(): | ||||
|             connection = existing_connections.first() | ||||
|             return Action.AUTH, self.update_connection(connection, **kwargs) | ||||
|         # No connection exists, but we match on identifier, so enroll | ||||
|         if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER: | ||||
|             # We don't save the connection here cause it doesn't have a user assigned yet | ||||
|             return Action.ENROLL, self.update_connection(new_connection, **kwargs) | ||||
|  | ||||
|         # Check for existing users with matching attributes | ||||
|         query = Q() | ||||
|         # Either query existing user based on email or username | ||||
|         if self.source.user_matching_mode in [ | ||||
|             SourceUserMatchingModes.EMAIL_LINK, | ||||
|             SourceUserMatchingModes.EMAIL_DENY, | ||||
|         ]: | ||||
|             if not self.enroll_info.get("email", None): | ||||
|                 self._logger.warning("Refusing to use none email", source=self.source) | ||||
|                 return Action.DENY, None | ||||
|             query = Q(email__exact=self.enroll_info.get("email", None)) | ||||
|         if self.source.user_matching_mode in [ | ||||
|             SourceUserMatchingModes.USERNAME_LINK, | ||||
|             SourceUserMatchingModes.USERNAME_DENY, | ||||
|         ]: | ||||
|             if not self.enroll_info.get("username", None): | ||||
|                 self._logger.warning( | ||||
|                     "Refusing to use none username", source=self.source | ||||
|                 ) | ||||
|                 return Action.DENY, None | ||||
|             query = Q(username__exact=self.enroll_info.get("username", None)) | ||||
|         self._logger.debug("trying to link with existing user", query=query) | ||||
|         matching_users = User.objects.filter(query) | ||||
|         # No matching users, always enroll | ||||
|         if not matching_users.exists(): | ||||
|             self._logger.debug("no matching users found, enrolling") | ||||
|             return Action.ENROLL, self.update_connection(new_connection, **kwargs) | ||||
|  | ||||
|         user = matching_users.first() | ||||
|         if self.source.user_matching_mode in [ | ||||
|             SourceUserMatchingModes.EMAIL_LINK, | ||||
|             SourceUserMatchingModes.USERNAME_LINK, | ||||
|         ]: | ||||
|             new_connection.user = user | ||||
|             new_connection = self.update_connection(new_connection, **kwargs) | ||||
|             new_connection.save() | ||||
|             return Action.LINK, new_connection | ||||
|         if self.source.user_matching_mode in [ | ||||
|             SourceUserMatchingModes.EMAIL_DENY, | ||||
|             SourceUserMatchingModes.USERNAME_DENY, | ||||
|         ]: | ||||
|             self._logger.info("denying source because user exists", user=user) | ||||
|             return Action.DENY, None | ||||
|         # Should never get here as default enroll case is returned above. | ||||
|         return Action.DENY, None | ||||
|  | ||||
|     def update_connection( | ||||
|         self, connection: UserSourceConnection, **kwargs | ||||
|     ) -> UserSourceConnection: | ||||
|         """Optionally make changes to the connection after it is looked up/created.""" | ||||
|         return connection | ||||
|  | ||||
|     def get_flow(self, **kwargs) -> HttpResponse: | ||||
|         """Get the flow response based on user_matching_mode""" | ||||
|         try: | ||||
|             action, connection = self.get_action(**kwargs) | ||||
|         except IntegrityError as exc: | ||||
|             self._logger.warning("failed to get action", exc=exc) | ||||
|             return redirect("/") | ||||
|         self._logger.debug("get_action() says", action=action, connection=connection) | ||||
|         if connection: | ||||
|             if action == Action.LINK: | ||||
|                 self._logger.debug("Linking existing user") | ||||
|                 return self.handle_existing_user_link(connection) | ||||
|             if action == Action.AUTH: | ||||
|                 self._logger.debug("Handling auth user") | ||||
|                 return self.handle_auth_user(connection) | ||||
|             if action == Action.ENROLL: | ||||
|                 self._logger.debug("Handling enrollment of new user") | ||||
|                 return self.handle_enroll(connection) | ||||
|         # Default case, assume deny | ||||
|         messages.error( | ||||
|             self.request, | ||||
|             _( | ||||
|                 ( | ||||
|                     "Request to authenticate with %(source)s has been denied. Please authenticate " | ||||
|                     "with the source you've previously signed up with." | ||||
|                 ) | ||||
|                 % {"source": self.source.name} | ||||
|             ), | ||||
|         ) | ||||
|         return redirect("/") | ||||
|  | ||||
|     # pylint: disable=unused-argument | ||||
|     def get_stages_to_append(self, flow: Flow) -> list[Stage]: | ||||
|         """Hook to override stages which are appended to the flow""" | ||||
|         if flow.slug == self.source.enrollment_flow.slug: | ||||
|             return [ | ||||
|                 in_memory_stage(PostUserEnrollmentStage), | ||||
|             ] | ||||
|         return [] | ||||
|  | ||||
|     def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: | ||||
|         """Prepare Authentication Plan, redirect user FlowExecutor""" | ||||
|         # Ensure redirect is carried through when user was trying to | ||||
|         # authorize application | ||||
|         final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( | ||||
|             NEXT_ARG_NAME, "authentik_core:if-admin" | ||||
|         ) | ||||
|         kwargs.update( | ||||
|             { | ||||
|                 # Since we authenticate the user by their token, they have no backend set | ||||
|                 PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", | ||||
|                 PLAN_CONTEXT_SSO: True, | ||||
|                 PLAN_CONTEXT_SOURCE: self.source, | ||||
|                 PLAN_CONTEXT_REDIRECT: final_redirect, | ||||
|             } | ||||
|         ) | ||||
|         if not flow: | ||||
|             return HttpResponseBadRequest() | ||||
|         # We run the Flow planner here so we can pass the Pending user in the context | ||||
|         planner = FlowPlanner(flow) | ||||
|         plan = planner.plan(self.request, kwargs) | ||||
|         for stage in self.get_stages_to_append(flow): | ||||
|             plan.append(stage) | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=flow.slug, | ||||
|         ) | ||||
|  | ||||
|     # pylint: disable=unused-argument | ||||
|     def handle_auth_user( | ||||
|         self, | ||||
|         connection: UserSourceConnection, | ||||
|     ) -> HttpResponse: | ||||
|         """Login user and redirect.""" | ||||
|         messages.success( | ||||
|             self.request, | ||||
|             _( | ||||
|                 "Successfully authenticated with %(source)s!" | ||||
|                 % {"source": self.source.name} | ||||
|             ), | ||||
|         ) | ||||
|         flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user} | ||||
|         return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs) | ||||
|  | ||||
|     def handle_existing_user_link( | ||||
|         self, | ||||
|         connection: UserSourceConnection, | ||||
|     ) -> HttpResponse: | ||||
|         """Handler when the user was already authenticated and linked an external source | ||||
|         to their account.""" | ||||
|         # Connection has already been saved | ||||
|         Event.new( | ||||
|             EventAction.SOURCE_LINKED, | ||||
|             message="Linked Source", | ||||
|             source=self.source, | ||||
|         ).from_http(self.request) | ||||
|         messages.success( | ||||
|             self.request, | ||||
|             _("Successfully linked %(source)s!" % {"source": self.source.name}), | ||||
|         ) | ||||
|         # When request isn't authenticated we jump straight to auth | ||||
|         if not self.request.user.is_authenticated: | ||||
|             return self.handle_auth_user(connection) | ||||
|         return redirect( | ||||
|             reverse( | ||||
|                 "authentik_core:if-admin", | ||||
|             ) | ||||
|             + f"#/user;page-{self.source.slug}" | ||||
|         ) | ||||
|  | ||||
|     def handle_enroll( | ||||
|         self, | ||||
|         connection: UserSourceConnection, | ||||
|     ) -> HttpResponse: | ||||
|         """User was not authenticated and previous request was not authenticated.""" | ||||
|         messages.success( | ||||
|             self.request, | ||||
|             _( | ||||
|                 "Successfully authenticated with %(source)s!" | ||||
|                 % {"source": self.source.name} | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|         # We run the Flow planner here so we can pass the Pending user in the context | ||||
|         if not self.source.enrollment_flow: | ||||
|             self._logger.warning("source has no enrollment flow") | ||||
|             return HttpResponseBadRequest() | ||||
|         return self._handle_login_flow( | ||||
|             self.source.enrollment_flow, | ||||
|             **{ | ||||
|                 PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), | ||||
|                 PLAN_CONTEXT_SOURCES_CONNECTION: connection, | ||||
|             }, | ||||
|         ) | ||||
| @ -14,9 +14,9 @@ | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}?v={{ ak_version }}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}?v={{ ak_version }}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}"> | ||||
|         {% block head_before %} | ||||
|         {% endblock %} | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}"> | ||||
|         <script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script> | ||||
|         <script>window["polymerSkipLoadingFontRoboto"] = true;</script> | ||||
|         {% block head %} | ||||
|  | ||||
| @ -3,10 +3,6 @@ | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head_before %} | ||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}"> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <div class="pf-c-background-image"> | ||||
|     <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> | ||||
|  | ||||
| @ -1,125 +0,0 @@ | ||||
| """Test Applications API""" | ||||
| from django.urls import reverse | ||||
| from django.utils.encoding import force_str | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Application, User | ||||
| from authentik.policies.dummy.models import DummyPolicy | ||||
| from authentik.policies.models import PolicyBinding | ||||
|  | ||||
|  | ||||
| class TestApplicationsAPI(APITestCase): | ||||
|     """Test applications API""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.user = User.objects.get(username="akadmin") | ||||
|         self.allowed = Application.objects.create(name="allowed", slug="allowed") | ||||
|         self.denied = Application.objects.create(name="denied", slug="denied") | ||||
|         PolicyBinding.objects.create( | ||||
|             target=self.denied, | ||||
|             policy=DummyPolicy.objects.create( | ||||
|                 name="deny", result=False, wait_min=1, wait_max=2 | ||||
|             ), | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|     def test_check_access(self): | ||||
|         """Test check_access operation """ | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
|                 "authentik_api:application-check-access", | ||||
|                 kwargs={"slug": self.allowed.slug}, | ||||
|             ) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 204) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
|                 "authentik_api:application-check-access", | ||||
|                 kwargs={"slug": self.denied.slug}, | ||||
|             ) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|     def test_list(self): | ||||
|         """Test list operation without superuser_full_list""" | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get(reverse("authentik_api:application-list")) | ||||
|         self.assertJSONEqual( | ||||
|             force_str(response.content), | ||||
|             { | ||||
|                 "pagination": { | ||||
|                     "next": 0, | ||||
|                     "previous": 0, | ||||
|                     "count": 2, | ||||
|                     "current": 1, | ||||
|                     "total_pages": 1, | ||||
|                     "start_index": 1, | ||||
|                     "end_index": 2, | ||||
|                 }, | ||||
|                 "results": [ | ||||
|                     { | ||||
|                         "pk": str(self.allowed.pk), | ||||
|                         "name": "allowed", | ||||
|                         "slug": "allowed", | ||||
|                         "provider": None, | ||||
|                         "provider_obj": None, | ||||
|                         "launch_url": None, | ||||
|                         "meta_launch_url": "", | ||||
|                         "meta_icon": None, | ||||
|                         "meta_description": "", | ||||
|                         "meta_publisher": "", | ||||
|                         "policy_engine_mode": "any", | ||||
|                     }, | ||||
|                 ], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_list_superuser_full_list(self): | ||||
|         """Test list operation with superuser_full_list""" | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:application-list") + "?superuser_full_list=true" | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             force_str(response.content), | ||||
|             { | ||||
|                 "pagination": { | ||||
|                     "next": 0, | ||||
|                     "previous": 0, | ||||
|                     "count": 2, | ||||
|                     "current": 1, | ||||
|                     "total_pages": 1, | ||||
|                     "start_index": 1, | ||||
|                     "end_index": 2, | ||||
|                 }, | ||||
|                 "results": [ | ||||
|                     { | ||||
|                         "pk": str(self.allowed.pk), | ||||
|                         "name": "allowed", | ||||
|                         "slug": "allowed", | ||||
|                         "provider": None, | ||||
|                         "provider_obj": None, | ||||
|                         "launch_url": None, | ||||
|                         "meta_launch_url": "", | ||||
|                         "meta_icon": None, | ||||
|                         "meta_description": "", | ||||
|                         "meta_publisher": "", | ||||
|                         "policy_engine_mode": "any", | ||||
|                     }, | ||||
|                     { | ||||
|                         "launch_url": None, | ||||
|                         "meta_description": "", | ||||
|                         "meta_icon": None, | ||||
|                         "meta_launch_url": "", | ||||
|                         "meta_publisher": "", | ||||
|                         "name": "denied", | ||||
|                         "pk": str(self.denied.pk), | ||||
|                         "policy_engine_mode": "any", | ||||
|                         "provider": None, | ||||
|                         "provider_obj": None, | ||||
|                         "slug": "denied", | ||||
|                     }, | ||||
|                 ], | ||||
|             }, | ||||
|         ) | ||||
| @ -1,14 +1,11 @@ | ||||
| """authentik core models tests""" | ||||
| from time import sleep | ||||
| from typing import Callable, Type | ||||
|  | ||||
| from django.test import TestCase | ||||
| from django.utils.timezone import now | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.core.models import Provider, Source, Token | ||||
| from authentik.flows.models import Stage | ||||
| from authentik.lib.utils.reflection import all_subclasses | ||||
| from authentik.core.models import Token | ||||
|  | ||||
|  | ||||
| class TestModels(TestCase): | ||||
| @ -27,40 +24,3 @@ class TestModels(TestCase): | ||||
|         ) | ||||
|         sleep(0.5) | ||||
|         self.assertFalse(token.is_expired) | ||||
|  | ||||
|  | ||||
| def source_tester_factory(test_model: Type[Stage]) -> Callable: | ||||
|     """Test source""" | ||||
|  | ||||
|     def tester(self: TestModels): | ||||
|         model_class = None | ||||
|         if test_model._meta.abstract: | ||||
|             model_class = test_model.__bases__[0]() | ||||
|         else: | ||||
|             model_class = test_model() | ||||
|         model_class.slug = "test" | ||||
|         self.assertIsNotNone(model_class.component) | ||||
|         _ = model_class.ui_login_button | ||||
|         _ = model_class.ui_user_settings | ||||
|  | ||||
|     return tester | ||||
|  | ||||
|  | ||||
| def provider_tester_factory(test_model: Type[Stage]) -> Callable: | ||||
|     """Test provider""" | ||||
|  | ||||
|     def tester(self: TestModels): | ||||
|         model_class = None | ||||
|         if test_model._meta.abstract: | ||||
|             model_class = test_model.__bases__[0]() | ||||
|         else: | ||||
|             model_class = test_model() | ||||
|         self.assertIsNotNone(model_class.component) | ||||
|  | ||||
|     return tester | ||||
|  | ||||
|  | ||||
| for model in all_subclasses(Source): | ||||
|     setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model)) | ||||
| for model in all_subclasses(Provider): | ||||
|     setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model)) | ||||
|  | ||||
| @ -2,10 +2,9 @@ | ||||
| from dataclasses import dataclass | ||||
| from typing import Optional | ||||
|  | ||||
| from rest_framework.fields import CharField, DictField | ||||
| from rest_framework.fields import CharField | ||||
|  | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.flows.challenge import Challenge | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| @ -15,8 +14,8 @@ class UILoginButton: | ||||
|     # Name, ran through i18n | ||||
|     name: str | ||||
|  | ||||
|     # Challenge which is presented to the user when they click the button | ||||
|     challenge: Challenge | ||||
|     # URL Which Button points to | ||||
|     url: str | ||||
|  | ||||
|     # Icon URL, used as-is | ||||
|     icon_url: Optional[str] = None | ||||
| @ -26,7 +25,7 @@ class UILoginButtonSerializer(PassiveSerializer): | ||||
|     """Serializer for Login buttons of sources""" | ||||
|  | ||||
|     name = CharField() | ||||
|     challenge = DictField() | ||||
|     url = CharField() | ||||
|     icon_url = CharField(required=False, allow_null=True) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -39,7 +39,7 @@ class CertificateKeyPairSerializer(ModelSerializer): | ||||
|         """Show if this keypair has a private key configured or not""" | ||||
|         return instance.key_data != "" and instance.key_data is not None | ||||
|  | ||||
|     def validate_certificate_data(self, value: str) -> str: | ||||
|     def validate_certificate_data(self, value): | ||||
|         """Verify that input is a valid PEM x509 Certificate""" | ||||
|         try: | ||||
|             load_pem_x509_certificate(value.encode("utf-8"), default_backend()) | ||||
| @ -47,7 +47,7 @@ class CertificateKeyPairSerializer(ModelSerializer): | ||||
|             raise ValidationError("Unable to load certificate.") | ||||
|         return value | ||||
|  | ||||
|     def validate_key_data(self, value: str) -> str: | ||||
|     def validate_key_data(self, value): | ||||
|         """Verify that input is a valid PEM RSA Key""" | ||||
|         # Since this field is optional, data can be empty. | ||||
|         if value != "": | ||||
| @ -57,10 +57,8 @@ class CertificateKeyPairSerializer(ModelSerializer): | ||||
|                     password=None, | ||||
|                     backend=default_backend(), | ||||
|                 ) | ||||
|             except (ValueError, TypeError): | ||||
|                 raise ValidationError( | ||||
|                     "Unable to load private key (possibly encrypted?)." | ||||
|                 ) | ||||
|             except ValueError: | ||||
|                 raise ValidationError("Unable to load private key.") | ||||
|         return value | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
| @ -33,7 +33,7 @@ class CertificateBuilder: | ||||
|     def save(self) -> Optional[CertificateKeyPair]: | ||||
|         """Save generated certificate as model""" | ||||
|         if not self.__certificate: | ||||
|             raise ValueError("Certificated hasn't been built yet") | ||||
|             return None | ||||
|         return CertificateKeyPair.objects.create( | ||||
|             name=self.common_name, | ||||
|             certificate_data=self.certificate, | ||||
|  | ||||
| @ -37,8 +37,6 @@ class TestCrypto(TestCase): | ||||
|         """Test Builder""" | ||||
|         builder = CertificateBuilder() | ||||
|         builder.common_name = "test-cert" | ||||
|         with self.assertRaises(ValueError): | ||||
|             builder.save() | ||||
|         builder.build( | ||||
|             subject_alt_names=[], | ||||
|             validity_days=3, | ||||
|  | ||||
| @ -8,10 +8,10 @@ from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField, DictField, IntegerField | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.serializers import ModelSerializer, Serializer | ||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | ||||
|  | ||||
| from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer | ||||
| from authentik.core.api.utils import TypeCreateSerializer | ||||
| from authentik.events.models import Event, EventAction | ||||
|  | ||||
|  | ||||
| @ -38,19 +38,31 @@ class EventSerializer(ModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class EventTopPerUserParams(PassiveSerializer): | ||||
| class EventTopPerUserParams(Serializer): | ||||
|     """Query params for top_per_user""" | ||||
|  | ||||
|     top_n = IntegerField(default=15) | ||||
|  | ||||
|     def create(self, request: Request) -> Response: | ||||
|         raise NotImplementedError | ||||
|  | ||||
| class EventTopPerUserSerializer(PassiveSerializer): | ||||
|     def update(self, request: Request) -> Response: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|  | ||||
| class EventTopPerUserSerializer(Serializer): | ||||
|     """Response object of Event's top_per_user""" | ||||
|  | ||||
|     application = DictField() | ||||
|     counted_events = IntegerField() | ||||
|     unique_users = IntegerField() | ||||
|  | ||||
|     def create(self, request: Request) -> Response: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def update(self, request: Request) -> Response: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|  | ||||
| class EventsFilter(django_filters.FilterSet): | ||||
|     """Filter for events""" | ||||
| @ -120,7 +132,7 @@ class EventViewSet(ReadOnlyModelViewSet): | ||||
|     def top_per_user(self, request: Request): | ||||
|         """Get the top_n events grouped by user count""" | ||||
|         filtered_action = request.query_params.get("action", EventAction.LOGIN) | ||||
|         top_n = int(request.query_params.get("top_n", "15")) | ||||
|         top_n = request.query_params.get("top_n", 15) | ||||
|         return Response( | ||||
|             get_objects_for_user(request.user, "authentik_events.view_event") | ||||
|             .filter(action=filtered_action) | ||||
|  | ||||
| @ -1,9 +1,6 @@ | ||||
| """Notification API Views""" | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from guardian.utils import get_anonymous_user | ||||
| from rest_framework import mixins | ||||
| from rest_framework.fields import ReadOnlyField | ||||
| from rest_framework.filters import OrderingFilter, SearchFilter | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| @ -49,12 +46,8 @@ class NotificationViewSet( | ||||
|         "event", | ||||
|         "seen", | ||||
|     ] | ||||
|     filter_backends = [ | ||||
|         DjangoFilterBackend, | ||||
|         OrderingFilter, | ||||
|         SearchFilter, | ||||
|     ] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         user = self.request.user if self.request else get_anonymous_user() | ||||
|         return Notification.objects.filter(user=user.pk) | ||||
|         if not self.request: | ||||
|             return super().get_queryset() | ||||
|         return Notification.objects.filter(user=self.request.user) | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| """Event notification tasks""" | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
| from structlog.stdlib import get_logger | ||||
| from structlog import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import ( | ||||
| @ -35,10 +35,7 @@ def event_trigger_handler(event_uuid: str, trigger_name: str): | ||||
|         LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid) | ||||
|         return | ||||
|     event: Event = events.first() | ||||
|     triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name) | ||||
|     if not triggers.exists(): | ||||
|         return | ||||
|     trigger = triggers.first() | ||||
|     trigger: NotificationRule = NotificationRule.objects.get(name=trigger_name) | ||||
|  | ||||
|     if "policy_uuid" in event.context: | ||||
|         policy_uuid = event.context["policy_uuid"] | ||||
| @ -61,13 +58,7 @@ def event_trigger_handler(event_uuid: str, trigger_name: str): | ||||
|         return | ||||
|  | ||||
|     LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger) | ||||
|     try: | ||||
|         user = ( | ||||
|             User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user() | ||||
|         ) | ||||
|     except User.DoesNotExist: | ||||
|         LOGGER.warning("e(trigger): failed to get user", trigger=trigger) | ||||
|         return | ||||
|     user = User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user() | ||||
|     policy_engine = PolicyEngine(trigger, user) | ||||
|     policy_engine.mode = PolicyEngineMode.MODE_ANY | ||||
|     policy_engine.empty_result = False | ||||
|  | ||||
| @ -1,32 +0,0 @@ | ||||
| """base model tests""" | ||||
| from typing import Callable, Type | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.flows.models import Stage | ||||
| from authentik.flows.stage import StageView | ||||
| from authentik.lib.utils.reflection import all_subclasses | ||||
|  | ||||
|  | ||||
| class TestModels(TestCase): | ||||
|     """Generic model properties tests""" | ||||
|  | ||||
|  | ||||
| def model_tester_factory(test_model: Type[Stage]) -> Callable: | ||||
|     """Test a form""" | ||||
|  | ||||
|     def tester(self: TestModels): | ||||
|         model_class = None | ||||
|         if test_model._meta.abstract: | ||||
|             model_class = test_model.__bases__[0]() | ||||
|         else: | ||||
|             model_class = test_model() | ||||
|         self.assertTrue(issubclass(model_class.type, StageView)) | ||||
|         self.assertIsNotNone(test_model.component) | ||||
|         _ = test_model.ui_user_settings | ||||
|  | ||||
|     return tester | ||||
|  | ||||
|  | ||||
| for model in all_subclasses(Stage): | ||||
|     setattr(TestModels, f"test_model_{model.__name__}", model_tester_factory(model)) | ||||
| @ -13,7 +13,7 @@ from django.db.models.query_utils import Q | ||||
| from django.db.utils import IntegrityError | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.serializers import BaseSerializer, Serializer | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
| from structlog import BoundLogger, get_logger | ||||
|  | ||||
| from authentik.flows.models import Flow, FlowStageBinding, Stage | ||||
| from authentik.flows.transfer.common import ( | ||||
|  | ||||
| @ -86,13 +86,6 @@ class ConfigLoader: | ||||
|         url = urlparse(value) | ||||
|         if url.scheme == "env": | ||||
|             value = os.getenv(url.netloc, url.query) | ||||
|         if url.scheme == "file": | ||||
|             try: | ||||
|                 with open(url.netloc, "r") as _file: | ||||
|                     value = _file.read() | ||||
|             except OSError: | ||||
|                 self._log("error", f"Failed to read config value from {url.netloc}") | ||||
|                 value = url.query | ||||
|         return value | ||||
|  | ||||
|     def update_from_file(self, path: str): | ||||
| @ -170,7 +163,6 @@ class ConfigLoader: | ||||
|         # Walk each component of the path | ||||
|         path_parts = path.split(sep) | ||||
|         for comp in path_parts[:-1]: | ||||
|             # pyright: reportGeneralTypeIssues=false | ||||
|             if comp not in root: | ||||
|                 root[comp] = {} | ||||
|             root = root.get(comp) | ||||
|  | ||||
| @ -5,10 +5,6 @@ postgresql: | ||||
|   user: authentik | ||||
|   password: 'env://POSTGRES_PASSWORD' | ||||
|  | ||||
| web: | ||||
|   listen: 0.0.0.0:9000 | ||||
|   listen_tls: 0.0.0.0:9443 | ||||
|  | ||||
| redis: | ||||
|   host: localhost | ||||
|   password: '' | ||||
| @ -38,10 +34,7 @@ email: | ||||
|   from: authentik@localhost | ||||
|  | ||||
| outposts: | ||||
|   # Placeholders: | ||||
|   # %(type)s: Outpost type; proxy, ldap, etc | ||||
|   # %(version)s: Current version; 2021.4.1 | ||||
|   docker_image_base: "beryju/authentik-%(type)s:%(version)s" | ||||
|   docker_image_base: "beryju/authentik" # this is prepended to -proxy:version | ||||
|  | ||||
| authentik: | ||||
|   avatars: gravatar  # gravatar or none | ||||
|  | ||||
| @ -1,16 +0,0 @@ | ||||
| """Test Reflection utils""" | ||||
|  | ||||
| from datetime import datetime | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.lib.utils.reflection import path_to_class | ||||
|  | ||||
|  | ||||
| class TestReflectionUtils(TestCase): | ||||
|     """Test Reflection-utils""" | ||||
|  | ||||
|     def test_path_to_class(self): | ||||
|         """Test path_to_class""" | ||||
|         self.assertIsNone(path_to_class(None)) | ||||
|         self.assertEqual(path_to_class("datetime.datetime"), datetime) | ||||
| @ -3,9 +3,6 @@ from typing import Any, Optional | ||||
|  | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | ||||
| USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | ||||
|  | ||||
|  | ||||
| def _get_client_ip_from_meta(meta: dict[str, Any]) -> Optional[str]: | ||||
|     """Attempt to get the client's IP by checking common HTTP Headers. | ||||
| @ -21,27 +18,9 @@ def _get_client_ip_from_meta(meta: dict[str, Any]) -> Optional[str]: | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: | ||||
|     """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 | ||||
|     to outpost""" | ||||
|     if not hasattr(request, "user"): | ||||
|         return None | ||||
|     if not request.user.is_authenticated: | ||||
|         return None | ||||
|     if OUTPOST_REMOTE_IP_HEADER not in request.META: | ||||
|         return None | ||||
|     if request.user.attributes.get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): | ||||
|         return None | ||||
|     return request.META[OUTPOST_REMOTE_IP_HEADER] | ||||
|  | ||||
|  | ||||
| def get_client_ip(request: Optional[HttpRequest]) -> Optional[str]: | ||||
|     """Attempt to get the client's IP by checking common HTTP Headers. | ||||
|     Returns none if no IP Could be found""" | ||||
|     if request: | ||||
|         override = _get_outpost_override_ip(request) | ||||
|         if override: | ||||
|             return override | ||||
|         return _get_client_ip_from_meta(request.META) | ||||
|     return None | ||||
|  | ||||
| @ -24,7 +24,6 @@ class OutpostSerializer(ModelSerializer): | ||||
|         fields = [ | ||||
|             "pk", | ||||
|             "name", | ||||
|             "type", | ||||
|             "providers", | ||||
|             "providers_obj", | ||||
|             "service_connection", | ||||
|  | ||||
| @ -82,8 +82,7 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
|             state.version = msg.args.get("version", None) | ||||
|         elif msg.instruction == WebsocketMessageInstruction.ACK: | ||||
|             return | ||||
|         if state.version: | ||||
|             state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) | ||||
|         state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) | ||||
|  | ||||
|         response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) | ||||
|         self.send_json(asdict(response)) | ||||
|  | ||||
| @ -1,12 +1,9 @@ | ||||
| """Base Controller""" | ||||
| from dataclasses import dataclass | ||||
| from typing import Optional | ||||
|  | ||||
| from structlog.stdlib import get_logger | ||||
| from structlog.testing import capture_logs | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||
|  | ||||
| @ -24,7 +21,6 @@ class DeploymentPort: | ||||
|     port: int | ||||
|     name: str | ||||
|     protocol: str | ||||
|     inner_port: Optional[int] = None | ||||
|  | ||||
|  | ||||
| class BaseController: | ||||
| @ -59,8 +55,3 @@ class BaseController: | ||||
|     def get_static_deployment(self) -> str: | ||||
|         """Return a static deployment configuration""" | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def get_container_image(self) -> str: | ||||
|         """Get container image to use for this outpost""" | ||||
|         image_name_template: str = CONFIG.y("outposts.docker_image_base") | ||||
|         return image_name_template % {"type": self.outpost.type, "version": __version__} | ||||
|  | ||||
| @ -8,6 +8,7 @@ from docker.models.containers import Container | ||||
| from yaml import safe_dump | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | ||||
| from authentik.outposts.models import ( | ||||
|     DockerServiceConnection, | ||||
| @ -59,14 +60,15 @@ class DockerController(BaseController): | ||||
|             return self.client.containers.get(container_name), False | ||||
|         except NotFound: | ||||
|             self.logger.info("Container does not exist, creating") | ||||
|             image_name = self.get_container_image() | ||||
|             image_prefix = CONFIG.y("outposts.docker_image_base") | ||||
|             image_name = f"{image_prefix}-{self.outpost.type}:{__version__}" | ||||
|             self.client.images.pull(image_name) | ||||
|             container_args = { | ||||
|                 "image": image_name, | ||||
|                 "name": f"authentik-proxy-{self.outpost.uuid.hex}", | ||||
|                 "detach": True, | ||||
|                 "ports": { | ||||
|                     f"{port.port}/{port.protocol.lower()}": port.inner_port or port.port | ||||
|                     f"{port.port}/{port.protocol.lower()}": port.port | ||||
|                     for port in self.deployment_ports | ||||
|                 }, | ||||
|                 "environment": self._get_env(), | ||||
| @ -141,15 +143,15 @@ class DockerController(BaseController): | ||||
|     def get_static_deployment(self) -> str: | ||||
|         """Generate docker-compose yaml for proxy, version 3.5""" | ||||
|         ports = [ | ||||
|             f"{port.port}:{port.inner_port or port.port}/{port.protocol.lower()}" | ||||
|             f"{port.port}:{port.port}/{port.protocol.lower()}" | ||||
|             for port in self.deployment_ports | ||||
|         ] | ||||
|         image_name = self.get_container_image() | ||||
|         image_prefix = CONFIG.y("outposts.docker_image_base") | ||||
|         compose = { | ||||
|             "version": "3.5", | ||||
|             "services": { | ||||
|                 f"authentik_{self.outpost.type}": { | ||||
|                     "image": image_name, | ||||
|                     "image": f"{image_prefix}-{self.outpost.type}:{__version__}", | ||||
|                     "ports": ports, | ||||
|                     "environment": { | ||||
|                         "AUTHENTIK_HOST": self.outpost.config.authentik_host, | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """Base Kubernetes Reconciler""" | ||||
| from typing import TYPE_CHECKING, Generic, TypeVar | ||||
|  | ||||
| from django.utils.text import slugify | ||||
| from kubernetes.client import V1ObjectMeta | ||||
| from kubernetes.client.models.v1_deployment import V1Deployment | ||||
| from kubernetes.client.models.v1_pod import V1Pod | ||||
| @ -30,11 +29,6 @@ class NeedsUpdate(ReconcileTrigger): | ||||
|     """Exception to trigger an update to the Kubernetes Object""" | ||||
|  | ||||
|  | ||||
| class Disabled(SentryIgnoredException): | ||||
|     """Exception which can be thrown in a reconciler to signal than an | ||||
|     object should not be created.""" | ||||
|  | ||||
|  | ||||
| class KubernetesObjectReconciler(Generic[T]): | ||||
|     """Base Kubernetes Reconciler, handles the basic logic.""" | ||||
|  | ||||
| @ -43,27 +37,17 @@ class KubernetesObjectReconciler(Generic[T]): | ||||
|     def __init__(self, controller: "KubernetesController"): | ||||
|         self.controller = controller | ||||
|         self.namespace = controller.outpost.config.kubernetes_namespace | ||||
|         self.logger = get_logger().bind(type=self.__class__.__name__) | ||||
|         self.logger = get_logger() | ||||
|  | ||||
|     @property | ||||
|     def name(self) -> str: | ||||
|         """Get the name of the object this reconciler manages""" | ||||
|         return ( | ||||
|             self.controller.outpost.config.object_naming_template | ||||
|             % { | ||||
|                 "name": slugify(self.controller.outpost.name), | ||||
|                 "uuid": self.controller.outpost.uuid.hex, | ||||
|             } | ||||
|         ).lower() | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def up(self): | ||||
|         """Create object if it doesn't exist, update if needed or recreate if needed.""" | ||||
|         current = None | ||||
|         try: | ||||
|             reference = self.get_reference_object() | ||||
|         except Disabled: | ||||
|             self.logger.debug("Object not required") | ||||
|             return | ||||
|         reference = self.get_reference_object() | ||||
|         try: | ||||
|             try: | ||||
|                 current = self.retrieve() | ||||
| @ -74,6 +58,7 @@ class KubernetesObjectReconciler(Generic[T]): | ||||
|                 self.logger.debug("Other unhandled error", exc=exc) | ||||
|                 raise exc | ||||
|             else: | ||||
|                 self.logger.debug("Got current, running reconcile") | ||||
|                 self.reconcile(current, reference) | ||||
|         except NeedsRecreate: | ||||
|             self.logger.debug("Recreate requested") | ||||
| @ -82,22 +67,16 @@ class KubernetesObjectReconciler(Generic[T]): | ||||
|                 self.delete(current) | ||||
|             else: | ||||
|                 self.logger.debug("No old found, creating") | ||||
|             self.logger.debug("Creating") | ||||
|             self.logger.debug("Created") | ||||
|             self.create(reference) | ||||
|         except NeedsUpdate: | ||||
|             self.logger.debug("Updating") | ||||
|             self.update(current, reference) | ||||
|         else: | ||||
|             self.logger.debug("Object is up-to-date.") | ||||
|             self.logger.debug("Nothing to do...") | ||||
|  | ||||
|     def down(self): | ||||
|         """Delete object if found""" | ||||
|         # Call self.get_reference_object to check if we even need to do anything | ||||
|         try: | ||||
|             self.get_reference_object() | ||||
|         except Disabled: | ||||
|             self.logger.debug("Object not required") | ||||
|             return | ||||
|         try: | ||||
|             current = self.retrieve() | ||||
|             self.delete(current) | ||||
| @ -141,7 +120,7 @@ class KubernetesObjectReconciler(Generic[T]): | ||||
|             namespace=self.namespace, | ||||
|             labels={ | ||||
|                 "app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}", | ||||
|                 "app.kubernetes.io/instance": slugify(self.controller.outpost.name), | ||||
|                 "app.kubernetes.io/instance": self.controller.outpost.name, | ||||
|                 "app.kubernetes.io/version": __version__, | ||||
|                 "app.kubernetes.io/managed-by": "goauthentik.io", | ||||
|                 "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex, | ||||
|  | ||||
| @ -16,6 +16,8 @@ from kubernetes.client import ( | ||||
|     V1SecretKeySelector, | ||||
| ) | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | ||||
| from authentik.outposts.controllers.k8s.base import ( | ||||
|     KubernetesObjectReconciler, | ||||
| @ -37,6 +39,10 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|         self.api = AppsV1Api(controller.client) | ||||
|         self.outpost = self.controller.outpost | ||||
|  | ||||
|     @property | ||||
|     def name(self) -> str: | ||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" | ||||
|  | ||||
|     def reconcile(self, current: V1Deployment, reference: V1Deployment): | ||||
|         super().reconcile(current, reference) | ||||
|         if current.spec.replicas != reference.spec.replicas: | ||||
| @ -62,13 +68,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|         for port in self.controller.deployment_ports: | ||||
|             container_ports.append( | ||||
|                 V1ContainerPort( | ||||
|                     container_port=port.inner_port or port.port, | ||||
|                     container_port=port.port, | ||||
|                     name=port.name, | ||||
|                     protocol=port.protocol.upper(), | ||||
|                 ) | ||||
|             ) | ||||
|         meta = self.get_object_meta(name=self.name) | ||||
|         image_name = self.controller.get_container_image() | ||||
|         secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" | ||||
|         image_prefix = CONFIG.y("outposts.docker_image_base") | ||||
|         return V1Deployment( | ||||
|             metadata=meta, | ||||
|             spec=V1DeploymentSpec( | ||||
| @ -80,14 +87,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|                         containers=[ | ||||
|                             V1Container( | ||||
|                                 name=str(self.outpost.type), | ||||
|                                 image=image_name, | ||||
|                                 image=f"{image_prefix}-{self.outpost.type}:{__version__}", | ||||
|                                 ports=container_ports, | ||||
|                                 env=[ | ||||
|                                     V1EnvVar( | ||||
|                                         name="AUTHENTIK_HOST", | ||||
|                                         value_from=V1EnvVarSource( | ||||
|                                             secret_key_ref=V1SecretKeySelector( | ||||
|                                                 name=self.name, | ||||
|                                                 name=secret_name, | ||||
|                                                 key="authentik_host", | ||||
|                                             ) | ||||
|                                         ), | ||||
| @ -96,7 +103,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|                                         name="AUTHENTIK_TOKEN", | ||||
|                                         value_from=V1EnvVarSource( | ||||
|                                             secret_key_ref=V1SecretKeySelector( | ||||
|                                                 name=self.name, | ||||
|                                                 name=secret_name, | ||||
|                                                 key="token", | ||||
|                                             ) | ||||
|                                         ), | ||||
| @ -105,7 +112,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|                                         name="AUTHENTIK_INSECURE", | ||||
|                                         value_from=V1EnvVarSource( | ||||
|                                             secret_key_ref=V1SecretKeySelector( | ||||
|                                                 name=self.name, | ||||
|                                                 name=secret_name, | ||||
|                                                 key="authentik_host_insecure", | ||||
|                                             ) | ||||
|                                         ), | ||||
|  | ||||
| @ -26,6 +26,10 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): | ||||
|         super().__init__(controller) | ||||
|         self.api = CoreV1Api(controller.client) | ||||
|  | ||||
|     @property | ||||
|     def name(self) -> str: | ||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" | ||||
|  | ||||
|     def reconcile(self, current: V1Secret, reference: V1Secret): | ||||
|         super().reconcile(current, reference) | ||||
|         for key in reference.data.keys(): | ||||
|  | ||||
| @ -21,6 +21,10 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | ||||
|         super().__init__(controller) | ||||
|         self.api = CoreV1Api(controller.client) | ||||
|  | ||||
|     @property | ||||
|     def name(self) -> str: | ||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" | ||||
|  | ||||
|     def reconcile(self, current: V1Service, reference: V1Service): | ||||
|         super().reconcile(current, reference) | ||||
|         if len(current.spec.ports) != len(reference.spec.ports): | ||||
| @ -39,17 +43,13 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | ||||
|                     name=port.name, | ||||
|                     port=port.port, | ||||
|                     protocol=port.protocol.upper(), | ||||
|                     target_port=port.inner_port or port.port, | ||||
|                     target_port=port.port, | ||||
|                 ) | ||||
|             ) | ||||
|         selector_labels = DeploymentReconciler(self.controller).get_pod_meta() | ||||
|         return V1Service( | ||||
|             metadata=meta, | ||||
|             spec=V1ServiceSpec( | ||||
|                 ports=ports, | ||||
|                 selector=selector_labels, | ||||
|                 type=self.controller.outpost.config.kubernetes_service_type, | ||||
|             ), | ||||
|             spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"), | ||||
|         ) | ||||
|  | ||||
|     def create(self, reference: V1Service): | ||||
|  | ||||
| @ -1,11 +0,0 @@ | ||||
| """k8s utils""" | ||||
| from pathlib import Path | ||||
|  | ||||
|  | ||||
| def get_namespace() -> str: | ||||
|     """Get the namespace if we're running in a pod, otherwise default to default""" | ||||
|     path = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace") | ||||
|     if path.exists(): | ||||
|         with open(path, "r") as _namespace_file: | ||||
|             return _namespace_file.read() | ||||
|     return "default" | ||||
| @ -2,13 +2,14 @@ | ||||
| from io import StringIO | ||||
| from typing import Type | ||||
|  | ||||
| from kubernetes.client import OpenApiException | ||||
| from kubernetes.client.api_client import ApiClient | ||||
| from kubernetes.client.exceptions import ApiException | ||||
| from structlog.testing import capture_logs | ||||
| from urllib3.exceptions import HTTPError | ||||
| from yaml import dump_all | ||||
|  | ||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | ||||
| from authentik.outposts.controllers.k8s.base import Disabled, KubernetesObjectReconciler | ||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||
| from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler | ||||
| from authentik.outposts.controllers.k8s.secret import SecretReconciler | ||||
| from authentik.outposts.controllers.k8s.service import ServiceReconciler | ||||
| @ -42,8 +43,8 @@ class KubernetesController(BaseController): | ||||
|                 reconciler = self.reconcilers[reconcile_key](self) | ||||
|                 reconciler.up() | ||||
|  | ||||
|         except ApiException as exc: | ||||
|             raise ControllerException(str(exc)) from exc | ||||
|         except (OpenApiException, HTTPError) as exc: | ||||
|             raise ControllerException from exc | ||||
|  | ||||
|     def up_with_logs(self) -> list[str]: | ||||
|         try: | ||||
| @ -54,27 +55,23 @@ class KubernetesController(BaseController): | ||||
|                     reconciler.up() | ||||
|                 all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs] | ||||
|             return all_logs | ||||
|         except ApiException as exc: | ||||
|             raise ControllerException(str(exc)) from exc | ||||
|         except (OpenApiException, HTTPError) as exc: | ||||
|             raise ControllerException from exc | ||||
|  | ||||
|     def down(self): | ||||
|         try: | ||||
|             for reconcile_key in self.reconcile_order: | ||||
|                 reconciler = self.reconcilers[reconcile_key](self) | ||||
|                 self.logger.debug("Tearing down object", name=reconcile_key) | ||||
|                 reconciler.down() | ||||
|  | ||||
|         except ApiException as exc: | ||||
|             raise ControllerException(str(exc)) from exc | ||||
|         except OpenApiException as exc: | ||||
|             raise ControllerException from exc | ||||
|  | ||||
|     def get_static_deployment(self) -> str: | ||||
|         documents = [] | ||||
|         for reconcile_key in self.reconcile_order: | ||||
|             reconciler = self.reconcilers[reconcile_key](self) | ||||
|             try: | ||||
|                 documents.append(reconciler.get_reference_object().to_dict()) | ||||
|             except Disabled: | ||||
|                 continue | ||||
|             documents.append(reconciler.get_reference_object().to_dict()) | ||||
|  | ||||
|         with StringIO() as _str: | ||||
|             dump_all( | ||||
|  | ||||
| @ -1,20 +0,0 @@ | ||||
| # Generated by Django 3.2 on 2021-04-26 09:27 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_outposts", "0015_auto_20201224_1206"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="outpost", | ||||
|             name="type", | ||||
|             field=models.TextField( | ||||
|                 choices=[("proxy", "Proxy"), ("ldap", "Ldap")], default="proxy" | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -5,7 +5,6 @@ from typing import Iterable, Optional, Union | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from dacite import from_dict | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.core.cache import cache | ||||
| from django.db import models, transaction | ||||
| from django.db.models.base import Model | ||||
| @ -32,8 +31,6 @@ from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.models import InheritanceForeignKey | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.lib.utils.http import USER_ATTRIBUTE_CAN_OVERRIDE_IP | ||||
| from authentik.outposts.controllers.k8s.utils import get_namespace | ||||
| from authentik.outposts.docker_tls import DockerInlineTLS | ||||
|  | ||||
| OUR_VERSION = parse(__version__) | ||||
| @ -58,18 +55,16 @@ class OutpostConfig: | ||||
|         "error_reporting.environment", "customer" | ||||
|     ) | ||||
|  | ||||
|     object_naming_template: str = field(default="ak-outpost-%(name)s") | ||||
|     kubernetes_replicas: int = field(default=1) | ||||
|     kubernetes_namespace: str = field(default_factory=get_namespace) | ||||
|     kubernetes_namespace: str = field(default="default") | ||||
|     kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict) | ||||
|     kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls") | ||||
|     kubernetes_service_type: str = field(default="ClusterIP") | ||||
|     kubernetes_ingress_secret_name: str = field(default="authentik-outpost") | ||||
|  | ||||
|  | ||||
| class OutpostModel(Model): | ||||
|     """Base model for providers that need more objects than just themselves""" | ||||
|  | ||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||
|     def get_required_objects(self) -> Iterable[models.Model]: | ||||
|         """Return a list of all required objects""" | ||||
|         return [self] | ||||
|  | ||||
| @ -82,7 +77,6 @@ class OutpostType(models.TextChoices): | ||||
|     """Outpost types, currently only the reverse proxy is available""" | ||||
|  | ||||
|     PROXY = "proxy" | ||||
|     LDAP = "ldap" | ||||
|  | ||||
|  | ||||
| def default_outpost_config(host: Optional[str] = None): | ||||
| @ -332,7 +326,6 @@ class Outpost(models.Model): | ||||
|         if not users.exists(): | ||||
|             user: User = User.objects.create(username=self.user_identifier) | ||||
|             user.attributes[USER_ATTRIBUTE_SA] = True | ||||
|             user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True | ||||
|             user.set_unusable_password() | ||||
|             user.save() | ||||
|         else: | ||||
| @ -341,29 +334,9 @@ class Outpost(models.Model): | ||||
|         # the ones the user needs | ||||
|         with transaction.atomic(): | ||||
|             UserObjectPermission.objects.filter(user=user).delete() | ||||
|             user.user_permissions.clear() | ||||
|             for model_or_perm in self.get_required_objects(): | ||||
|                 if isinstance(model_or_perm, models.Model): | ||||
|                     model_or_perm: models.Model | ||||
|                     code_name = ( | ||||
|                         f"{model_or_perm._meta.app_label}." | ||||
|                         f"view_{model_or_perm._meta.model_name}" | ||||
|                     ) | ||||
|                     assign_perm(code_name, user, model_or_perm) | ||||
|                 else: | ||||
|                     app_label, perm = model_or_perm.split(".") | ||||
|                     permission = Permission.objects.filter( | ||||
|                         codename=perm, | ||||
|                         content_type__app_label=app_label, | ||||
|                     ) | ||||
|                     if not permission.exists(): | ||||
|                         LOGGER.warning("permission doesn't exist", perm=model_or_perm) | ||||
|                         continue | ||||
|                     user.user_permissions.add(permission.first()) | ||||
|         LOGGER.debug( | ||||
|             "Updated service account's permissions", | ||||
|             perms=UserObjectPermission.objects.filter(user=user), | ||||
|         ) | ||||
|             for model in self.get_required_objects(): | ||||
|                 code_name = f"{model._meta.app_label}.view_{model._meta.model_name}" | ||||
|                 assign_perm(code_name, user, model) | ||||
|         return user | ||||
|  | ||||
|     @property | ||||
| @ -386,9 +359,9 @@ class Outpost(models.Model): | ||||
|             managed=f"goauthentik.io/outpost/{self.token_identifier}", | ||||
|         ) | ||||
|  | ||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||
|     def get_required_objects(self) -> Iterable[models.Model]: | ||||
|         """Get an iterator of all objects the user needs read access to""" | ||||
|         objects: list[Union[models.Model, str]] = [self] | ||||
|         objects = [self] | ||||
|         for provider in ( | ||||
|             Provider.objects.filter(outpost=self).select_related().select_subclasses() | ||||
|         ): | ||||
|  | ||||
| @ -1,16 +1,15 @@ | ||||
| """authentik outpost signals""" | ||||
| from django.conf import settings | ||||
| from django.db.models import Model | ||||
| from django.db.models.signals import post_save, pre_delete, pre_save | ||||
| from django.db.models.signals import post_save, pre_delete | ||||
| from django.dispatch import receiver | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Provider | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.outposts.controllers.base import ControllerException | ||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||
| from authentik.outposts.tasks import outpost_controller_down, outpost_post_save | ||||
| from authentik.outposts.tasks import outpost_post_save, outpost_pre_delete | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| UPDATE_TRIGGERING_MODELS = ( | ||||
| @ -21,27 +20,6 @@ UPDATE_TRIGGERING_MODELS = ( | ||||
| ) | ||||
|  | ||||
|  | ||||
| @receiver(pre_save, sender=Outpost) | ||||
| # pylint: disable=unused-argument | ||||
| def pre_save_outpost(sender, instance: Outpost, **_): | ||||
|     """Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes, | ||||
|     we call down and then wait for the up after save""" | ||||
|     old_instances = Outpost.objects.filter(pk=instance.pk) | ||||
|     if not old_instances.exists(): | ||||
|         return | ||||
|     old_instance = old_instances.first() | ||||
|     dirty = False | ||||
|     # Name changes the deployment name, need to recreate | ||||
|     dirty += old_instance.name != instance.name | ||||
|     # namespace requires re-create | ||||
|     dirty += ( | ||||
|         old_instance.config.kubernetes_namespace != instance.config.kubernetes_namespace | ||||
|     ) | ||||
|     if bool(dirty): | ||||
|         LOGGER.info("Outpost needs re-deployment due to changes", instance=instance) | ||||
|         outpost_controller_down_wrapper(old_instance) | ||||
|  | ||||
|  | ||||
| @receiver(post_save) | ||||
| # pylint: disable=unused-argument | ||||
| def post_save_update(sender, instance: Model, **_): | ||||
| @ -63,23 +41,15 @@ def post_save_update(sender, instance: Model, **_): | ||||
| def pre_delete_cleanup(sender, instance: Outpost, **_): | ||||
|     """Ensure that Outpost's user is deleted (which will delete the token through cascade)""" | ||||
|     instance.user.delete() | ||||
|     outpost_controller_down_wrapper(instance) | ||||
|  | ||||
|  | ||||
| def outpost_controller_down_wrapper(instance: Outpost): | ||||
|     """To ensure that deployment is cleaned up *consistently* we call the controller, and wait | ||||
|     for it to finish. We don't want to call it in this thread, as we don't have the Outpost | ||||
|     Service connection here""" | ||||
|     # To ensure that deployment is cleaned up *consistently* we call the controller, and wait | ||||
|     # for it to finish. We don't want to call it in this thread, as we don't have the Outpost | ||||
|     # Service connection here | ||||
|     try: | ||||
|         outpost_controller_down.delay(instance.pk.hex).get() | ||||
|     except RuntimeError:  # pragma: no cover | ||||
|         outpost_pre_delete.delay(instance.pk.hex).get() | ||||
|     except RuntimeError: | ||||
|         # In e2e/integration tests, this might run inside a thread/process and | ||||
|         # trigger the celery `Never call result.get() within a task` detection | ||||
|         if settings.TEST: | ||||
|             pass | ||||
|         else: | ||||
|             raise | ||||
|     except ControllerException as exc: | ||||
|         LOGGER.warning( | ||||
|             "failed to cleanup outpost deployment", exc=exc, instance=instance | ||||
|         ) | ||||
|  | ||||
| @ -3,7 +3,7 @@ from os import R_OK, access | ||||
| from os.path import expanduser | ||||
| from pathlib import Path | ||||
| from socket import gethostname | ||||
| from typing import Any, Optional | ||||
| from typing import Any | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| import yaml | ||||
| @ -19,7 +19,7 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||
| from authentik.lib.utils.reflection import path_to_class | ||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | ||||
| from authentik.outposts.controllers.base import ControllerException | ||||
| from authentik.outposts.models import ( | ||||
|     DockerServiceConnection, | ||||
|     KubernetesServiceConnection, | ||||
| @ -29,8 +29,6 @@ from authentik.outposts.models import ( | ||||
|     OutpostState, | ||||
|     OutpostType, | ||||
| ) | ||||
| from authentik.providers.ldap.controllers.docker import LDAPDockerController | ||||
| from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController | ||||
| from authentik.providers.proxy.controllers.docker import ProxyDockerController | ||||
| from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController | ||||
| from authentik.root.celery import CELERY_APP | ||||
| @ -38,24 +36,6 @@ from authentik.root.celery import CELERY_APP | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| def controller_for_outpost(outpost: Outpost) -> Optional[BaseController]: | ||||
|     """Get a controller for the outpost, when a service connection is defined""" | ||||
|     if not outpost.service_connection: | ||||
|         return None | ||||
|     service_connection = outpost.service_connection | ||||
|     if outpost.type == OutpostType.PROXY: | ||||
|         if isinstance(service_connection, DockerServiceConnection): | ||||
|             return ProxyDockerController(outpost, service_connection) | ||||
|         if isinstance(service_connection, KubernetesServiceConnection): | ||||
|             return ProxyKubernetesController(outpost, service_connection) | ||||
|     if outpost.type == OutpostType.LDAP: | ||||
|         if isinstance(service_connection, DockerServiceConnection): | ||||
|             return LDAPDockerController(outpost, service_connection) | ||||
|         if isinstance(service_connection, KubernetesServiceConnection): | ||||
|             return LDAPKubernetesController(outpost, service_connection) | ||||
|     return None | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| def outpost_controller_all(): | ||||
|     """Launch Controller for all Outposts which support it""" | ||||
| @ -96,10 +76,16 @@ def outpost_controller(self: MonitoredTask, outpost_pk: str): | ||||
|     outpost: Outpost = Outpost.objects.get(pk=outpost_pk) | ||||
|     self.set_uid(slugify(outpost.name)) | ||||
|     try: | ||||
|         controller = controller_for_outpost(outpost) | ||||
|         if not controller: | ||||
|         if not outpost.service_connection: | ||||
|             return | ||||
|         logs = controller.up_with_logs() | ||||
|         if outpost.type == OutpostType.PROXY: | ||||
|             service_connection = outpost.service_connection | ||||
|             if isinstance(service_connection, DockerServiceConnection): | ||||
|                 logs = ProxyDockerController(outpost, service_connection).up_with_logs() | ||||
|             if isinstance(service_connection, KubernetesServiceConnection): | ||||
|                 logs = ProxyKubernetesController( | ||||
|                     outpost, service_connection | ||||
|                 ).up_with_logs() | ||||
|         LOGGER.debug("---------------Outpost Controller logs starting----------------") | ||||
|         for log in logs: | ||||
|             LOGGER.debug(log) | ||||
| @ -111,13 +97,15 @@ def outpost_controller(self: MonitoredTask, outpost_pk: str): | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| def outpost_controller_down(outpost_pk: str): | ||||
| def outpost_pre_delete(outpost_pk: str): | ||||
|     """Delete outpost objects before deleting the DB Object""" | ||||
|     outpost = Outpost.objects.get(pk=outpost_pk) | ||||
|     controller = controller_for_outpost(outpost) | ||||
|     if not controller: | ||||
|         return | ||||
|     controller.down() | ||||
|     if outpost.type == OutpostType.PROXY: | ||||
|         service_connection = outpost.service_connection | ||||
|         if isinstance(service_connection, DockerServiceConnection): | ||||
|             ProxyDockerController(outpost, service_connection).down() | ||||
|         if isinstance(service_connection, KubernetesServiceConnection): | ||||
|             ProxyKubernetesController(outpost, service_connection).down() | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||
|  | ||||
| @ -1,84 +0,0 @@ | ||||
| # Generated by Django 3.2 on 2021-05-02 17:06 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies_event_matcher", "0012_auto_20210323_1339"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="eventmatcherpolicy", | ||||
|             name="app", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("authentik.admin", "authentik Admin"), | ||||
|                     ("authentik.api", "authentik API"), | ||||
|                     ("authentik.events", "authentik Events"), | ||||
|                     ("authentik.crypto", "authentik Crypto"), | ||||
|                     ("authentik.flows", "authentik Flows"), | ||||
|                     ("authentik.outposts", "authentik Outpost"), | ||||
|                     ("authentik.lib", "authentik lib"), | ||||
|                     ("authentik.policies", "authentik Policies"), | ||||
|                     ("authentik.policies.dummy", "authentik Policies.Dummy"), | ||||
|                     ( | ||||
|                         "authentik.policies.event_matcher", | ||||
|                         "authentik Policies.Event Matcher", | ||||
|                     ), | ||||
|                     ("authentik.policies.expiry", "authentik Policies.Expiry"), | ||||
|                     ("authentik.policies.expression", "authentik Policies.Expression"), | ||||
|                     ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), | ||||
|                     ("authentik.policies.password", "authentik Policies.Password"), | ||||
|                     ("authentik.policies.reputation", "authentik Policies.Reputation"), | ||||
|                     ("authentik.providers.proxy", "authentik Providers.Proxy"), | ||||
|                     ("authentik.providers.oauth2", "authentik Providers.OAuth2"), | ||||
|                     ("authentik.providers.saml", "authentik Providers.SAML"), | ||||
|                     ("authentik.recovery", "authentik Recovery"), | ||||
|                     ("authentik.sources.ldap", "authentik Sources.LDAP"), | ||||
|                     ("authentik.sources.oauth", "authentik Sources.OAuth"), | ||||
|                     ("authentik.sources.plex", "authentik Sources.Plex"), | ||||
|                     ("authentik.sources.saml", "authentik Sources.SAML"), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_static", | ||||
|                         "authentik Stages.Authenticator.Static", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_totp", | ||||
|                         "authentik Stages.Authenticator.TOTP", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_validate", | ||||
|                         "authentik Stages.Authenticator.Validate", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_webauthn", | ||||
|                         "authentik Stages.Authenticator.WebAuthn", | ||||
|                     ), | ||||
|                     ("authentik.stages.captcha", "authentik Stages.Captcha"), | ||||
|                     ("authentik.stages.consent", "authentik Stages.Consent"), | ||||
|                     ("authentik.stages.deny", "authentik Stages.Deny"), | ||||
|                     ("authentik.stages.dummy", "authentik Stages.Dummy"), | ||||
|                     ("authentik.stages.email", "authentik Stages.Email"), | ||||
|                     ( | ||||
|                         "authentik.stages.identification", | ||||
|                         "authentik Stages.Identification", | ||||
|                     ), | ||||
|                     ("authentik.stages.invitation", "authentik Stages.User Invitation"), | ||||
|                     ("authentik.stages.password", "authentik Stages.Password"), | ||||
|                     ("authentik.stages.prompt", "authentik Stages.Prompt"), | ||||
|                     ("authentik.stages.user_delete", "authentik Stages.User Delete"), | ||||
|                     ("authentik.stages.user_login", "authentik Stages.User Login"), | ||||
|                     ("authentik.stages.user_logout", "authentik Stages.User Logout"), | ||||
|                     ("authentik.stages.user_write", "authentik Stages.User Write"), | ||||
|                     ("authentik.core", "authentik Core"), | ||||
|                     ("authentik.managed", "authentik Managed"), | ||||
|                 ], | ||||
|                 default="", | ||||
|                 help_text="Match events created by selected application. When left empty, all applications are matched.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,85 +0,0 @@ | ||||
| # Generated by Django 3.2.1 on 2021-05-05 17:17 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies_event_matcher", "0013_alter_eventmatcherpolicy_app"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="eventmatcherpolicy", | ||||
|             name="app", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("authentik.admin", "authentik Admin"), | ||||
|                     ("authentik.api", "authentik API"), | ||||
|                     ("authentik.events", "authentik Events"), | ||||
|                     ("authentik.crypto", "authentik Crypto"), | ||||
|                     ("authentik.flows", "authentik Flows"), | ||||
|                     ("authentik.outposts", "authentik Outpost"), | ||||
|                     ("authentik.lib", "authentik lib"), | ||||
|                     ("authentik.policies", "authentik Policies"), | ||||
|                     ("authentik.policies.dummy", "authentik Policies.Dummy"), | ||||
|                     ( | ||||
|                         "authentik.policies.event_matcher", | ||||
|                         "authentik Policies.Event Matcher", | ||||
|                     ), | ||||
|                     ("authentik.policies.expiry", "authentik Policies.Expiry"), | ||||
|                     ("authentik.policies.expression", "authentik Policies.Expression"), | ||||
|                     ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), | ||||
|                     ("authentik.policies.password", "authentik Policies.Password"), | ||||
|                     ("authentik.policies.reputation", "authentik Policies.Reputation"), | ||||
|                     ("authentik.providers.proxy", "authentik Providers.Proxy"), | ||||
|                     ("authentik.providers.ldap", "authentik Providers.LDAP"), | ||||
|                     ("authentik.providers.oauth2", "authentik Providers.OAuth2"), | ||||
|                     ("authentik.providers.saml", "authentik Providers.SAML"), | ||||
|                     ("authentik.recovery", "authentik Recovery"), | ||||
|                     ("authentik.sources.ldap", "authentik Sources.LDAP"), | ||||
|                     ("authentik.sources.oauth", "authentik Sources.OAuth"), | ||||
|                     ("authentik.sources.plex", "authentik Sources.Plex"), | ||||
|                     ("authentik.sources.saml", "authentik Sources.SAML"), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_static", | ||||
|                         "authentik Stages.Authenticator.Static", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_totp", | ||||
|                         "authentik Stages.Authenticator.TOTP", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_validate", | ||||
|                         "authentik Stages.Authenticator.Validate", | ||||
|                     ), | ||||
|                     ( | ||||
|                         "authentik.stages.authenticator_webauthn", | ||||
|                         "authentik Stages.Authenticator.WebAuthn", | ||||
|                     ), | ||||
|                     ("authentik.stages.captcha", "authentik Stages.Captcha"), | ||||
|                     ("authentik.stages.consent", "authentik Stages.Consent"), | ||||
|                     ("authentik.stages.deny", "authentik Stages.Deny"), | ||||
|                     ("authentik.stages.dummy", "authentik Stages.Dummy"), | ||||
|                     ("authentik.stages.email", "authentik Stages.Email"), | ||||
|                     ( | ||||
|                         "authentik.stages.identification", | ||||
|                         "authentik Stages.Identification", | ||||
|                     ), | ||||
|                     ("authentik.stages.invitation", "authentik Stages.User Invitation"), | ||||
|                     ("authentik.stages.password", "authentik Stages.Password"), | ||||
|                     ("authentik.stages.prompt", "authentik Stages.Prompt"), | ||||
|                     ("authentik.stages.user_delete", "authentik Stages.User Delete"), | ||||
|                     ("authentik.stages.user_login", "authentik Stages.User Login"), | ||||
|                     ("authentik.stages.user_logout", "authentik Stages.User Logout"), | ||||
|                     ("authentik.stages.user_write", "authentik Stages.User Write"), | ||||
|                     ("authentik.core", "authentik Core"), | ||||
|                     ("authentik.managed", "authentik Managed"), | ||||
|                 ], | ||||
|                 default="", | ||||
|                 help_text="Match events created by selected application. When left empty, all applications are matched.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -4,7 +4,7 @@ | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block title %} | ||||
| {% trans 'Permission denied' %} - {{ config.authentik.branding.title }} | ||||
| {% trans 'Permission denied - authentik' %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block card_title %} | ||||
|  | ||||
| @ -1,54 +0,0 @@ | ||||
| """LDAPProvider API Views""" | ||||
| from rest_framework.fields import CharField | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | ||||
|  | ||||
| from authentik.core.api.providers import ProviderSerializer | ||||
| from authentik.providers.ldap.models import LDAPProvider | ||||
|  | ||||
|  | ||||
| class LDAPProviderSerializer(ProviderSerializer): | ||||
|     """LDAPProvider Serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = LDAPProvider | ||||
|         fields = ProviderSerializer.Meta.fields + [ | ||||
|             "base_dn", | ||||
|             "search_group", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class LDAPProviderViewSet(ModelViewSet): | ||||
|     """LDAPProvider Viewset""" | ||||
|  | ||||
|     queryset = LDAPProvider.objects.all() | ||||
|     serializer_class = LDAPProviderSerializer | ||||
|     ordering = ["name"] | ||||
|  | ||||
|  | ||||
| class LDAPOutpostConfigSerializer(ModelSerializer): | ||||
|     """LDAPProvider Serializer""" | ||||
|  | ||||
|     application_slug = CharField(source="application.slug") | ||||
|     bind_flow_slug = CharField(source="authorization_flow.slug") | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         model = LDAPProvider | ||||
|         fields = [ | ||||
|             "pk", | ||||
|             "name", | ||||
|             "base_dn", | ||||
|             "bind_flow_slug", | ||||
|             "application_slug", | ||||
|             "search_group", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class LDAPOutpostConfigViewSet(ReadOnlyModelViewSet): | ||||
|     """LDAPProvider Viewset""" | ||||
|  | ||||
|     queryset = LDAPProvider.objects.filter(application__isnull=False) | ||||
|     serializer_class = LDAPOutpostConfigSerializer | ||||
|     ordering = ["name"] | ||||
| @ -1,10 +0,0 @@ | ||||
| """authentik ldap provider app config""" | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class AuthentikProviderLDAPConfig(AppConfig): | ||||
|     """authentik ldap provider app config""" | ||||
|  | ||||
|     name = "authentik.providers.ldap" | ||||
|     label = "authentik_providers_ldap" | ||||
|     verbose_name = "authentik Providers.LDAP" | ||||
| @ -1,14 +0,0 @@ | ||||
| """LDAP Provider Docker Contoller""" | ||||
| from authentik.outposts.controllers.base import DeploymentPort | ||||
| from authentik.outposts.controllers.docker import DockerController | ||||
| from authentik.outposts.models import DockerServiceConnection, Outpost | ||||
|  | ||||
|  | ||||
| class LDAPDockerController(DockerController): | ||||
|     """LDAP Provider Docker Contoller""" | ||||
|  | ||||
|     def __init__(self, outpost: Outpost, connection: DockerServiceConnection): | ||||
|         super().__init__(outpost, connection) | ||||
|         self.deployment_ports = [ | ||||
|             DeploymentPort(389, "ldap", "tcp", 3389), | ||||
|         ] | ||||
| @ -1,14 +0,0 @@ | ||||
| """LDAP Provider Kubernetes Contoller""" | ||||
| from authentik.outposts.controllers.base import DeploymentPort | ||||
| from authentik.outposts.controllers.kubernetes import KubernetesController | ||||
| from authentik.outposts.models import KubernetesServiceConnection, Outpost | ||||
|  | ||||
|  | ||||
| class LDAPKubernetesController(KubernetesController): | ||||
|     """LDAP Provider Kubernetes Contoller""" | ||||
|  | ||||
|     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): | ||||
|         super().__init__(outpost, connection) | ||||
|         self.deployment_ports = [ | ||||
|             DeploymentPort(389, "ldap", "tcp", 3389), | ||||
|         ] | ||||
| @ -1,44 +0,0 @@ | ||||
| # Generated by Django 3.2 on 2021-04-26 12:45 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0019_source_managed"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="LDAPProvider", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "provider_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_core.provider", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "base_dn", | ||||
|                     models.TextField( | ||||
|                         default="DC=ldap,DC=goauthentik,DC=io", | ||||
|                         help_text="DN under which objects are accessible.", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "LDAP Provider", | ||||
|                 "verbose_name_plural": "LDAP Providers", | ||||
|             }, | ||||
|             bases=("authentik_core.provider", models.Model), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,26 +0,0 @@ | ||||
| # Generated by Django 3.2 on 2021-04-26 19:57 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0019_source_managed"), | ||||
|         ("authentik_providers_ldap", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="ldapprovider", | ||||
|             name="search_group", | ||||
|             field=models.ForeignKey( | ||||
|                 default=None, | ||||
|                 help_text="Users in this group can do search queries. If not set, every user can execute search queries.", | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_DEFAULT, | ||||
|                 to="authentik_core.group", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,55 +0,0 @@ | ||||
| """LDAP Provider""" | ||||
| from typing import Iterable, Optional, Type, Union | ||||
|  | ||||
| from django.db import models | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.serializers import Serializer | ||||
|  | ||||
| from authentik.core.models import Group, Provider | ||||
| from authentik.outposts.models import OutpostModel | ||||
|  | ||||
|  | ||||
| class LDAPProvider(OutpostModel, Provider): | ||||
|     """Allow applications to authenticate against authentik's users using LDAP.""" | ||||
|  | ||||
|     base_dn = models.TextField( | ||||
|         default="DC=ldap,DC=goauthentik,DC=io", | ||||
|         help_text=_("DN under which objects are accessible."), | ||||
|     ) | ||||
|  | ||||
|     search_group = models.ForeignKey( | ||||
|         Group, | ||||
|         null=True, | ||||
|         default=None, | ||||
|         on_delete=models.SET_DEFAULT, | ||||
|         help_text=_( | ||||
|             "Users in this group can do search queries. " | ||||
|             "If not set, every user can execute search queries." | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def launch_url(self) -> Optional[str]: | ||||
|         """LDAP never has a launch URL""" | ||||
|         return None | ||||
|  | ||||
|     @property | ||||
|     def component(self) -> str: | ||||
|         return "ak-provider-ldap-form" | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> Type[Serializer]: | ||||
|         from authentik.providers.ldap.api import LDAPProviderSerializer | ||||
|  | ||||
|         return LDAPProviderSerializer | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"LDAP Provider {self.name}" | ||||
|  | ||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||
|         return [self, "authentik_core.view_user", "authentik_core.view_group"] | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         verbose_name = _("LDAP Provider") | ||||
|         verbose_name_plural = _("LDAP Providers") | ||||
| @ -38,7 +38,6 @@ class OAuth2ProviderSerializer(ProviderSerializer): | ||||
|             "client_type", | ||||
|             "client_id", | ||||
|             "client_secret", | ||||
|             "access_code_validity", | ||||
|             "token_validity", | ||||
|             "include_claims_in_id_token", | ||||
|             "jwt_alg", | ||||
|  | ||||
| @ -1,9 +1,6 @@ | ||||
| """OAuth2Provider API Views""" | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from guardian.utils import get_anonymous_user | ||||
| from rest_framework import mixins | ||||
| from rest_framework.fields import CharField, ListField | ||||
| from rest_framework.filters import OrderingFilter, SearchFilter | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| @ -39,17 +36,13 @@ class AuthorizationCodeViewSet( | ||||
|     serializer_class = ExpiringBaseGrantModelSerializer | ||||
|     filterset_fields = ["user", "provider"] | ||||
|     ordering = ["provider", "expires"] | ||||
|     filter_backends = [ | ||||
|         DjangoFilterBackend, | ||||
|         OrderingFilter, | ||||
|         SearchFilter, | ||||
|     ] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         user = self.request.user if self.request else get_anonymous_user() | ||||
|         if user.is_superuser: | ||||
|         if not self.request: | ||||
|             return super().get_queryset() | ||||
|         return super().get_queryset().filter(user=user.pk) | ||||
|         if self.request.user.is_superuser: | ||||
|             return super().get_queryset() | ||||
|         return super().get_queryset().filter(user=self.request.user) | ||||
|  | ||||
|  | ||||
| class RefreshTokenViewSet( | ||||
| @ -64,14 +57,10 @@ class RefreshTokenViewSet( | ||||
|     serializer_class = ExpiringBaseGrantModelSerializer | ||||
|     filterset_fields = ["user", "provider"] | ||||
|     ordering = ["provider", "expires"] | ||||
|     filter_backends = [ | ||||
|         DjangoFilterBackend, | ||||
|         OrderingFilter, | ||||
|         SearchFilter, | ||||
|     ] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         user = self.request.user if self.request else get_anonymous_user() | ||||
|         if user.is_superuser: | ||||
|         if not self.request: | ||||
|             return super().get_queryset() | ||||
|         return super().get_queryset().filter(user=user.pk) | ||||
|         if self.request.user.is_superuser: | ||||
|             return super().get_queryset() | ||||
|         return super().get_queryset().filter(user=self.request.user) | ||||
|  | ||||
| @ -1,11 +1,11 @@ | ||||
| """authentik oauth provider app config""" | ||||
| """authentik auth oauth provider app config""" | ||||
| from importlib import import_module | ||||
|  | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class AuthentikProviderOAuth2Config(AppConfig): | ||||
|     """authentik oauth provider app config""" | ||||
|     """authentik auth oauth provider app config""" | ||||
|  | ||||
|     name = "authentik.providers.oauth2" | ||||
|     label = "authentik_providers_oauth2" | ||||
|  | ||||
| @ -1,24 +0,0 @@ | ||||
| # Generated by Django 3.2 on 2021-04-28 18:17 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
| import authentik.lib.utils.time | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_providers_oauth2", "0011_managed"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="oauth2provider", | ||||
|             name="access_code_validity", | ||||
|             field=models.TextField( | ||||
|                 default="minutes=1", | ||||
|                 help_text="Access codes not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", | ||||
|                 validators=[authentik.lib.utils.time.timedelta_string_validator], | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -6,18 +6,18 @@ import time | ||||
| from dataclasses import asdict, dataclass, field | ||||
| from datetime import datetime | ||||
| from hashlib import sha256 | ||||
| from typing import Any, Optional, Type, Union | ||||
| from typing import Any, Optional, Type | ||||
| from urllib.parse import urlparse | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | ||||
| from dacite import from_dict | ||||
| from django.conf import settings | ||||
| from django.db import models | ||||
| from django.http import HttpRequest | ||||
| from django.utils import dateformat, timezone | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from jwt import encode | ||||
| from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key | ||||
| from jwkest.jws import JWS | ||||
| from rest_framework.serializers import Serializer | ||||
|  | ||||
| from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User | ||||
| @ -175,16 +175,6 @@ class OAuth2Provider(Provider): | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     access_code_validity = models.TextField( | ||||
|         default="minutes=1", | ||||
|         validators=[timedelta_string_validator], | ||||
|         help_text=_( | ||||
|             ( | ||||
|                 "Access codes not valid on or after current time + this value " | ||||
|                 "(Format: hours=1;minutes=2;seconds=3)." | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     token_validity = models.TextField( | ||||
|         default="minutes=10", | ||||
|         validators=[timedelta_string_validator], | ||||
| @ -239,7 +229,7 @@ class OAuth2Provider(Provider): | ||||
|         token.access_token = token.create_access_token(user, request) | ||||
|         return token | ||||
|  | ||||
|     def get_jwt_keys(self) -> Union[RSAPrivateKey, str]: | ||||
|     def get_jwt_keys(self) -> list[Key]: | ||||
|         """ | ||||
|         Takes a provider and returns the set of keys associated with it. | ||||
|         Returns a list of keys. | ||||
| @ -256,10 +246,17 @@ class OAuth2Provider(Provider): | ||||
|                 self.jwt_alg = JWTAlgorithms.HS256 | ||||
|                 self.save() | ||||
|             else: | ||||
|                 return self.rsa_key.private_key | ||||
|                 # Because the JWT Library uses python cryptodome, | ||||
|                 # we can't directly pass the RSAPublicKey | ||||
|                 # object, but have to load it ourselves | ||||
|                 key = import_rsa_key(self.rsa_key.key_data) | ||||
|                 keys = [RSAKey(key=key, kid=self.rsa_key.kid)] | ||||
|                 if not keys: | ||||
|                     raise Exception("You must add at least one RSA Key.") | ||||
|                 return keys | ||||
|  | ||||
|         if self.jwt_alg == JWTAlgorithms.HS256: | ||||
|             return self.client_secret | ||||
|             return [SYMKey(key=self.client_secret, alg=self.jwt_alg)] | ||||
|  | ||||
|         raise Exception("Unsupported key algorithm.") | ||||
|  | ||||
| @ -300,11 +297,11 @@ class OAuth2Provider(Provider): | ||||
|  | ||||
|     def encode(self, payload: dict[str, Any]) -> str: | ||||
|         """Represent the ID Token as a JSON Web Token (JWT).""" | ||||
|         key = self.get_jwt_keys() | ||||
|         keys = self.get_jwt_keys() | ||||
|         # If the provider does not have an RSA Key assigned, it was switched to Symmetric | ||||
|         self.refresh_from_db() | ||||
|         # pyright: reportGeneralTypeIssues=false | ||||
|         return encode(payload, key, algorithm=self.jwt_alg) | ||||
|         jws = JWS(payload, alg=self.jwt_alg) | ||||
|         return jws.sign_compact(keys) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|  | ||||
| @ -14,7 +14,7 @@ | ||||
| {% endblock %} | ||||
|  | ||||
| {% block title %} | ||||
| {% trans 'End session' %} - {{ config.authentik.branding.title }} | ||||
| {% trans 'End session' %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block card_title %} | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| """Test authorize view""" | ||||
| from django.test import RequestFactory | ||||
| from django.test import RequestFactory, TestCase | ||||
| from django.urls import reverse | ||||
| from django.utils.encoding import force_str | ||||
| 
 | ||||
| @ -11,21 +11,17 @@ from authentik.providers.oauth2.errors import ( | ||||
|     ClientIdError, | ||||
|     RedirectUriError, | ||||
| ) | ||||
| from authentik.providers.oauth2.generators import ( | ||||
|     generate_client_id, | ||||
|     generate_client_secret, | ||||
| ) | ||||
| from authentik.providers.oauth2.generators import generate_client_id | ||||
| from authentik.providers.oauth2.models import ( | ||||
|     AuthorizationCode, | ||||
|     GrantTypes, | ||||
|     OAuth2Provider, | ||||
|     RefreshToken, | ||||
| ) | ||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||
| from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams | ||||
| 
 | ||||
| 
 | ||||
| class TestAuthorize(OAuthTestCase): | ||||
| class TestViewsAuthorize(TestCase): | ||||
|     """Test authorize view""" | ||||
| 
 | ||||
|     def setUp(self) -> None: | ||||
| @ -204,7 +200,6 @@ class TestAuthorize(OAuthTestCase): | ||||
|         provider = OAuth2Provider.objects.create( | ||||
|             name="test", | ||||
|             client_id="test", | ||||
|             client_secret=generate_client_secret(), | ||||
|             authorization_flow=flow, | ||||
|             redirect_uris="http://localhost", | ||||
|         ) | ||||
| @ -238,4 +233,3 @@ class TestAuthorize(OAuthTestCase): | ||||
|                 ), | ||||
|             }, | ||||
|         ) | ||||
|         self.validate_jwt(token, provider) | ||||
| @ -1,11 +1,11 @@ | ||||
| """Test token view""" | ||||
| from base64 import b64encode | ||||
| 
 | ||||
| from django.test import RequestFactory | ||||
| from django.test import RequestFactory, TestCase | ||||
| from django.urls import reverse | ||||
| from django.utils.encoding import force_str | ||||
| 
 | ||||
| from authentik.core.models import Application, User | ||||
| from authentik.core.models import User | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.providers.oauth2.constants import ( | ||||
|     GRANT_TYPE_AUTHORIZATION_CODE, | ||||
| @ -20,17 +20,15 @@ from authentik.providers.oauth2.models import ( | ||||
|     OAuth2Provider, | ||||
|     RefreshToken, | ||||
| ) | ||||
| from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||
| from authentik.providers.oauth2.views.token import TokenParams | ||||
| 
 | ||||
| 
 | ||||
| class TestToken(OAuthTestCase): | ||||
| class TestViewsToken(TestCase): | ||||
|     """Test token view""" | ||||
| 
 | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.factory = RequestFactory() | ||||
|         self.app = Application.objects.create(name="test", slug="test") | ||||
| 
 | ||||
|     def test_request_auth_code(self): | ||||
|         """test request param""" | ||||
| @ -99,15 +97,12 @@ class TestToken(OAuthTestCase): | ||||
|             authorization_flow=Flow.objects.first(), | ||||
|             redirect_uris="http://local.invalid", | ||||
|         ) | ||||
|         # 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") | ||||
|         code = AuthorizationCode.objects.create( | ||||
|             code="foobar", provider=provider, user=user, is_open_id=True | ||||
|             code="foobar", provider=provider, user=user | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -131,7 +126,6 @@ class TestToken(OAuthTestCase): | ||||
|                 ), | ||||
|             }, | ||||
|         ) | ||||
|         self.validate_jwt(new_token, provider) | ||||
| 
 | ||||
|     def test_refresh_token_view(self): | ||||
|         """test request param""" | ||||
| @ -142,9 +136,6 @@ class TestToken(OAuthTestCase): | ||||
|             authorization_flow=Flow.objects.first(), | ||||
|             redirect_uris="http://local.invalid", | ||||
|         ) | ||||
|         # 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() | ||||
| @ -183,7 +174,6 @@ class TestToken(OAuthTestCase): | ||||
|                 ), | ||||
|             }, | ||||
|         ) | ||||
|         self.validate_jwt(new_token, provider) | ||||
| 
 | ||||
|     def test_refresh_token_view_invalid_origin(self): | ||||
|         """test request param""" | ||||
| @ -1,31 +0,0 @@ | ||||
| """OAuth test helpers""" | ||||
| from django.test import TestCase | ||||
| from jwt import decode | ||||
|  | ||||
| from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken | ||||
|  | ||||
|  | ||||
| class OAuthTestCase(TestCase): | ||||
|     """OAuth test helpers""" | ||||
|  | ||||
|     required_jwt_keys = [ | ||||
|         "exp", | ||||
|         "iat", | ||||
|         "auth_time", | ||||
|         "acr", | ||||
|         "sub", | ||||
|         "iss", | ||||
|     ] | ||||
|  | ||||
|     def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider): | ||||
|         """Validate that all required fields are set""" | ||||
|         jwt = decode( | ||||
|             token.access_token, | ||||
|             provider.client_secret, | ||||
|             algorithms=[provider.jwt_alg], | ||||
|             audience=provider.client_id, | ||||
|         ) | ||||
|         id_token = token.id_token.to_dict() | ||||
|         for key in self.required_jwt_keys: | ||||
|             self.assertIsNotNone(jwt[key], f"Key {key} is missing in access_token") | ||||
|             self.assertIsNotNone(id_token[key], f"Key {key} is missing in id_token") | ||||
| @ -219,7 +219,7 @@ class OAuthAuthorizationParams: | ||||
|             code.code_challenge_method = self.code_challenge_method | ||||
|  | ||||
|         code.expires_at = timezone.now() + timedelta_from_string( | ||||
|             self.provider.access_code_validity | ||||
|             self.provider.token_validity | ||||
|         ) | ||||
|         code.scope = self.scope | ||||
|         code.nonce = self.nonce | ||||
| @ -291,7 +291,7 @@ class OAuthFulfillmentStage(StageView): | ||||
|                 GrantTypes.HYBRID, | ||||
|             ]: | ||||
|                 code = self.params.create_code(self.request) | ||||
|                 code.save(force_insert=True) | ||||
|                 code.save() | ||||
|  | ||||
|             if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE: | ||||
|                 query_params["code"] = code.code | ||||
|  | ||||
| @ -104,6 +104,7 @@ class TokenIntrospectionView(View): | ||||
|     token: RefreshToken | ||||
|     params: TokenIntrospectionParams | ||||
|     provider: OAuth2Provider | ||||
|     id_token: IDToken | ||||
|  | ||||
|     def post(self, request: HttpRequest) -> HttpResponse: | ||||
|         """Introspection handler""" | ||||
|  | ||||
| @ -1,23 +1,14 @@ | ||||
| """authentik OAuth2 JWKS Views""" | ||||
| from base64 import urlsafe_b64encode | ||||
|  | ||||
| from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey | ||||
| from django.http import HttpRequest, HttpResponse, JsonResponse | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.views import View | ||||
| from jwkest import long_to_base64 | ||||
| from jwkest.jwk import import_rsa_key | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider | ||||
|  | ||||
|  | ||||
| def b64_enc(number: int) -> str: | ||||
|     """Convert number to base64-encoded octet-value""" | ||||
|     length = ((number).bit_length() + 7) // 8 | ||||
|     number_bytes = number.to_bytes(length, "big") | ||||
|     final = urlsafe_b64encode(number_bytes).rstrip(b"=") | ||||
|     return final.decode("ascii") | ||||
|  | ||||
|  | ||||
| class JWKSView(View): | ||||
|     """Show RSA Key data for Provider""" | ||||
|  | ||||
| @ -31,16 +22,15 @@ class JWKSView(View): | ||||
|         response_data = {} | ||||
|  | ||||
|         if provider.jwt_alg == JWTAlgorithms.RS256: | ||||
|             public_key: RSAPublicKey = provider.rsa_key.private_key.public_key() | ||||
|             public_numbers = public_key.public_numbers() | ||||
|             public_key = import_rsa_key(provider.rsa_key.key_data).publickey() | ||||
|             response_data["keys"] = [ | ||||
|                 { | ||||
|                     "kty": "RSA", | ||||
|                     "alg": "RS256", | ||||
|                     "use": "sig", | ||||
|                     "kid": provider.rsa_key.kid, | ||||
|                     "n": b64_enc(public_numbers.n), | ||||
|                     "e": b64_enc(public_numbers.e), | ||||
|                     "n": long_to_base64(public_key.n), | ||||
|                     "e": long_to_base64(public_key.e), | ||||
|                 } | ||||
|             ] | ||||
|  | ||||
|  | ||||
| @ -16,7 +16,6 @@ from authentik.providers.oauth2.constants import ( | ||||
| from authentik.providers.oauth2.errors import TokenError, UserAuthError | ||||
| from authentik.providers.oauth2.models import ( | ||||
|     AuthorizationCode, | ||||
|     ClientTypes, | ||||
|     OAuth2Provider, | ||||
|     RefreshToken, | ||||
| ) | ||||
| @ -76,7 +75,7 @@ class TokenParams: | ||||
|             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 == "confidential": | ||||
|             if self.provider.client_secret != self.client_secret: | ||||
|                 LOGGER.warning( | ||||
|                     "Invalid client secret: client does not have secret", | ||||
|  | ||||
| @ -1,8 +1,5 @@ | ||||
| """ProxyProvider API Views""" | ||||
| from typing import Any | ||||
|  | ||||
| from drf_yasg.utils import swagger_serializer_method | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | ||||
| @ -33,17 +30,6 @@ class OpenIDConnectConfigurationSerializer(PassiveSerializer): | ||||
| class ProxyProviderSerializer(ProviderSerializer): | ||||
|     """ProxyProvider Serializer""" | ||||
|  | ||||
|     def validate(self, attrs) -> dict[Any, str]: | ||||
|         """Check that internal_host is set when forward_auth_mode is disabled""" | ||||
|         if ( | ||||
|             not attrs.get("forward_auth_mode", False) | ||||
|             and attrs.get("internal_host", "") == "" | ||||
|         ): | ||||
|             raise ValidationError( | ||||
|                 "Internal host cannot be empty when forward auth is disabled." | ||||
|             ) | ||||
|         return attrs | ||||
|  | ||||
|     def create(self, validated_data): | ||||
|         instance: ProxyProvider = super().create(validated_data) | ||||
|         instance.set_oauth_defaults() | ||||
| @ -66,7 +52,6 @@ class ProxyProviderSerializer(ProviderSerializer): | ||||
|             "basic_auth_enabled", | ||||
|             "basic_auth_password_attribute", | ||||
|             "basic_auth_user_attribute", | ||||
|             "forward_auth_mode", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| @ -101,7 +86,6 @@ class ProxyOutpostConfigSerializer(ModelSerializer): | ||||
|             "basic_auth_enabled", | ||||
|             "basic_auth_password_attribute", | ||||
|             "basic_auth_user_attribute", | ||||
|             "forward_auth_mode", | ||||
|         ] | ||||
|  | ||||
|     @swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer) | ||||
|  | ||||
| @ -17,7 +17,6 @@ from kubernetes.client.models.networking_v1beta1_ingress_rule import ( | ||||
|  | ||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | ||||
| from authentik.outposts.controllers.k8s.base import ( | ||||
|     Disabled, | ||||
|     KubernetesObjectReconciler, | ||||
|     NeedsUpdate, | ||||
| ) | ||||
| @ -34,6 +33,10 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): | ||||
|         super().__init__(controller) | ||||
|         self.api = NetworkingV1beta1Api(controller.client) | ||||
|  | ||||
|     @property | ||||
|     def name(self) -> str: | ||||
|         return f"authentik-outpost-{self.controller.outpost.uuid.hex}" | ||||
|  | ||||
|     def _check_annotations(self, reference: NetworkingV1beta1Ingress): | ||||
|         """Check that all annotations *we* set are correct""" | ||||
|         for key, value in self.get_ingress_annotations().items(): | ||||
| @ -51,8 +54,7 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): | ||||
|         expected_hosts = [] | ||||
|         expected_hosts_tls = [] | ||||
|         for proxy_provider in ProxyProvider.objects.filter( | ||||
|             outpost__in=[self.controller.outpost], | ||||
|             forward_auth_mode=False, | ||||
|             outpost__in=[self.controller.outpost] | ||||
|         ): | ||||
|             proxy_provider: ProxyProvider | ||||
|             external_host_name = urlparse(proxy_provider.external_host) | ||||
| @ -100,46 +102,27 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): | ||||
|         rules = [] | ||||
|         tls_hosts = [] | ||||
|         for proxy_provider in ProxyProvider.objects.filter( | ||||
|             outpost__in=[self.controller.outpost], | ||||
|             outpost__in=[self.controller.outpost] | ||||
|         ): | ||||
|             proxy_provider: ProxyProvider | ||||
|             external_host_name = urlparse(proxy_provider.external_host) | ||||
|             if external_host_name.scheme == "https": | ||||
|                 tls_hosts.append(external_host_name.hostname) | ||||
|             if proxy_provider.forward_auth_mode: | ||||
|                 rule = NetworkingV1beta1IngressRule( | ||||
|                     host=external_host_name.hostname, | ||||
|                     http=NetworkingV1beta1HTTPIngressRuleValue( | ||||
|                         paths=[ | ||||
|                             NetworkingV1beta1HTTPIngressPath( | ||||
|                                 backend=NetworkingV1beta1IngressBackend( | ||||
|                                     service_name=self.name, | ||||
|                                     service_port="http", | ||||
|                                 ), | ||||
|                                 path="/akprox", | ||||
|                             ) | ||||
|                         ] | ||||
|                     ), | ||||
|                 ) | ||||
|             else: | ||||
|                 rule = NetworkingV1beta1IngressRule( | ||||
|                     host=external_host_name.hostname, | ||||
|                     http=NetworkingV1beta1HTTPIngressRuleValue( | ||||
|                         paths=[ | ||||
|                             NetworkingV1beta1HTTPIngressPath( | ||||
|                                 backend=NetworkingV1beta1IngressBackend( | ||||
|                                     service_name=self.name, | ||||
|                                     service_port="http", | ||||
|                                 ), | ||||
|                                 path="/", | ||||
|                             ) | ||||
|                         ] | ||||
|                     ), | ||||
|                 ) | ||||
|             rule = NetworkingV1beta1IngressRule( | ||||
|                 host=external_host_name.hostname, | ||||
|                 http=NetworkingV1beta1HTTPIngressRuleValue( | ||||
|                     paths=[ | ||||
|                         NetworkingV1beta1HTTPIngressPath( | ||||
|                             backend=NetworkingV1beta1IngressBackend( | ||||
|                                 service_name=self.name, | ||||
|                                 service_port="http", | ||||
|                             ), | ||||
|                             path="/", | ||||
|                         ) | ||||
|                     ] | ||||
|                 ), | ||||
|             ) | ||||
|             rules.append(rule) | ||||
|         if not rules: | ||||
|             self.logger.debug("No providers use proxying, no ingress needed") | ||||
|             raise Disabled() | ||||
|         tls_config = None | ||||
|         if tls_hosts: | ||||
|             tls_config = NetworkingV1beta1IngressTLS( | ||||
|  | ||||
| @ -1,162 +0,0 @@ | ||||
| """Kubernetes Traefik Middleware Reconciler""" | ||||
| from dataclasses import asdict, dataclass, field | ||||
| from typing import TYPE_CHECKING | ||||
|  | ||||
| from dacite import from_dict | ||||
| from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi | ||||
|  | ||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | ||||
| from authentik.outposts.controllers.k8s.base import ( | ||||
|     Disabled, | ||||
|     KubernetesObjectReconciler, | ||||
|     NeedsUpdate, | ||||
| ) | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.outposts.controllers.kubernetes import KubernetesController | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class TraefikMiddlewareSpecForwardAuth: | ||||
|     """traefik middleware forwardAuth spec""" | ||||
|  | ||||
|     address: str | ||||
|     # pylint: disable=invalid-name | ||||
|     authResponseHeaders: list[str] | ||||
|     # pylint: disable=invalid-name | ||||
|     trustForwardHeader: bool | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class TraefikMiddlewareSpec: | ||||
|     """Traefik middleware spec""" | ||||
|  | ||||
|     # pylint: disable=invalid-name | ||||
|     forwardAuth: TraefikMiddlewareSpecForwardAuth | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class TraefikMiddlewareMetadata: | ||||
|     """Traefik Middleware metadata""" | ||||
|  | ||||
|     name: str | ||||
|     namespace: str | ||||
|     labels: dict = field(default_factory=dict) | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class TraefikMiddleware: | ||||
|     """Traefik Middleware""" | ||||
|  | ||||
|     # pylint: disable=invalid-name | ||||
|     apiVersion: str | ||||
|     kind: str | ||||
|     metadata: TraefikMiddlewareMetadata | ||||
|     spec: TraefikMiddlewareSpec | ||||
|  | ||||
|  | ||||
| CRD_NAME = "middlewares.traefik.containo.us" | ||||
| CRD_GROUP = "traefik.containo.us" | ||||
| CRD_VERSION = "v1alpha1" | ||||
| CRD_PLURAL = "middlewares" | ||||
|  | ||||
|  | ||||
| class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]): | ||||
|     """Kubernetes Traefik Middleware Reconciler""" | ||||
|  | ||||
|     def __init__(self, controller: "KubernetesController") -> None: | ||||
|         super().__init__(controller) | ||||
|         self.api_ex = ApiextensionsV1Api(controller.client) | ||||
|         self.api = CustomObjectsApi(controller.client) | ||||
|  | ||||
|     def _crd_exists(self) -> bool: | ||||
|         """Check if the traefik middleware exists""" | ||||
|         return bool( | ||||
|             len( | ||||
|                 self.api_ex.list_custom_resource_definition( | ||||
|                     field_selector=f"metadata.name={CRD_NAME}" | ||||
|                 ).items | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     def reconcile(self, current: TraefikMiddleware, reference: TraefikMiddleware): | ||||
|         super().reconcile(current, reference) | ||||
|         if current.spec.forwardAuth.address != reference.spec.forwardAuth.address: | ||||
|             raise NeedsUpdate() | ||||
|  | ||||
|     def get_reference_object(self) -> TraefikMiddleware: | ||||
|         """Get deployment object for outpost""" | ||||
|         if not ProxyProvider.objects.filter( | ||||
|             outpost__in=[self.controller.outpost], | ||||
|             forward_auth_mode=True, | ||||
|         ).exists(): | ||||
|             self.logger.debug("No providers with forward auth enabled.") | ||||
|             raise Disabled() | ||||
|         if not self._crd_exists(): | ||||
|             self.logger.debug("CRD doesn't exist") | ||||
|             raise Disabled() | ||||
|         return TraefikMiddleware( | ||||
|             apiVersion=f"{CRD_GROUP}/{CRD_VERSION}", | ||||
|             kind="Middleware", | ||||
|             metadata=TraefikMiddlewareMetadata( | ||||
|                 name=self.name, | ||||
|                 namespace=self.namespace, | ||||
|                 labels=self.get_object_meta().labels, | ||||
|             ), | ||||
|             spec=TraefikMiddlewareSpec( | ||||
|                 forwardAuth=TraefikMiddlewareSpecForwardAuth( | ||||
|                     address=f"http://{self.name}.{self.namespace}:4180/akprox/auth?traefik", | ||||
|                     authResponseHeaders=[ | ||||
|                         "Set-Cookie", | ||||
|                         "X-Auth-Username", | ||||
|                         "X-Forwarded-Email", | ||||
|                         "X-Forwarded-Preferred-Username", | ||||
|                         "X-Forwarded-User", | ||||
|                     ], | ||||
|                     trustForwardHeader=True, | ||||
|                 ) | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def create(self, reference: TraefikMiddleware): | ||||
|         return self.api.create_namespaced_custom_object( | ||||
|             group=CRD_GROUP, | ||||
|             version=CRD_VERSION, | ||||
|             plural=CRD_PLURAL, | ||||
|             namespace=self.namespace, | ||||
|             body=asdict(reference), | ||||
|             field_manager=FIELD_MANAGER, | ||||
|         ) | ||||
|  | ||||
|     def delete(self, reference: TraefikMiddleware): | ||||
|         return self.api.delete_namespaced_custom_object( | ||||
|             group=CRD_GROUP, | ||||
|             version=CRD_VERSION, | ||||
|             namespace=self.namespace, | ||||
|             plural=CRD_PLURAL, | ||||
|             name=self.name, | ||||
|         ) | ||||
|  | ||||
|     def retrieve(self) -> TraefikMiddleware: | ||||
|         return from_dict( | ||||
|             TraefikMiddleware, | ||||
|             self.api.get_namespaced_custom_object( | ||||
|                 group=CRD_GROUP, | ||||
|                 version=CRD_VERSION, | ||||
|                 namespace=self.namespace, | ||||
|                 plural=CRD_PLURAL, | ||||
|                 name=self.name, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def update(self, current: TraefikMiddleware, reference: TraefikMiddleware): | ||||
|         return self.api.patch_namespaced_custom_object( | ||||
|             group=CRD_GROUP, | ||||
|             version=CRD_VERSION, | ||||
|             namespace=self.namespace, | ||||
|             plural=CRD_PLURAL, | ||||
|             name=self.name, | ||||
|             body=asdict(reference), | ||||
|             field_manager=FIELD_MANAGER, | ||||
|         ) | ||||
| @ -3,9 +3,6 @@ from authentik.outposts.controllers.base import DeploymentPort | ||||
| from authentik.outposts.controllers.kubernetes import KubernetesController | ||||
| from authentik.outposts.models import KubernetesServiceConnection, Outpost | ||||
| from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler | ||||
| from authentik.providers.proxy.controllers.k8s.traefik import ( | ||||
|     TraefikMiddlewareReconciler, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class ProxyKubernetesController(KubernetesController): | ||||
| @ -18,6 +15,4 @@ class ProxyKubernetesController(KubernetesController): | ||||
|             DeploymentPort(4443, "https", "tcp"), | ||||
|         ] | ||||
|         self.reconcilers["ingress"] = IngressReconciler | ||||
|         self.reconcilers["traefik middleware"] = TraefikMiddlewareReconciler | ||||
|         self.reconcile_order.append("ingress") | ||||
|         self.reconcile_order.append("traefik middleware") | ||||
|  | ||||
| @ -1,35 +0,0 @@ | ||||
| # Generated by Django 3.2 on 2021-04-27 18:47 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
| import authentik.lib.models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_providers_proxy", "0010_auto_20201214_0942"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="proxyprovider", | ||||
|             name="forward_auth_mode", | ||||
|             field=models.BooleanField( | ||||
|                 default=False, | ||||
|                 help_text="Enable support for forwardAuth in traefik and nginx auth_request. Exclusive with internal_host.", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="proxyprovider", | ||||
|             name="internal_host", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 validators=[ | ||||
|                     authentik.lib.models.DomainlessURLValidator( | ||||
|                         schemes=("http", "https") | ||||
|                     ) | ||||
|                 ], | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,7 +1,7 @@ | ||||
| """authentik proxy models""" | ||||
| import string | ||||
| from random import SystemRandom | ||||
| from typing import Iterable, Optional, Type, Union | ||||
| from typing import Iterable, Optional, Type | ||||
| from urllib.parse import urljoin | ||||
|  | ||||
| from django.db import models | ||||
| @ -42,8 +42,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | ||||
|     Protocols by using a Reverse-Proxy.""" | ||||
|  | ||||
|     internal_host = models.TextField( | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))], | ||||
|         blank=True, | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))] | ||||
|     ) | ||||
|     external_host = models.TextField( | ||||
|         validators=[DomainlessURLValidator(schemes=("http", "https"))] | ||||
| @ -53,13 +52,6 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | ||||
|         help_text=_("Validate SSL Certificates of upstream servers"), | ||||
|         verbose_name=_("Internal host SSL Validation"), | ||||
|     ) | ||||
|     forward_auth_mode = models.BooleanField( | ||||
|         default=False, | ||||
|         help_text=_( | ||||
|             "Enable support for forwardAuth in traefik and nginx auth_request. Exclusive with " | ||||
|             "internal_host." | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     skip_path_regex = models.TextField( | ||||
|         default="", | ||||
| @ -147,7 +139,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | ||||
|     def __str__(self): | ||||
|         return f"Proxy Provider {self.name}" | ||||
|  | ||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||
|     def get_required_objects(self) -> Iterable[models.Model]: | ||||
|         required_models = [self] | ||||
|         if self.certificate is not None: | ||||
|             required_models.append(self.certificate) | ||||
|  | ||||
| @ -102,13 +102,11 @@ INSTALLED_APPS = [ | ||||
|     "authentik.policies.password", | ||||
|     "authentik.policies.reputation", | ||||
|     "authentik.providers.proxy", | ||||
|     "authentik.providers.ldap", | ||||
|     "authentik.providers.oauth2", | ||||
|     "authentik.providers.saml", | ||||
|     "authentik.recovery", | ||||
|     "authentik.sources.ldap", | ||||
|     "authentik.sources.oauth", | ||||
|     "authentik.sources.plex", | ||||
|     "authentik.sources.saml", | ||||
|     "authentik.stages.authenticator_static", | ||||
|     "authentik.stages.authenticator_totp", | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| """authentik URL Configuration""" | ||||
| from django.conf import settings | ||||
| from django.conf.urls.static import static | ||||
| from django.urls import include, path | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -47,3 +49,11 @@ urlpatterns += [ | ||||
|     path("-/health/live/", LiveView.as_view(), name="health-live"), | ||||
|     path("-/health/ready/", ReadyView.as_view(), name="health-ready"), | ||||
| ] | ||||
|  | ||||
| if settings.DEBUG:  # pragma: no cover | ||||
|  | ||||
|     urlpatterns = ( | ||||
|         static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) | ||||
|         + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) | ||||
|         + urlpatterns | ||||
|     ) | ||||
|  | ||||
| @ -19,7 +19,7 @@ class BaseLDAPSynchronizer: | ||||
|  | ||||
|     def __init__(self, source: LDAPSource): | ||||
|         self._source = source | ||||
|         self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__) | ||||
|         self._logger = get_logger().bind(source=source) | ||||
|  | ||||
|     @property | ||||
|     def base_dn_users(self) -> str: | ||||
|  | ||||
| @ -75,7 +75,6 @@ class OAuthSourceSerializer(SourceSerializer): | ||||
|             "callback_url", | ||||
|             "type", | ||||
|         ] | ||||
|         extra_kwargs = {"consumer_secret": {"write_only": True}} | ||||
|  | ||||
|  | ||||
| class OAuthSourceViewSet(ModelViewSet): | ||||
|  | ||||
| @ -1,7 +1,4 @@ | ||||
| """OAuth Source Serializer""" | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from guardian.utils import get_anonymous_user | ||||
| from rest_framework.filters import OrderingFilter, SearchFilter | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.sources import SourceSerializer | ||||
| @ -27,14 +24,10 @@ class UserOAuthSourceConnectionViewSet(ModelViewSet): | ||||
|     queryset = UserOAuthSourceConnection.objects.all() | ||||
|     serializer_class = UserOAuthSourceConnectionSerializer | ||||
|     filterset_fields = ["source__slug"] | ||||
|     filter_backends = [ | ||||
|         DjangoFilterBackend, | ||||
|         OrderingFilter, | ||||
|         SearchFilter, | ||||
|     ] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         user = self.request.user if self.request else get_anonymous_user() | ||||
|         if user.is_superuser: | ||||
|         if not self.request: | ||||
|             return super().get_queryset() | ||||
|         return super().get_queryset().filter(user=user.pk) | ||||
|         if self.request.user.is_superuser: | ||||
|             return super().get_queryset() | ||||
|         return super().get_queryset().filter(user=self.request.user) | ||||
|  | ||||
| @ -2,21 +2,11 @@ | ||||
| from importlib import import_module | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.conf import settings | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| AUTHENTIK_SOURCES_OAUTH_TYPES = [ | ||||
|     "authentik.sources.oauth.types.discord", | ||||
|     "authentik.sources.oauth.types.facebook", | ||||
|     "authentik.sources.oauth.types.github", | ||||
|     "authentik.sources.oauth.types.google", | ||||
|     "authentik.sources.oauth.types.reddit", | ||||
|     "authentik.sources.oauth.types.twitter", | ||||
|     "authentik.sources.oauth.types.azure_ad", | ||||
|     "authentik.sources.oauth.types.oidc", | ||||
| ] | ||||
|  | ||||
|  | ||||
| class AuthentikSourceOAuthConfig(AppConfig): | ||||
|     """authentik source.oauth config""" | ||||
| @ -28,7 +18,7 @@ class AuthentikSourceOAuthConfig(AppConfig): | ||||
|  | ||||
|     def ready(self): | ||||
|         """Load source_types from config file""" | ||||
|         for source_type in AUTHENTIK_SOURCES_OAUTH_TYPES: | ||||
|         for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES: | ||||
|             try: | ||||
|                 import_module(source_type) | ||||
|                 LOGGER.debug("Loaded OAuth Source Type", type=source_type) | ||||
|  | ||||
							
								
								
									
										23
									
								
								authentik/sources/oauth/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/sources/oauth/auth.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| """authentik oauth_client Authorization backend""" | ||||
| from typing import Optional | ||||
|  | ||||
| from django.contrib.auth.backends import ModelBackend | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | ||||
|  | ||||
|  | ||||
| class AuthorizedServiceBackend(ModelBackend): | ||||
|     "Authentication backend for users registered with remote OAuth provider." | ||||
|  | ||||
|     def authenticate( | ||||
|         self, request: HttpRequest, source: OAuthSource, identifier: str | ||||
|     ) -> Optional[User]: | ||||
|         "Fetch user for a given source by id." | ||||
|         access = UserOAuthSourceConnection.objects.filter( | ||||
|             source=source, identifier=identifier | ||||
|         ).select_related("user") | ||||
|         if not access.exists(): | ||||
|             return None | ||||
|         return access.first().user | ||||
| @ -9,7 +9,6 @@ from rest_framework.serializers import Serializer | ||||
|  | ||||
| from authentik.core.models import Source, UserSourceConnection | ||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | ||||
| from authentik.flows.challenge import ChallengeTypes, RedirectChallenge | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.sources.oauth.types.manager import SourceType | ||||
| @ -68,14 +67,9 @@ class OAuthSource(Source): | ||||
|     @property | ||||
|     def ui_login_button(self) -> UILoginButton: | ||||
|         return UILoginButton( | ||||
|             challenge=RedirectChallenge( | ||||
|                 instance={ | ||||
|                     "type": ChallengeTypes.REDIRECT.value, | ||||
|                     "to": reverse( | ||||
|                         "authentik_sources_oauth:oauth-client-login", | ||||
|                         kwargs={"source_slug": self.slug}, | ||||
|                     ), | ||||
|                 } | ||||
|             url=reverse( | ||||
|                 "authentik_sources_oauth:oauth-client-login", | ||||
|                 kwargs={"source_slug": self.slug}, | ||||
|             ), | ||||
|             icon_url=static(f"authentik/sources/{self.provider_type}.svg"), | ||||
|             name=self.name, | ||||
| @ -169,6 +163,16 @@ class OpenIDOAuthSource(OAuthSource): | ||||
|         verbose_name_plural = _("OpenID OAuth Sources") | ||||
|  | ||||
|  | ||||
| class PlexOAuthSource(OAuthSource): | ||||
|     """Login using plex.tv.""" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|         abstract = True | ||||
|         verbose_name = _("Plex OAuth Source") | ||||
|         verbose_name_plural = _("Plex OAuth Sources") | ||||
|  | ||||
|  | ||||
| class UserOAuthSourceConnection(UserSourceConnection): | ||||
|     """Authorized remote OAuth provider.""" | ||||
|  | ||||
|  | ||||
							
								
								
									
										13
									
								
								authentik/sources/oauth/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								authentik/sources/oauth/settings.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| """Oauth2 Client Settings""" | ||||
|  | ||||
| AUTHENTIK_SOURCES_OAUTH_TYPES = [ | ||||
|     "authentik.sources.oauth.types.discord", | ||||
|     "authentik.sources.oauth.types.facebook", | ||||
|     "authentik.sources.oauth.types.github", | ||||
|     "authentik.sources.oauth.types.google", | ||||
|     "authentik.sources.oauth.types.reddit", | ||||
|     "authentik.sources.oauth.types.twitter", | ||||
|     "authentik.sources.oauth.types.azure_ad", | ||||
|     "authentik.sources.oauth.types.oidc", | ||||
|     "authentik.sources.oauth.types.plex", | ||||
| ] | ||||
| @ -1,7 +1,7 @@ | ||||
| """Discord Type tests""" | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.sources.oauth.models import OAuthSource | ||||
| from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | ||||
| from authentik.sources.oauth.types.discord import DiscordOAuth2Callback | ||||
|  | ||||
| # https://discord.com/developers/docs/resources/user#user-object | ||||
| @ -18,7 +18,7 @@ DISCORD_USER = { | ||||
| } | ||||
|  | ||||
|  | ||||
| class TestTypeDiscord(TestCase): | ||||
| class TestTypeGitHub(TestCase): | ||||
|     """OAuth Source tests""" | ||||
|  | ||||
|     def setUp(self): | ||||
| @ -32,8 +32,10 @@ class TestTypeDiscord(TestCase): | ||||
|         ) | ||||
|  | ||||
|     def test_enroll_context(self): | ||||
|         """Test discord Enrollment context""" | ||||
|         ak_context = DiscordOAuth2Callback().get_user_enroll_context(DISCORD_USER) | ||||
|         """Test GitHub Enrollment context""" | ||||
|         ak_context = DiscordOAuth2Callback().get_user_enroll_context( | ||||
|             self.source, UserOAuthSourceConnection(), DISCORD_USER | ||||
|         ) | ||||
|         self.assertEqual(ak_context["username"], DISCORD_USER["username"]) | ||||
|         self.assertEqual(ak_context["email"], DISCORD_USER["email"]) | ||||
|         self.assertEqual(ak_context["name"], DISCORD_USER["username"]) | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """GitHub Type tests""" | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.sources.oauth.models import OAuthSource | ||||
| from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | ||||
| from authentik.sources.oauth.types.github import GitHubOAuth2Callback | ||||
|  | ||||
| # https://developer.github.com/v3/users/#get-the-authenticated-user | ||||
| @ -63,7 +63,9 @@ class TestTypeGitHub(TestCase): | ||||
|  | ||||
|     def test_enroll_context(self): | ||||
|         """Test GitHub Enrollment context""" | ||||
|         ak_context = GitHubOAuth2Callback().get_user_enroll_context(GITHUB_USER) | ||||
|         ak_context = GitHubOAuth2Callback().get_user_enroll_context( | ||||
|             self.source, UserOAuthSourceConnection(), GITHUB_USER | ||||
|         ) | ||||
|         self.assertEqual(ak_context["username"], GITHUB_USER["login"]) | ||||
|         self.assertEqual(ak_context["email"], GITHUB_USER["email"]) | ||||
|         self.assertEqual(ak_context["name"], GITHUB_USER["name"]) | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	