Compare commits
	
		
			3 Commits
		
	
	
		
			version/20
			...
			version-20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 18778ce0d9 | |||
| 14973fb595 | |||
| 9171bd6d6f | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2021.6.1-rc3 | current_version = 2021.4.6 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||||
| @ -19,18 +19,26 @@ values = | |||||||
|  |  | ||||||
| [bumpversion:file:website/docs/installation/docker-compose.md] | [bumpversion:file:website/docs/installation/docker-compose.md] | ||||||
|  |  | ||||||
|  | [bumpversion:file:website/docs/installation/kubernetes.md] | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [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:.github/workflows/release.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:authentik/__init__.py] | [bumpversion:file:authentik/__init__.py] | ||||||
|  |  | ||||||
| [bumpversion:file:internal/constants/constants.go] |  | ||||||
|  |  | ||||||
| [bumpversion:file:outpost/pkg/version.go] | [bumpversion:file:outpost/pkg/version.go] | ||||||
|  |  | ||||||
| [bumpversion:file:web/src/constants.ts] | [bumpversion:file:web/src/constants.ts] | ||||||
|  |  | ||||||
|  | [bumpversion:file:web/nginx.conf] | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md] | [bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md] | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md] | [bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md] | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							| @ -1,27 +0,0 @@ | |||||||
| --- |  | ||||||
| name: Question |  | ||||||
| about: Ask a question about a feature or specific configuration |  | ||||||
| title: '' |  | ||||||
| labels: question |  | ||||||
| assignees: '' |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| **Describe your question/** |  | ||||||
| A clear and concise description of what you're trying to do. |  | ||||||
|  |  | ||||||
| **Relevant infos** |  | ||||||
| i.e. Version of other software you're using, specifics of your setup |  | ||||||
|  |  | ||||||
| **Screenshots** |  | ||||||
| If applicable, add screenshots to help explain your problem. |  | ||||||
|  |  | ||||||
| **Logs** |  | ||||||
| Output of docker-compose logs or kubectl logs respectively |  | ||||||
|  |  | ||||||
| **Version and Deployment (please complete the following information):** |  | ||||||
|  - authentik version: [e.g. 0.10.0-stable] |  | ||||||
|  - Deployment: [e.g. docker-compose, helm] |  | ||||||
|  |  | ||||||
| **Additional context** |  | ||||||
| Add any other context about the problem here. |  | ||||||
							
								
								
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,13 +1,5 @@ | |||||||
| version: 2 | version: 2 | ||||||
| updates: | updates: | ||||||
| - package-ecosystem: "github-actions" |  | ||||||
|   directory: "/" |  | ||||||
|   schedule: |  | ||||||
|     interval: daily |  | ||||||
|     time: "04:00" |  | ||||||
|   open-pull-requests-limit: 10 |  | ||||||
|   assignees: |  | ||||||
|   - BeryJu |  | ||||||
| - package-ecosystem: gomod | - package-ecosystem: gomod | ||||||
|   directory: "/outpost" |   directory: "/outpost" | ||||||
|   schedule: |   schedule: | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,13 +0,0 @@ | |||||||
| # Number of days of inactivity before an issue becomes stale |  | ||||||
| daysUntilStale: 60 |  | ||||||
| # Number of days of inactivity before a stale issue is closed |  | ||||||
| daysUntilClose: 7 |  | ||||||
| # Issues with these labels will never be considered stale |  | ||||||
| exemptLabels: |  | ||||||
|   - pinned |  | ||||||
|   - security |  | ||||||
| # Comment to post when marking an issue as stale. Set to `false` to disable |  | ||||||
| markComment: > |  | ||||||
|   This issue has been automatically marked as stale because it has not had |  | ||||||
|   recent activity. It will be closed if no further activity occurs. Thank you |  | ||||||
|   for your contributions. |  | ||||||
							
								
								
									
										152
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										152
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,117 +3,90 @@ name: authentik-on-release | |||||||
| on: | on: | ||||||
|   release: |   release: | ||||||
|     types: [published, created] |     types: [published, created] | ||||||
|   push: |  | ||||||
|     branches: |  | ||||||
|       - version-* |  | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   # Build |   # Build | ||||||
|   build-server: |   build-server: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v1 | ||||||
|       - name: Set up QEMU |  | ||||||
|         uses: docker/setup-qemu-action@v1.2.0 |  | ||||||
|       - name: Set up Docker Buildx |  | ||||||
|         uses: docker/setup-buildx-action@v1 |  | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         uses: docker/login-action@v1 |         env: | ||||||
|         with: |           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||||
|           username: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|           password: ${{ secrets.DOCKER_PASSWORD }} |         run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD | ||||||
|       - name: Login to GitHub Container Registry |  | ||||||
|         uses: docker/login-action@v1 |  | ||||||
|         with: |  | ||||||
|           registry: ghcr.io |  | ||||||
|           username: ${{ github.repository_owner }} |  | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|       - name: Building Docker Image |       - name: Building Docker Image | ||||||
|         uses: docker/build-push-action@v2 |         run: docker build | ||||||
|         with: |           --no-cache | ||||||
|           push: ${{ github.event_name == 'release' }} |           -t beryju/authentik:2021.4.6 | ||||||
|           tags: | |           -t beryju/authentik:latest | ||||||
|             beryju/authentik:2021.6.1-rc3, |           -f Dockerfile . | ||||||
|             beryju/authentik:latest, |       - name: Push Docker Container to Registry (versioned) | ||||||
|             ghcr.io/goauthentik/server:2021.6.1-rc3, |         run: docker push beryju/authentik:2021.4.6 | ||||||
|             ghcr.io/goauthentik/server:latest |       - name: Push Docker Container to Registry (latest) | ||||||
|           platforms: linux/amd64,linux/arm64 |         run: docker push beryju/authentik:latest | ||||||
|           context: . |  | ||||||
|   build-proxy: |   build-proxy: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v1 | ||||||
|       - uses: actions/setup-go@v2 |       - uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.15" |           go-version: "^1.15" | ||||||
|       - name: Set up QEMU |       - name: prepare go api client | ||||||
|         uses: docker/setup-qemu-action@v1.2.0 |         run: | | ||||||
|       - name: Set up Docker Buildx |           cd outpost | ||||||
|         uses: docker/setup-buildx-action@v1 |           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||||
|  |           swagger generate client -f ../swagger.yaml -A authentik -t pkg/ | ||||||
|  |           go build -v . | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         uses: docker/login-action@v1 |         env: | ||||||
|         with: |           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||||
|           username: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|           password: ${{ secrets.DOCKER_PASSWORD }} |         run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD | ||||||
|       - name: Login to GitHub Container Registry |  | ||||||
|         uses: docker/login-action@v1 |  | ||||||
|         with: |  | ||||||
|           registry: ghcr.io |  | ||||||
|           username: ${{ github.repository_owner }} |  | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|       - name: Building Docker Image |       - name: Building Docker Image | ||||||
|         uses: docker/build-push-action@v2 |         run: | | ||||||
|         with: |           cd outpost/ | ||||||
|           push: ${{ github.event_name == 'release' }} |           docker build \ | ||||||
|           tags: | |           --no-cache \ | ||||||
|             beryju/authentik-proxy:2021.6.1-rc3, |           -t beryju/authentik-proxy:2021.4.6 \ | ||||||
|             beryju/authentik-proxy:latest, |           -t beryju/authentik-proxy:latest \ | ||||||
|             ghcr.io/goauthentik/proxy:2021.6.1-rc3, |           -f proxy.Dockerfile . | ||||||
|             ghcr.io/goauthentik/proxy:latest |       - name: Push Docker Container to Registry (versioned) | ||||||
|           file: outpost/proxy.Dockerfile |         run: docker push beryju/authentik-proxy:2021.4.6 | ||||||
|           platforms: linux/amd64,linux/arm64 |       - name: Push Docker Container to Registry (latest) | ||||||
|   build-ldap: |         run: docker push beryju/authentik-proxy:latest | ||||||
|  |   build-static: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v1 | ||||||
|       - uses: actions/setup-go@v2 |       - name: prepare ts api client | ||||||
|         with: |         run: | | ||||||
|           go-version: "^1.15" |           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: Set up QEMU |  | ||||||
|         uses: docker/setup-qemu-action@v1.2.0 |  | ||||||
|       - name: Set up Docker Buildx |  | ||||||
|         uses: docker/setup-buildx-action@v1 |  | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         uses: docker/login-action@v1 |         env: | ||||||
|         with: |           DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} | ||||||
|           username: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|           password: ${{ secrets.DOCKER_PASSWORD }} |         run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD | ||||||
|       - name: Login to GitHub Container Registry |  | ||||||
|         uses: docker/login-action@v1 |  | ||||||
|         with: |  | ||||||
|           registry: ghcr.io |  | ||||||
|           username: ${{ github.repository_owner }} |  | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |  | ||||||
|       - name: Building Docker Image |       - name: Building Docker Image | ||||||
|         uses: docker/build-push-action@v2 |         run: | | ||||||
|         with: |           cd web/ | ||||||
|           push: ${{ github.event_name == 'release' }} |           docker build \ | ||||||
|           tags: | |           --no-cache \ | ||||||
|             beryju/authentik-ldap:2021.6.1-rc3, |           -t beryju/authentik-static:2021.4.6 \ | ||||||
|             beryju/authentik-ldap:latest, |           -t beryju/authentik-static:latest \ | ||||||
|             ghcr.io/goauthentik/ldap:2021.6.1-rc3, |           -f Dockerfile . | ||||||
|             ghcr.io/goauthentik/ldap:latest |       - name: Push Docker Container to Registry (versioned) | ||||||
|           file: outpost/ldap.Dockerfile |         run: docker push beryju/authentik-static:2021.4.6 | ||||||
|           platforms: linux/amd64,linux/arm64 |       - name: Push Docker Container to Registry (latest) | ||||||
|  |         run: docker push beryju/authentik-static:latest | ||||||
|   test-release: |   test-release: | ||||||
|     if: ${{ github.event_name == 'release' }} |  | ||||||
|     needs: |     needs: | ||||||
|       - build-server |       - build-server | ||||||
|  |       - build-static | ||||||
|       - build-proxy |       - build-proxy | ||||||
|       - build-ldap |  | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v1 | ||||||
|       - name: Run test suite in final docker images |       - name: Run test suite in final docker images | ||||||
|         run: | |         run: | | ||||||
|           sudo apt-get install -y pwgen |           sudo apt-get install -y pwgen | ||||||
| @ -122,21 +95,20 @@ jobs: | |||||||
|           docker-compose pull -q |           docker-compose pull -q | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root server test |           docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik" | ||||||
|   sentry-release: |   sentry-release: | ||||||
|     if: ${{ github.event_name == 'release' }} |  | ||||||
|     needs: |     needs: | ||||||
|       - test-release |       - test-release | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v1 | ||||||
|       - name: Create a Sentry.io release |       - name: Create a Sentry.io release | ||||||
|         uses: getsentry/action-release@v1 |         uses: tclindner/sentry-releases-action@v1.2.0 | ||||||
|         env: |         env: | ||||||
|           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} |           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | ||||||
|           SENTRY_ORG: beryjuorg |           SENTRY_ORG: beryjuorg | ||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2021.6.1-rc3 |           tagName: 2021.4.6 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -10,7 +10,7 @@ jobs: | |||||||
|     name: Create Release from Tag |     name: Create Release from Tag | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@master | ||||||
|       - name: Pre-release test |       - name: Pre-release test | ||||||
|         run: | |         run: | | ||||||
|           sudo apt-get install -y pwgen |           sudo apt-get install -y pwgen | ||||||
| @ -24,17 +24,26 @@ jobs: | |||||||
|             -f Dockerfile . |             -f Dockerfile . | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root server test |           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 |       - name: Extract version number | ||||||
|         id: get_version |         id: get_version | ||||||
|         uses: actions/github-script@v4.0.2 |         uses: actions/github-script@0.2.0 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} |           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|           script: | |           script: | | ||||||
|             return context.payload.ref.replace(/\/refs\/tags\/version\//, ''); |             return context.payload.ref.replace(/\/refs\/tags\/version\//, ''); | ||||||
|       - name: Create Release |       - name: Create Release | ||||||
|         id: create_release |         id: create_release | ||||||
|         uses: actions/create-release@v1.1.4 |         uses: actions/create-release@v1.0.0 | ||||||
|         env: |         env: | ||||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||||
|         with: |         with: | ||||||
| @ -42,3 +51,13 @@ jobs: | |||||||
|           release_name: Release ${{ steps.get_version.outputs.result }} |           release_name: Release ${{ steps.get_version.outputs.result }} | ||||||
|           draft: true |           draft: true | ||||||
|           prerelease: false |           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/ | backups/ | ||||||
| media/ | media/ | ||||||
| *mmdb | *mmdb | ||||||
|  |  | ||||||
| .idea/ |  | ||||||
|  | |||||||
							
								
								
									
										63
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										63
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,4 +1,3 @@ | |||||||
| # Stage 1: Lock python dependencies |  | ||||||
| FROM python:3.9-slim-buster as locker | FROM python:3.9-slim-buster as locker | ||||||
|  |  | ||||||
| COPY ./Pipfile /app/ | COPY ./Pipfile /app/ | ||||||
| @ -8,47 +7,8 @@ WORKDIR /app/ | |||||||
|  |  | ||||||
| RUN pip install pipenv && \ | RUN pip install pipenv && \ | ||||||
|     pipenv lock -r > requirements.txt && \ |     pipenv lock -r > requirements.txt && \ | ||||||
|     pipenv lock -r --dev-only > requirements-dev.txt |     pipenv lock -rd > requirements-dev.txt | ||||||
|  |  | ||||||
| # Stage 2: Build web API |  | ||||||
| FROM openapitools/openapi-generator-cli as api-builder |  | ||||||
|  |  | ||||||
| COPY ./schema.yml /local/schema.yml |  | ||||||
|  |  | ||||||
| RUN	docker-entrypoint.sh generate \ |  | ||||||
|     -i /local/schema.yml \ |  | ||||||
|     -g typescript-fetch \ |  | ||||||
|     -o /local/web/api \ |  | ||||||
|     --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 |  | ||||||
|  |  | ||||||
| # Stage 3: Build webui |  | ||||||
| FROM node as npm-builder |  | ||||||
|  |  | ||||||
| COPY ./web /static/ |  | ||||||
| COPY --from=api-builder /local/web/api /static/api |  | ||||||
|  |  | ||||||
| ENV NODE_ENV=production |  | ||||||
| RUN cd /static && npm i && npm run build |  | ||||||
|  |  | ||||||
| # Stage 4: Build go proxy |  | ||||||
| FROM golang:1.16.5 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/ |  | ||||||
|  |  | ||||||
| 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 5: Run |  | ||||||
| FROM python:3.9-slim-buster | FROM python:3.9-slim-buster | ||||||
|  |  | ||||||
| WORKDIR / | WORKDIR / | ||||||
| @ -59,29 +19,34 @@ ARG GIT_BUILD_HASH | |||||||
| ENV GIT_BUILD_HASH=$GIT_BUILD_HASH | ENV GIT_BUILD_HASH=$GIT_BUILD_HASH | ||||||
|  |  | ||||||
| RUN apt-get update && \ | RUN apt-get update && \ | ||||||
|     apt-get install -y --no-install-recommends curl ca-certificates gnupg git runit && \ |     apt-get install -y --no-install-recommends curl ca-certificates gnupg && \ | ||||||
|     curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ |     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 && \ |     echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ | ||||||
|     apt-get update && \ |     apt-get update && \ | ||||||
|     apt-get install -y --no-install-recommends libpq-dev postgresql-client 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 && \ | ||||||
|     pip install -r /requirements.txt --no-cache-dir && \ |  | ||||||
|     apt-get remove --purge -y build-essential git && \ |  | ||||||
|     apt-get autoremove --purge -y && \ |  | ||||||
|     apt-get clean && \ |     apt-get clean && \ | ||||||
|     rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ |     pip install -r /requirements.txt --no-cache-dir && \ | ||||||
|  |     apt-get remove --purge -y build-essential && \ | ||||||
|  |     apt-get autoremove --purge -y && \ | ||||||
|  |     # 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 | ||||||
|  |     groupadd -g 998 docker_998 && \ | ||||||
|  |     groupadd -g 999 docker_999 && \ | ||||||
|     adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ |     adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ | ||||||
|  |     usermod -a -G docker_998 authentik && \ | ||||||
|  |     usermod -a -G docker_999 authentik && \ | ||||||
|     mkdir /backups && \ |     mkdir /backups && \ | ||||||
|     chown authentik:authentik /backups |     chown authentik:authentik /backups | ||||||
|  |  | ||||||
| COPY ./authentik/ /authentik | COPY ./authentik/ /authentik | ||||||
| COPY ./pyproject.toml / | COPY ./pyproject.toml / | ||||||
| COPY ./xml /xml | COPY ./xml /xml | ||||||
| COPY ./tests /tests |  | ||||||
| COPY ./manage.py / | COPY ./manage.py / | ||||||
| COPY ./lifecycle/ /lifecycle | COPY ./lifecycle/ /lifecycle | ||||||
| COPY --from=builder /work/authentik /authentik-proxy |  | ||||||
|  |  | ||||||
| USER authentik | USER authentik | ||||||
|  | STOPSIGNAL SIGINT | ||||||
| ENV TMPDIR /dev/shm/ | ENV TMPDIR /dev/shm/ | ||||||
| ENV PYTHONUBUFFERED 1 | ENV PYTHONUBUFFERED 1 | ||||||
| ENTRYPOINT [ "/lifecycle/bootstrap.sh" ] | ENTRYPOINT [ "/lifecycle/bootstrap.sh" ] | ||||||
|  | |||||||
							
								
								
									
										54
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,9 +1,4 @@ | |||||||
| .SHELLFLAGS += -x -e | all: lint-fix lint coverage gen | ||||||
| PWD = $(shell pwd) |  | ||||||
| UID = $(shell id -u) |  | ||||||
| GID = $(shell id -g) |  | ||||||
|  |  | ||||||
| all: lint-fix lint test gen |  | ||||||
|  |  | ||||||
| test-integration: | test-integration: | ||||||
| 	k3d cluster create || exit 0 | 	k3d cluster create || exit 0 | ||||||
| @ -13,7 +8,7 @@ test-integration: | |||||||
| test-e2e: | test-e2e: | ||||||
| 	coverage run manage.py test --failfast -v 3 tests/e2e | 	coverage run manage.py test --failfast -v 3 tests/e2e | ||||||
|  |  | ||||||
| test: | coverage: | ||||||
| 	coverage run manage.py test -v 3 authentik | 	coverage run manage.py test -v 3 authentik | ||||||
| 	coverage html | 	coverage html | ||||||
| 	coverage report | 	coverage report | ||||||
| @ -27,39 +22,16 @@ lint: | |||||||
| 	bandit -r authentik tests lifecycle -x node_modules | 	bandit -r authentik tests lifecycle -x node_modules | ||||||
| 	pylint authentik tests lifecycle | 	pylint authentik tests lifecycle | ||||||
|  |  | ||||||
| gen-build: | gen: coverage | ||||||
| 	./manage.py spectacular --file schema.yml | 	./manage.py generate_swagger -o swagger.yaml -f yaml | ||||||
|  |  | ||||||
| gen-clean: | local-stack: | ||||||
| 	rm -rf web/api/src/ | 	export AUTHENTIK_TAG=testing | ||||||
| 	rm -rf outpost/api/ | 	docker build -t beryju/authentik:testng . | ||||||
|  | 	docker-compose up -d | ||||||
|  | 	docker-compose run --rm server migrate | ||||||
|  |  | ||||||
| gen-web: | build-static: | ||||||
| 	docker run \ | 	docker-compose -f scripts/ci.docker-compose.yml up -d | ||||||
| 		--rm -v ${PWD}:/local \ | 	docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default . | ||||||
| 		--user ${UID}:${GID} \ | 	docker-compose -f scripts/ci.docker-compose.yml down -v | ||||||
| 		openapitools/openapi-generator-cli generate \ |  | ||||||
| 		-i /local/schema.yml \ |  | ||||||
| 		-g typescript-fetch \ |  | ||||||
| 		-o /local/web/api \ |  | ||||||
| 		--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 |  | ||||||
| 	cd web/api && npx tsc |  | ||||||
|  |  | ||||||
| gen-outpost: |  | ||||||
| 	docker run \ |  | ||||||
| 		--rm -v ${PWD}:/local \ |  | ||||||
| 		--user ${UID}:${GID} \ |  | ||||||
| 		openapitools/openapi-generator-cli generate \ |  | ||||||
| 		--git-host goauthentik.io \ |  | ||||||
| 		--git-repo-id outpost \ |  | ||||||
| 		--git-user-id api \ |  | ||||||
| 		-i /local/schema.yml \ |  | ||||||
| 		-g go \ |  | ||||||
| 		-o /local/outpost/api \ |  | ||||||
| 		--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true |  | ||||||
| 	rm -f outpost/api/go.mod outpost/api/go.sum |  | ||||||
|  |  | ||||||
| gen: gen-build gen-clean gen-web gen-outpost |  | ||||||
|  |  | ||||||
| run: |  | ||||||
| 	go run -v cmd/server/main.go |  | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								Pipfile
									
									
									
									
									
								
							| @ -11,7 +11,7 @@ channels-redis = "*" | |||||||
| dacite = "*" | dacite = "*" | ||||||
| defusedxml = "*" | defusedxml = "*" | ||||||
| django = "*" | django = "*" | ||||||
| django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' } | django-dbbackup = "*" | ||||||
| django-filter = "*" | django-filter = "*" | ||||||
| django-guardian = "*" | django-guardian = "*" | ||||||
| django-model-utils = "*" | django-model-utils = "*" | ||||||
| @ -22,7 +22,7 @@ django-storages = "*" | |||||||
| djangorestframework = "*" | djangorestframework = "*" | ||||||
| djangorestframework-guardian = "*" | djangorestframework-guardian = "*" | ||||||
| docker = "*" | docker = "*" | ||||||
| drf-spectacular = "*" | drf_yasg = "*" | ||||||
| facebook-sdk = "*" | facebook-sdk = "*" | ||||||
| geoip2 = "*" | geoip2 = "*" | ||||||
| gunicorn = "*" | gunicorn = "*" | ||||||
| @ -32,7 +32,7 @@ lxml = ">=4.6.3" | |||||||
| packaging = "*" | packaging = "*" | ||||||
| psycopg2-binary = "*" | psycopg2-binary = "*" | ||||||
| pycryptodome = "*" | pycryptodome = "*" | ||||||
| pyjwt = "*" | pyjwkest = "*" | ||||||
| pyyaml = "*" | pyyaml = "*" | ||||||
| requests-oauthlib = "*" | requests-oauthlib = "*" | ||||||
| sentry-sdk = "*" | sentry-sdk = "*" | ||||||
| @ -44,15 +44,13 @@ urllib3 = {extras = ["secure"],version = "*"} | |||||||
| uvicorn = {extras = ["standard"],version = "*"} | uvicorn = {extras = ["standard"],version = "*"} | ||||||
| webauthn = "*" | webauthn = "*" | ||||||
| xmlsec = "*" | xmlsec = "*" | ||||||
| duo-client = "*" |  | ||||||
| ua-parser = "*" |  | ||||||
|  |  | ||||||
| [requires] | [requires] | ||||||
| python_version = "3.9" | python_version = "3.9" | ||||||
|  |  | ||||||
| [dev-packages] | [dev-packages] | ||||||
| bandit = "*" | bandit = "*" | ||||||
| black = "==21.5b1" | black = "==20.8b1" | ||||||
| bump2version = "*" | bump2version = "*" | ||||||
| colorama = "*" | colorama = "*" | ||||||
| coverage = "*" | coverage = "*" | ||||||
| @ -61,4 +59,3 @@ pylint-django = "*" | |||||||
| pytest = "*" | pytest = "*" | ||||||
| pytest-django = "*" | pytest-django = "*" | ||||||
| selenium = "*" | selenium = "*" | ||||||
| requests-mock = "*" |  | ||||||
|  | |||||||
							
								
								
									
										656
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										656
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -11,7 +11,6 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| [Transifex](https://www.transifex.com/beryjuorg/authentik/) |  | ||||||
|  |  | ||||||
| ## What is authentik? | ## What is authentik? | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,8 +4,8 @@ | |||||||
|  |  | ||||||
| | Version    | Supported          | | | Version    | Supported          | | ||||||
| | ---------- | ------------------ | | | ---------- | ------------------ | | ||||||
|  | | 2021.3.x   | :white_check_mark: | | ||||||
| | 2021.4.x   | :white_check_mark: | | | 2021.4.x   | :white_check_mark: | | ||||||
| | 2021.5.x   | :white_check_mark: | |  | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,3 +1,3 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "2021.6.1-rc3" | __version__ = "2021.4.6" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """Meta API""" | """Meta API""" | ||||||
| from drf_spectacular.utils import extend_schema | from drf_yasg.utils import swagger_auto_schema | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -22,7 +22,7 @@ class AppsViewSet(ViewSet): | |||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: AppSerializer(many=True)}) |     @swagger_auto_schema(responses={200: AppSerializer(many=True)}) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """List current messages and pass into Serializer""" |         """List current messages and pass into Serializer""" | ||||||
|         data = [] |         data = [] | ||||||
|  | |||||||
| @ -7,12 +7,12 @@ from django.db.models import Count, ExpressionWrapper, F | |||||||
| from django.db.models.fields import DurationField | from django.db.models.fields import DurationField | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import ExtractHour | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from drf_spectacular.utils import extend_schema, extend_schema_field | from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method | ||||||
| from rest_framework.fields import IntegerField, SerializerMethodField | from rest_framework.fields import IntegerField, SerializerMethodField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.viewsets import ViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| @ -58,24 +58,24 @@ class LoginMetricsSerializer(PassiveSerializer): | |||||||
|     logins_per_1h = SerializerMethodField() |     logins_per_1h = SerializerMethodField() | ||||||
|     logins_failed_per_1h = SerializerMethodField() |     logins_failed_per_1h = SerializerMethodField() | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||||
|     def get_logins_per_1h(self, _): |     def get_logins_per_1h(self, _): | ||||||
|         """Get successful logins per hour for the last 24 hours""" |         """Get successful logins per hour for the last 24 hours""" | ||||||
|         return get_events_per_1h(action=EventAction.LOGIN) |         return get_events_per_1h(action=EventAction.LOGIN) | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||||
|     def get_logins_failed_per_1h(self, _): |     def get_logins_failed_per_1h(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per hour for the last 24 hours""" | ||||||
|         return get_events_per_1h(action=EventAction.LOGIN_FAILED) |         return get_events_per_1h(action=EventAction.LOGIN_FAILED) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AdministrationMetricsViewSet(APIView): | class AdministrationMetricsViewSet(ViewSet): | ||||||
|     """Login Metrics per 1h""" |     """Login Metrics per 1h""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: LoginMetricsSerializer(many=False)}) |     @swagger_auto_schema(responses={200: LoginMetricsSerializer(many=False)}) | ||||||
|     def get(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Login Metrics per 1h""" |         """Login Metrics per 1h""" | ||||||
|         serializer = LoginMetricsSerializer(True) |         serializer = LoginMetricsSerializer(True) | ||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  | |||||||
| @ -1,91 +0,0 @@ | |||||||
| """authentik administration overview""" |  | ||||||
| import os |  | ||||||
| import platform |  | ||||||
| from datetime import datetime |  | ||||||
| from sys import version as python_version |  | ||||||
| from typing import TypedDict |  | ||||||
|  |  | ||||||
| from django.utils.timezone import now |  | ||||||
| from drf_spectacular.utils import extend_schema |  | ||||||
| from gunicorn import version_info as gunicorn_version |  | ||||||
| from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME |  | ||||||
| from rest_framework.fields import SerializerMethodField |  | ||||||
| from rest_framework.permissions import IsAdminUser |  | ||||||
| from rest_framework.request import Request |  | ||||||
| from rest_framework.response import Response |  | ||||||
| from rest_framework.views import APIView |  | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class RuntimeDict(TypedDict): |  | ||||||
|     """Runtime information""" |  | ||||||
|  |  | ||||||
|     python_version: str |  | ||||||
|     gunicorn_version: str |  | ||||||
|     environment: str |  | ||||||
|     architecture: str |  | ||||||
|     platform: str |  | ||||||
|     uname: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SystemSerializer(PassiveSerializer): |  | ||||||
|     """Get system information.""" |  | ||||||
|  |  | ||||||
|     http_headers = SerializerMethodField() |  | ||||||
|     http_host = SerializerMethodField() |  | ||||||
|     http_is_secure = SerializerMethodField() |  | ||||||
|     runtime = SerializerMethodField() |  | ||||||
|     tenant = SerializerMethodField() |  | ||||||
|     server_time = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     def get_http_headers(self, request: Request) -> dict[str, str]: |  | ||||||
|         """Get HTTP Request headers""" |  | ||||||
|         headers = {} |  | ||||||
|         for key, value in request.META.items(): |  | ||||||
|             if not isinstance(value, str): |  | ||||||
|                 continue |  | ||||||
|             headers[key] = value |  | ||||||
|         return headers |  | ||||||
|  |  | ||||||
|     def get_http_host(self, request: Request) -> str: |  | ||||||
|         """Get HTTP host""" |  | ||||||
|         return request._request.get_host() |  | ||||||
|  |  | ||||||
|     def get_http_is_secure(self, request: Request) -> bool: |  | ||||||
|         """Get HTTP Secure flag""" |  | ||||||
|         return request._request.is_secure() |  | ||||||
|  |  | ||||||
|     def get_runtime(self, request: Request) -> RuntimeDict: |  | ||||||
|         """Get versions""" |  | ||||||
|         return { |  | ||||||
|             "python_version": python_version, |  | ||||||
|             "gunicorn_version": ".".join(str(x) for x in gunicorn_version), |  | ||||||
|             "environment": "kubernetes" |  | ||||||
|             if SERVICE_HOST_ENV_NAME in os.environ |  | ||||||
|             else "compose", |  | ||||||
|             "architecture": platform.machine(), |  | ||||||
|             "platform": platform.platform(), |  | ||||||
|             "uname": " ".join(platform.uname()), |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|     def get_tenant(self, request: Request) -> str: |  | ||||||
|         """Currently active tenant""" |  | ||||||
|         return str(request._request.tenant) |  | ||||||
|  |  | ||||||
|     def get_server_time(self, request: Request) -> datetime: |  | ||||||
|         """Current server time""" |  | ||||||
|         return now() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SystemView(APIView): |  | ||||||
|     """Get system information.""" |  | ||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |  | ||||||
|     pagination_class = None |  | ||||||
|     filter_backends = [] |  | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: SystemSerializer(many=False)}) |  | ||||||
|     def get(self, request: Request) -> Response: |  | ||||||
|         """Get system information.""" |  | ||||||
|         return Response(SystemSerializer(request).data) |  | ||||||
| @ -4,8 +4,7 @@ from importlib import import_module | |||||||
| from django.contrib import messages | from django.contrib import messages | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_yasg.utils import swagger_auto_schema | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema |  | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField | from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| @ -22,7 +21,7 @@ class TaskSerializer(PassiveSerializer): | |||||||
|  |  | ||||||
|     task_name = CharField() |     task_name = CharField() | ||||||
|     task_description = CharField() |     task_description = CharField() | ||||||
|     task_finish_timestamp = DateTimeField(source="finish_time") |     task_finish_timestamp = DateTimeField(source="finish_timestamp") | ||||||
|  |  | ||||||
|     status = ChoiceField( |     status = ChoiceField( | ||||||
|         source="result.status.name", |         source="result.status.name", | ||||||
| @ -30,32 +29,14 @@ class TaskSerializer(PassiveSerializer): | |||||||
|     ) |     ) | ||||||
|     messages = ListField(source="result.messages") |     messages = ListField(source="result.messages") | ||||||
|  |  | ||||||
|     def to_representation(self, instance): |  | ||||||
|         """When a new version of authentik adds fields to TaskInfo, |  | ||||||
|         the API will fail with an AttributeError, as the classes |  | ||||||
|         are pickled in cache. In that case, just delete the info""" |  | ||||||
|         try: |  | ||||||
|             return super().to_representation(instance) |  | ||||||
|         except AttributeError: |  | ||||||
|             if isinstance(self.instance, list): |  | ||||||
|                 for inst in self.instance: |  | ||||||
|                     inst.delete() |  | ||||||
|             else: |  | ||||||
|                 self.instance.delete() |  | ||||||
|             return {} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TaskViewSet(ViewSet): | class TaskViewSet(ViewSet): | ||||||
|     """Read-only view set that returns all background tasks""" |     """Read-only view set that returns all background tasks""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|     serializer_class = TaskSerializer |  | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={200: TaskSerializer(many=False), 404: "Task not found"} | ||||||
|             200: TaskSerializer(many=False), |  | ||||||
|             404: OpenApiResponse(description="Task not found"), |  | ||||||
|         } |  | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|     def retrieve(self, request: Request, pk=None) -> Response: |     def retrieve(self, request: Request, pk=None) -> Response: | ||||||
| @ -65,19 +46,18 @@ class TaskViewSet(ViewSet): | |||||||
|             raise Http404 |             raise Http404 | ||||||
|         return Response(TaskSerializer(task, many=False).data) |         return Response(TaskSerializer(task, many=False).data) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TaskSerializer(many=True)}) |     @swagger_auto_schema(responses={200: TaskSerializer(many=True)}) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """List system tasks""" |         """List system tasks""" | ||||||
|         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) |         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) | ||||||
|         return Response(TaskSerializer(tasks, many=True).data) |         return Response(TaskSerializer(tasks, many=True).data) | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request=OpenApiTypes.NONE, |  | ||||||
|         responses={ |         responses={ | ||||||
|             204: OpenApiResponse(description="Task retried successfully"), |             204: "Task retried successfully", | ||||||
|             404: OpenApiResponse(description="Task not found"), |             404: "Task not found", | ||||||
|             500: OpenApiResponse(description="Failed to retry task"), |             500: "Failed to retry task", | ||||||
|         }, |         } | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["post"]) |     @action(detail=True, methods=["post"]) | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|  | |||||||
| @ -2,13 +2,14 @@ | |||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from drf_spectacular.utils import extend_schema | from drf_yasg.utils import swagger_auto_schema | ||||||
| from packaging.version import parse | from packaging.version import parse | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
|  | from rest_framework.mixins import ListModelMixin | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import ENV_GIT_HASH_KEY, __version__ | ||||||
| from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | ||||||
| @ -46,14 +47,17 @@ class VersionSerializer(PassiveSerializer): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class VersionView(APIView): | class VersionViewSet(ListModelMixin, GenericViewSet): | ||||||
|     """Get running and latest version.""" |     """Get running and latest version.""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAuthenticated] |     permission_classes = [IsAuthenticated] | ||||||
|     pagination_class = None |     pagination_class = None | ||||||
|     filter_backends = [] |     filter_backends = [] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: VersionSerializer(many=False)}) |     def get_queryset(self):  # pragma: no cover | ||||||
|     def get(self, request: Request) -> Response: |         return None | ||||||
|  |  | ||||||
|  |     @swagger_auto_schema(responses={200: VersionSerializer(many=False)}) | ||||||
|  |     def list(self, request: Request) -> Response: | ||||||
|         """Get running and latest version.""" |         """Get running and latest version.""" | ||||||
|         return Response(VersionSerializer(True).data) |         return Response(VersionSerializer(True).data) | ||||||
|  | |||||||
| @ -1,26 +1,25 @@ | |||||||
| """authentik administration overview""" | """authentik administration overview""" | ||||||
| from drf_spectacular.utils import extend_schema, inline_serializer | from rest_framework.mixins import ListModelMixin | ||||||
| from prometheus_client import Gauge |  | ||||||
| from rest_framework.fields import IntegerField |  | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.serializers import Serializer | ||||||
|  | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
| GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers") |  | ||||||
|  |  | ||||||
|  | class WorkerViewSet(ListModelMixin, GenericViewSet): | ||||||
| class WorkerView(APIView): |  | ||||||
|     """Get currently connected worker count.""" |     """Get currently connected worker count.""" | ||||||
|  |  | ||||||
|  |     serializer_class = Serializer | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @extend_schema( |     def get_queryset(self):  # pragma: no cover | ||||||
|         responses=inline_serializer("Workers", fields={"count": IntegerField()}) |         return None | ||||||
|     ) |  | ||||||
|     def get(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Get currently connected worker count.""" |         """Get currently connected worker count.""" | ||||||
|         count = len(CELERY_APP.control.ping(timeout=0.5)) |         return Response( | ||||||
|         return Response({"count": count}) |             {"pagination": {"count": len(CELERY_APP.control.ping(timeout=0.5))}} | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -1,15 +1,13 @@ | |||||||
| """authentik admin tasks""" | """authentik admin tasks""" | ||||||
| import re | import re | ||||||
| from os import environ |  | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.core.validators import URLValidator | from django.core.validators import URLValidator | ||||||
| from packaging.version import parse | from packaging.version import parse | ||||||
| from prometheus_client import Info |  | ||||||
| from requests import RequestException, get | from requests import RequestException, get | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import __version__ | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| @ -19,18 +17,6 @@ VERSION_CACHE_KEY = "authentik_latest_version" | |||||||
| VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | ||||||
| # Chop of the first ^ because we want to search the entire string | # Chop of the first ^ because we want to search the entire string | ||||||
| URL_FINDER = URLValidator.regex.pattern[1:] | URL_FINDER = URLValidator.regex.pattern[1:] | ||||||
| PROM_INFO = Info("authentik_version", "Currently running authentik version") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _set_prom_info(): |  | ||||||
|     """Set prometheus info for version""" |  | ||||||
|     PROM_INFO.info( |  | ||||||
|         { |  | ||||||
|             "version": __version__, |  | ||||||
|             "latest": cache.get(VERSION_CACHE_KEY, ""), |  | ||||||
|             "build_hash": environ.get(ENV_GIT_HASH_KEY, ""), |  | ||||||
|         } |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||||
| @ -50,7 +36,6 @@ def update_latest_version(self: MonitoredTask): | |||||||
|                 TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] |                 TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         _set_prom_info() |  | ||||||
|         # Check if upstream version is newer than what we're running, |         # Check if upstream version is newer than what we're running, | ||||||
|         # and if no event exists yet, create one. |         # and if no event exists yet, create one. | ||||||
|         local_version = parse(__version__) |         local_version = parse(__version__) | ||||||
| @ -68,6 +53,3 @@ def update_latest_version(self: MonitoredTask): | |||||||
|     except (RequestException, IndexError) as exc: |     except (RequestException, IndexError) as exc: | ||||||
|         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) |         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) | ||||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|  |  | ||||||
|  |  | ||||||
| _set_prom_info() |  | ||||||
|  | |||||||
| @ -7,7 +7,6 @@ from django.urls import reverse | |||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| from authentik.core.tasks import clean_expired_models | from authentik.core.tasks import clean_expired_models | ||||||
| from authentik.events.monitored_tasks import TaskResultStatus |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestAdminAPI(TestCase): | class TestAdminAPI(TestCase): | ||||||
| @ -31,26 +30,6 @@ class TestAdminAPI(TestCase): | |||||||
|             any(task["task_name"] == "clean_expired_models" for task in body) |             any(task["task_name"] == "clean_expired_models" for task in body) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_tasks_single(self): |  | ||||||
|         """Test Task API (read single)""" |  | ||||||
|         clean_expired_models.delay() |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:admin_system_tasks-detail", |  | ||||||
|                 kwargs={"pk": "clean_expired_models"}, |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|         body = loads(response.content) |  | ||||||
|         self.assertEqual(body["status"], TaskResultStatus.SUCCESSFUL.name) |  | ||||||
|         self.assertEqual(body["task_name"], "clean_expired_models") |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:admin_system_tasks-detail", kwargs={"pk": "qwerqwer"} |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 404) |  | ||||||
|  |  | ||||||
|     def test_tasks_retry(self): |     def test_tasks_retry(self): | ||||||
|         """Test Task API (retry)""" |         """Test Task API (retry)""" | ||||||
|         clean_expired_models.delay() |         clean_expired_models.delay() | ||||||
| @ -74,29 +53,24 @@ class TestAdminAPI(TestCase): | |||||||
|  |  | ||||||
|     def test_version(self): |     def test_version(self): | ||||||
|         """Test Version API""" |         """Test Version API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_version")) |         response = self.client.get(reverse("authentik_api:admin_version-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         body = loads(response.content) |         body = loads(response.content) | ||||||
|         self.assertEqual(body["version_current"], __version__) |         self.assertEqual(body["version_current"], __version__) | ||||||
|  |  | ||||||
|     def test_workers(self): |     def test_workers(self): | ||||||
|         """Test Workers API""" |         """Test Workers API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_workers")) |         response = self.client.get(reverse("authentik_api:admin_workers-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         body = loads(response.content) |         body = loads(response.content) | ||||||
|         self.assertEqual(body["count"], 0) |         self.assertEqual(body["pagination"]["count"], 0) | ||||||
|  |  | ||||||
|     def test_metrics(self): |     def test_metrics(self): | ||||||
|         """Test metrics API""" |         """Test metrics API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_metrics")) |         response = self.client.get(reverse("authentik_api:admin_metrics-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|     def test_apps(self): |     def test_apps(self): | ||||||
|         """Test apps API""" |         """Test apps API""" | ||||||
|         response = self.client.get(reverse("authentik_api:apps-list")) |         response = self.client.get(reverse("authentik_api:apps-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|     def test_system(self): |  | ||||||
|         """Test system API""" |  | ||||||
|         response = self.client.get(reverse("authentik_api:admin_system")) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  | |||||||
| @ -1,9 +1,8 @@ | |||||||
| """API Authentication""" | """API Authentication""" | ||||||
| from base64 import b64decode | from base64 import b64decode, b64encode | ||||||
| from binascii import Error | from binascii import Error | ||||||
| from typing import Any, Optional, Union | from typing import Any, Optional, Union | ||||||
| 
 | 
 | ||||||
| from drf_spectacular.authentication import OpenApiAuthenticationExtension |  | ||||||
| from rest_framework.authentication import BaseAuthentication, get_authorization_header | from rest_framework.authentication import BaseAuthentication, get_authorization_header | ||||||
| from rest_framework.exceptions import AuthenticationFailed | from rest_framework.exceptions import AuthenticationFailed | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -18,8 +17,16 @@ LOGGER = get_logger() | |||||||
| def token_from_header(raw_header: bytes) -> Optional[Token]: | def token_from_header(raw_header: bytes) -> Optional[Token]: | ||||||
|     """raw_header in the Format of `Bearer dGVzdDp0ZXN0`""" |     """raw_header in the Format of `Bearer dGVzdDp0ZXN0`""" | ||||||
|     auth_credentials = raw_header.decode() |     auth_credentials = raw_header.decode() | ||||||
|     if auth_credentials == "" or " " not in auth_credentials: |     if auth_credentials == "": | ||||||
|         return None |         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() |     auth_type, auth_credentials = auth_credentials.split() | ||||||
|     if auth_type.lower() not in ["basic", "bearer"]: |     if auth_type.lower() not in ["basic", "bearer"]: | ||||||
|         LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) |         LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) | ||||||
| @ -43,7 +50,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]: | |||||||
|     return tokens.first() |     return tokens.first() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TokenAuthentication(BaseAuthentication): | class AuthentikTokenAuthentication(BaseAuthentication): | ||||||
|     """Token-based authentication using HTTP Bearer authentication""" |     """Token-based authentication using HTTP Bearer authentication""" | ||||||
| 
 | 
 | ||||||
|     def authenticate(self, request: Request) -> Union[tuple[User, Any], None]: |     def authenticate(self, request: Request) -> Union[tuple[User, Any], None]: | ||||||
| @ -55,19 +62,4 @@ class TokenAuthentication(BaseAuthentication): | |||||||
|         if not token: |         if not token: | ||||||
|             return None |             return None | ||||||
| 
 | 
 | ||||||
|         return (token.user, None)  # pragma: no cover |         return (token.user, None) | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| class TokenSchema(OpenApiAuthenticationExtension): |  | ||||||
|     """Auth schema""" |  | ||||||
| 
 |  | ||||||
|     target_class = TokenAuthentication |  | ||||||
|     name = "authentik" |  | ||||||
| 
 |  | ||||||
|     def get_security_definition(self, auto_schema): |  | ||||||
|         """Auth schema""" |  | ||||||
|         return { |  | ||||||
|             "type": "apiKey", |  | ||||||
|             "in": "header", |  | ||||||
|             "name": "Authorization", |  | ||||||
|         } |  | ||||||
| @ -1,35 +0,0 @@ | |||||||
| """API Authorization""" |  | ||||||
| from django.db.models import Model |  | ||||||
| from django.db.models.query import QuerySet |  | ||||||
| from rest_framework.filters import BaseFilterBackend |  | ||||||
| from rest_framework.permissions import BasePermission |  | ||||||
| from rest_framework.request import Request |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class OwnerFilter(BaseFilterBackend): |  | ||||||
|     """Filter objects by their owner""" |  | ||||||
|  |  | ||||||
|     owner_key = "user" |  | ||||||
|  |  | ||||||
|     def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: |  | ||||||
|         return queryset.filter(**{self.owner_key: request.user}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class OwnerPermissions(BasePermission): |  | ||||||
|     """Authorize requests by an object's owner matching the requesting user""" |  | ||||||
|  |  | ||||||
|     owner_key = "user" |  | ||||||
|  |  | ||||||
|     def has_permission(self, request: Request, view) -> bool: |  | ||||||
|         """If the user is authenticated, we allow all requests here. For listing, the |  | ||||||
|         object-level permissions are done by the filter backend""" |  | ||||||
|         return request.user.is_authenticated |  | ||||||
|  |  | ||||||
|     def has_object_permission(self, request: Request, view, obj: Model) -> bool: |  | ||||||
|         """Check if the object's owner matches the currently logged in user""" |  | ||||||
|         if not hasattr(obj, self.owner_key): |  | ||||||
|             return False |  | ||||||
|         owner = getattr(obj, self.owner_key) |  | ||||||
|         if owner != request.user: |  | ||||||
|             return False |  | ||||||
|         return True |  | ||||||
| @ -30,47 +30,3 @@ class Pagination(pagination.PageNumberPagination): | |||||||
|                 "results": data, |                 "results": data, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def get_paginated_response_schema(self, schema): |  | ||||||
|         return { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "pagination": { |  | ||||||
|                     "type": "object", |  | ||||||
|                     "properties": { |  | ||||||
|                         "next": { |  | ||||||
|                             "type": "number", |  | ||||||
|                         }, |  | ||||||
|                         "previous": { |  | ||||||
|                             "type": "number", |  | ||||||
|                         }, |  | ||||||
|                         "count": { |  | ||||||
|                             "type": "number", |  | ||||||
|                         }, |  | ||||||
|                         "current": { |  | ||||||
|                             "type": "number", |  | ||||||
|                         }, |  | ||||||
|                         "total_pages": { |  | ||||||
|                             "type": "number", |  | ||||||
|                         }, |  | ||||||
|                         "start_index": { |  | ||||||
|                             "type": "number", |  | ||||||
|                         }, |  | ||||||
|                         "end_index": { |  | ||||||
|                             "type": "number", |  | ||||||
|                         }, |  | ||||||
|                     }, |  | ||||||
|                     "required": [ |  | ||||||
|                         "next", |  | ||||||
|                         "previous", |  | ||||||
|                         "count", |  | ||||||
|                         "current", |  | ||||||
|                         "total_pages", |  | ||||||
|                         "start_index", |  | ||||||
|                         "end_index", |  | ||||||
|                     ], |  | ||||||
|                 }, |  | ||||||
|                 "results": schema, |  | ||||||
|             }, |  | ||||||
|             "required": ["pagination", "results"], |  | ||||||
|         } |  | ||||||
|  | |||||||
							
								
								
									
										97
									
								
								authentik/api/pagination_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								authentik/api/pagination_schema.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,97 @@ | |||||||
|  | """Swagger Pagination Schema class""" | ||||||
|  | from typing import OrderedDict | ||||||
|  |  | ||||||
|  | from drf_yasg import openapi | ||||||
|  | from drf_yasg.inspectors import PaginatorInspector | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PaginationInspector(PaginatorInspector): | ||||||
|  |     """Swagger Pagination Schema class""" | ||||||
|  |  | ||||||
|  |     def get_paginated_response(self, paginator, response_schema): | ||||||
|  |         """ | ||||||
|  |         :param BasePagination paginator: the paginator | ||||||
|  |         :param openapi.Schema response_schema: the response schema that must be paged. | ||||||
|  |         :rtype: openapi.Schema | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return openapi.Schema( | ||||||
|  |             type=openapi.TYPE_OBJECT, | ||||||
|  |             properties=OrderedDict( | ||||||
|  |                 ( | ||||||
|  |                     ( | ||||||
|  |                         "pagination", | ||||||
|  |                         openapi.Schema( | ||||||
|  |                             type=openapi.TYPE_OBJECT, | ||||||
|  |                             properties=OrderedDict( | ||||||
|  |                                 ( | ||||||
|  |                                     ("next", openapi.Schema(type=openapi.TYPE_NUMBER)), | ||||||
|  |                                     ( | ||||||
|  |                                         "previous", | ||||||
|  |                                         openapi.Schema(type=openapi.TYPE_NUMBER), | ||||||
|  |                                     ), | ||||||
|  |                                     ("count", openapi.Schema(type=openapi.TYPE_NUMBER)), | ||||||
|  |                                     ( | ||||||
|  |                                         "current", | ||||||
|  |                                         openapi.Schema(type=openapi.TYPE_NUMBER), | ||||||
|  |                                     ), | ||||||
|  |                                     ( | ||||||
|  |                                         "total_pages", | ||||||
|  |                                         openapi.Schema(type=openapi.TYPE_NUMBER), | ||||||
|  |                                     ), | ||||||
|  |                                     ( | ||||||
|  |                                         "start_index", | ||||||
|  |                                         openapi.Schema(type=openapi.TYPE_NUMBER), | ||||||
|  |                                     ), | ||||||
|  |                                     ( | ||||||
|  |                                         "end_index", | ||||||
|  |                                         openapi.Schema(type=openapi.TYPE_NUMBER), | ||||||
|  |                                     ), | ||||||
|  |                                 ) | ||||||
|  |                             ), | ||||||
|  |                             required=[ | ||||||
|  |                                 "next", | ||||||
|  |                                 "previous", | ||||||
|  |                                 "count", | ||||||
|  |                                 "current", | ||||||
|  |                                 "total_pages", | ||||||
|  |                                 "start_index", | ||||||
|  |                                 "end_index", | ||||||
|  |                             ], | ||||||
|  |                         ), | ||||||
|  |                     ), | ||||||
|  |                     ("results", response_schema), | ||||||
|  |                 ) | ||||||
|  |             ), | ||||||
|  |             required=["results", "pagination"], | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def get_paginator_parameters(self, paginator): | ||||||
|  |         """ | ||||||
|  |         Get the pagination parameters for a single paginator **instance**. | ||||||
|  |  | ||||||
|  |         Should return :data:`.NotHandled` if this inspector | ||||||
|  |         does not know how to handle the given `paginator`. | ||||||
|  |  | ||||||
|  |         :param BasePagination paginator: the paginator | ||||||
|  |         :rtype: list[openapi.Parameter] | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return [ | ||||||
|  |             openapi.Parameter( | ||||||
|  |                 "page", | ||||||
|  |                 openapi.IN_QUERY, | ||||||
|  |                 "Page Index", | ||||||
|  |                 False, | ||||||
|  |                 None, | ||||||
|  |                 openapi.TYPE_INTEGER, | ||||||
|  |             ), | ||||||
|  |             openapi.Parameter( | ||||||
|  |                 "page_size", | ||||||
|  |                 openapi.IN_QUERY, | ||||||
|  |                 "Page Size", | ||||||
|  |                 False, | ||||||
|  |                 None, | ||||||
|  |                 openapi.TYPE_INTEGER, | ||||||
|  |             ), | ||||||
|  |         ] | ||||||
| @ -1,77 +1,102 @@ | |||||||
| """Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224""" | """Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224""" | ||||||
| from django.utils.translation import gettext_lazy as _ | from drf_yasg import openapi | ||||||
| from drf_spectacular.plumbing import ( | from drf_yasg.inspectors.view import SwaggerAutoSchema | ||||||
|     ResolvedComponent, | from drf_yasg.utils import force_real_str, is_list_view | ||||||
|     build_array_type, | from rest_framework import exceptions, status | ||||||
|     build_basic_type, | from rest_framework.settings import api_settings | ||||||
|     build_object_type, |  | ||||||
| ) |  | ||||||
| from drf_spectacular.settings import spectacular_settings |  | ||||||
| from drf_spectacular.types import OpenApiTypes |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def build_standard_type(obj, **kwargs): | class ErrorResponseAutoSchema(SwaggerAutoSchema): | ||||||
|     """Build a basic type with optional add ons.""" |     """Inspector which includes an error schema""" | ||||||
|     schema = build_basic_type(obj) |  | ||||||
|     schema.update(kwargs) |  | ||||||
|     return schema |  | ||||||
|  |  | ||||||
|  |     def get_generic_error_schema(self): | ||||||
| GENERIC_ERROR = build_object_type( |         """Get a generic error schema""" | ||||||
|     description=_("Generic API Error"), |         return openapi.Schema( | ||||||
|  |             "Generic API Error", | ||||||
|  |             type=openapi.TYPE_OBJECT, | ||||||
|             properties={ |             properties={ | ||||||
|         "detail": build_standard_type(OpenApiTypes.STR), |                 "detail": openapi.Schema( | ||||||
|         "code": build_standard_type(OpenApiTypes.STR), |                     type=openapi.TYPE_STRING, description="Error details" | ||||||
|  |                 ), | ||||||
|  |                 "code": openapi.Schema( | ||||||
|  |                     type=openapi.TYPE_STRING, description="Error code" | ||||||
|  |                 ), | ||||||
|             }, |             }, | ||||||
|             required=["detail"], |             required=["detail"], | ||||||
| ) |         ) | ||||||
| VALIDATION_ERROR = build_object_type( |  | ||||||
|     description=_("Validation Error"), |     def get_validation_error_schema(self): | ||||||
|  |         """Get a generic validation error schema""" | ||||||
|  |         return openapi.Schema( | ||||||
|  |             "Validation Error", | ||||||
|  |             type=openapi.TYPE_OBJECT, | ||||||
|             properties={ |             properties={ | ||||||
|         "non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)), |                 api_settings.NON_FIELD_ERRORS_KEY: openapi.Schema( | ||||||
|         "code": build_standard_type(OpenApiTypes.STR), |                     description="List of validation errors not related to any field", | ||||||
|  |                     type=openapi.TYPE_ARRAY, | ||||||
|  |                     items=openapi.Schema(type=openapi.TYPE_STRING), | ||||||
|  |                 ), | ||||||
|             }, |             }, | ||||||
|     required=["detail"], |             additional_properties=openapi.Schema( | ||||||
|     additionalProperties={}, |                 description=( | ||||||
| ) |                     "A list of error messages for each " | ||||||
|  |                     "field that triggered a validation error" | ||||||
|  |                 ), | ||||||
| def postprocess_schema_responses(result, generator, **kwargs):  # noqa: W0613 |                 type=openapi.TYPE_ARRAY, | ||||||
|     """Workaround to set a default response for endpoints. |                 items=openapi.Schema(type=openapi.TYPE_STRING), | ||||||
|     Workaround suggested at |             ), | ||||||
|     <https://github.com/tfranzel/drf-spectacular/issues/119#issuecomment-656970357> |  | ||||||
|     for the missing drf-spectacular feature discussed in |  | ||||||
|     <https://github.com/tfranzel/drf-spectacular/issues/101>. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     def create_component(name, schema, type_=ResolvedComponent.SCHEMA): |  | ||||||
|         """Register a component and return a reference to it.""" |  | ||||||
|         component = ResolvedComponent( |  | ||||||
|             name=name, |  | ||||||
|             type=type_, |  | ||||||
|             schema=schema, |  | ||||||
|             object=name, |  | ||||||
|         ) |  | ||||||
|         generator.registry.register_on_missing(component) |  | ||||||
|         return component |  | ||||||
|  |  | ||||||
|     generic_error = create_component("GenericError", GENERIC_ERROR) |  | ||||||
|     validation_error = create_component("ValidationError", VALIDATION_ERROR) |  | ||||||
|  |  | ||||||
|     for path in result["paths"].values(): |  | ||||||
|         for method in path.values(): |  | ||||||
|             method["responses"].setdefault("400", validation_error.ref) |  | ||||||
|             method["responses"].setdefault("403", generic_error.ref) |  | ||||||
|  |  | ||||||
|     result["components"] = generator.registry.build( |  | ||||||
|         spectacular_settings.APPEND_COMPONENTS |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     # This is a workaround for authentik/stages/prompt/stage.py |     def get_response_serializers(self): | ||||||
|     # since the serializer PromptChallengeResponse |         responses = super().get_response_serializers() | ||||||
|     # accepts dynamic keys |         definitions = self.components.with_scope( | ||||||
|     for component in result["components"]["schemas"]: |             openapi.SCHEMA_DEFINITIONS | ||||||
|         if component == "PromptChallengeResponseRequest": |         )  # type: openapi.ReferenceResolver | ||||||
|             comp = result["components"]["schemas"][component] |  | ||||||
|             comp["additionalProperties"] = {} |         definitions.setdefault("GenericError", self.get_generic_error_schema) | ||||||
|     return result |         definitions.setdefault("ValidationError", self.get_validation_error_schema) | ||||||
|  |         definitions.setdefault("APIException", self.get_generic_error_schema) | ||||||
|  |  | ||||||
|  |         if self.get_request_serializer() or self.get_query_serializer(): | ||||||
|  |             responses.setdefault( | ||||||
|  |                 exceptions.ValidationError.status_code, | ||||||
|  |                 openapi.Response( | ||||||
|  |                     description=force_real_str( | ||||||
|  |                         exceptions.ValidationError.default_detail | ||||||
|  |                     ), | ||||||
|  |                     schema=openapi.SchemaRef(definitions, "ValidationError"), | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         security = self.get_security() | ||||||
|  |         if security is None or len(security) > 0: | ||||||
|  |             # Note: 401 error codes are coerced  into 403 see | ||||||
|  |             # rest_framework/views.py:433:handle_exception | ||||||
|  |             # This is b/c the API uses token auth which doesn't have WWW-Authenticate header | ||||||
|  |             responses.setdefault( | ||||||
|  |                 status.HTTP_403_FORBIDDEN, | ||||||
|  |                 openapi.Response( | ||||||
|  |                     description="Authentication credentials were invalid, absent or insufficient.", | ||||||
|  |                     schema=openapi.SchemaRef(definitions, "GenericError"), | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |         if not is_list_view(self.path, self.method, self.view): | ||||||
|  |             responses.setdefault( | ||||||
|  |                 exceptions.PermissionDenied.status_code, | ||||||
|  |                 openapi.Response( | ||||||
|  |                     description="Permission denied.", | ||||||
|  |                     schema=openapi.SchemaRef(definitions, "APIException"), | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |             responses.setdefault( | ||||||
|  |                 exceptions.NotFound.status_code, | ||||||
|  |                 openapi.Response( | ||||||
|  |                     description=( | ||||||
|  |                         "Object does not exist or caller " | ||||||
|  |                         "has insufficient permissions to access it." | ||||||
|  |                     ), | ||||||
|  |                     schema=openapi.SchemaRef(definitions, "APIException"), | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         return responses | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| 
 | 
 | ||||||
| {% block title %} | {% block title %} | ||||||
| API Browser - {{ tenant.branding_title }} | authentik API Browser | ||||||
| {% endblock %} | {% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block head %} | {% block head %} | ||||||
| @ -5,7 +5,7 @@ from django.test import TestCase | |||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
| from rest_framework.exceptions import AuthenticationFailed | from rest_framework.exceptions import AuthenticationFailed | ||||||
|  |  | ||||||
| from authentik.api.authentication import token_from_header | from authentik.api.auth import token_from_header | ||||||
| from authentik.core.models import Token, TokenIntents | from authentik.core.models import Token, TokenIntents | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -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:config"), |  | ||||||
|         ) |  | ||||||
|         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) |  | ||||||
| @ -1,22 +0,0 @@ | |||||||
| """Schema generation tests""" |  | ||||||
| from django.urls import reverse |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
| from yaml import safe_load |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestSchemaGeneration(APITestCase): |  | ||||||
|     """Generic admin tests""" |  | ||||||
|  |  | ||||||
|     def test_schema(self): |  | ||||||
|         """Test generation""" |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse("authentik_api:schema"), |  | ||||||
|         ) |  | ||||||
|         self.assertTrue(safe_load(response.content.decode())) |  | ||||||
|  |  | ||||||
|     def test_browser(self): |  | ||||||
|         """Test API Browser""" |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse("authentik_api:schema-browser"), |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
							
								
								
									
										24
									
								
								authentik/api/tests/test_swagger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								authentik/api/tests/test_swagger.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | """Swagger generation tests""" | ||||||
|  | from json import loads | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  | from yaml import safe_load | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSwaggerGeneration(APITestCase): | ||||||
|  |     """Generic admin tests""" | ||||||
|  |  | ||||||
|  |     def test_yaml(self): | ||||||
|  |         """Test YAML generation""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:schema-json", kwargs={"format": ".yaml"}), | ||||||
|  |         ) | ||||||
|  |         self.assertTrue(safe_load(response.content.decode())) | ||||||
|  |  | ||||||
|  |     def test_json(self): | ||||||
|  |         """Test JSON generation""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:schema-json", kwargs={"format": ".json"}), | ||||||
|  |         ) | ||||||
|  |         self.assertTrue(loads(response.content.decode())) | ||||||
| @ -1,70 +1,50 @@ | |||||||
| """core Configs API""" | """core Configs API""" | ||||||
| from os import environ, path | from drf_yasg.utils import swagger_auto_schema | ||||||
|  | from rest_framework.fields import BooleanField, CharField, ListField | ||||||
| from django.conf import settings |  | ||||||
| from django.db import models |  | ||||||
| from drf_spectacular.utils import extend_schema |  | ||||||
| from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME |  | ||||||
| from rest_framework.fields import BooleanField, CharField, ChoiceField, ListField |  | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.viewsets import ViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.events.geo import GEOIP_READER |  | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  |  | ||||||
|  |  | ||||||
| class Capabilities(models.TextChoices): | class FooterLinkSerializer(PassiveSerializer): | ||||||
|     """Define capabilities which influence which APIs can/should be used""" |     """Links returned in Config API""" | ||||||
|  |  | ||||||
|     CAN_SAVE_MEDIA = "can_save_media" |     href = CharField(read_only=True) | ||||||
|     CAN_GEO_IP = "can_geo_ip" |     name = CharField(read_only=True) | ||||||
|     CAN_BACKUP = "can_backup" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ConfigSerializer(PassiveSerializer): | class ConfigSerializer(PassiveSerializer): | ||||||
|     """Serialize authentik Config into DRF Object""" |     """Serialize authentik Config into DRF Object""" | ||||||
|  |  | ||||||
|  |     branding_logo = CharField(read_only=True) | ||||||
|  |     branding_title = CharField(read_only=True) | ||||||
|  |     ui_footer_links = ListField(child=FooterLinkSerializer(), read_only=True) | ||||||
|  |  | ||||||
|     error_reporting_enabled = BooleanField(read_only=True) |     error_reporting_enabled = BooleanField(read_only=True) | ||||||
|     error_reporting_environment = CharField(read_only=True) |     error_reporting_environment = CharField(read_only=True) | ||||||
|     error_reporting_send_pii = BooleanField(read_only=True) |     error_reporting_send_pii = BooleanField(read_only=True) | ||||||
|  |  | ||||||
|     capabilities = ListField(child=ChoiceField(choices=Capabilities.choices)) |  | ||||||
|  |  | ||||||
|  | class ConfigsViewSet(ViewSet): | ||||||
| class ConfigView(APIView): |  | ||||||
|     """Read-only view set that returns the current session's Configs""" |     """Read-only view set that returns the current session's Configs""" | ||||||
|  |  | ||||||
|     permission_classes = [AllowAny] |     permission_classes = [AllowAny] | ||||||
|  |  | ||||||
|     def get_capabilities(self) -> list[Capabilities]: |     @swagger_auto_schema(responses={200: ConfigSerializer(many=False)}) | ||||||
|         """Get all capabilities this server instance supports""" |     def list(self, request: Request) -> Response: | ||||||
|         caps = [] |  | ||||||
|         deb_test = settings.DEBUG or settings.TEST |  | ||||||
|         if path.ismount(settings.MEDIA_ROOT) or deb_test: |  | ||||||
|             caps.append(Capabilities.CAN_SAVE_MEDIA) |  | ||||||
|         if GEOIP_READER.enabled: |  | ||||||
|             caps.append(Capabilities.CAN_GEO_IP) |  | ||||||
|         if SERVICE_HOST_ENV_NAME in environ: |  | ||||||
|             # Running in k8s, only s3 backup is supported |  | ||||||
|             if CONFIG.y("postgresql.s3_backup"): |  | ||||||
|                 caps.append(Capabilities.CAN_BACKUP) |  | ||||||
|         else: |  | ||||||
|             # Running in compose, backup is always supported |  | ||||||
|             caps.append(Capabilities.CAN_BACKUP) |  | ||||||
|         return caps |  | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: ConfigSerializer(many=False)}) |  | ||||||
|     def get(self, request: Request) -> Response: |  | ||||||
|         """Retrive public configuration options""" |         """Retrive public configuration options""" | ||||||
|         config = ConfigSerializer( |         config = ConfigSerializer( | ||||||
|             { |             { | ||||||
|  |                 "branding_logo": CONFIG.y("authentik.branding.logo"), | ||||||
|  |                 "branding_title": CONFIG.y("authentik.branding.title"), | ||||||
|                 "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), |                 "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), | ||||||
|                 "error_reporting_environment": CONFIG.y("error_reporting.environment"), |                 "error_reporting_environment": CONFIG.y("error_reporting.environment"), | ||||||
|                 "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"), |                 "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"), | ||||||
|                 "capabilities": self.get_capabilities(), |                 "ui_footer_links": CONFIG.y("authentik.footer_links"), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|         return Response(config.data) |         return Response(config.data) | ||||||
|  | |||||||
| @ -1,18 +1,18 @@ | |||||||
| """api v2 urls""" | """api v2 urls""" | ||||||
| from django.urls import path | from django.urls import path, re_path | ||||||
| from drf_spectacular.views import SpectacularAPIView | from drf_yasg import openapi | ||||||
|  | from drf_yasg.views import get_schema_view | ||||||
| from rest_framework import routers | from rest_framework import routers | ||||||
|  | from rest_framework.permissions import AllowAny | ||||||
|  |  | ||||||
| from authentik.admin.api.meta import AppsViewSet | from authentik.admin.api.meta import AppsViewSet | ||||||
| from authentik.admin.api.metrics import AdministrationMetricsViewSet | from authentik.admin.api.metrics import AdministrationMetricsViewSet | ||||||
| from authentik.admin.api.system import SystemView |  | ||||||
| from authentik.admin.api.tasks import TaskViewSet | from authentik.admin.api.tasks import TaskViewSet | ||||||
| from authentik.admin.api.version import VersionView | from authentik.admin.api.version import VersionViewSet | ||||||
| from authentik.admin.api.workers import WorkerView | from authentik.admin.api.workers import WorkerViewSet | ||||||
| from authentik.api.v2.config import ConfigView | from authentik.api.v2.config import ConfigsViewSet | ||||||
| from authentik.api.views import APIBrowserView | from authentik.api.views import SwaggerView | ||||||
| from authentik.core.api.applications import ApplicationViewSet | from authentik.core.api.applications import ApplicationViewSet | ||||||
| from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet |  | ||||||
| from authentik.core.api.groups import GroupViewSet | from authentik.core.api.groups import GroupViewSet | ||||||
| from authentik.core.api.propertymappings import PropertyMappingViewSet | from authentik.core.api.propertymappings import PropertyMappingViewSet | ||||||
| from authentik.core.api.providers import ProviderViewSet | from authentik.core.api.providers import ProviderViewSet | ||||||
| @ -28,12 +28,12 @@ from authentik.flows.api.bindings import FlowStageBindingViewSet | |||||||
| from authentik.flows.api.flows import FlowViewSet | from authentik.flows.api.flows import FlowViewSet | ||||||
| from authentik.flows.api.stages import StageViewSet | from authentik.flows.api.stages import StageViewSet | ||||||
| from authentik.flows.views import FlowExecutorView | from authentik.flows.views import FlowExecutorView | ||||||
| from authentik.outposts.api.outposts import OutpostViewSet | from authentik.outposts.api.outpost_service_connections import ( | ||||||
| from authentik.outposts.api.service_connections import ( |  | ||||||
|     DockerServiceConnectionViewSet, |     DockerServiceConnectionViewSet, | ||||||
|     KubernetesServiceConnectionViewSet, |     KubernetesServiceConnectionViewSet, | ||||||
|     ServiceConnectionViewSet, |     ServiceConnectionViewSet, | ||||||
| ) | ) | ||||||
|  | from authentik.outposts.api.outposts import OutpostViewSet | ||||||
| from authentik.policies.api.bindings import PolicyBindingViewSet | from authentik.policies.api.bindings import PolicyBindingViewSet | ||||||
| from authentik.policies.api.policies import PolicyViewSet | from authentik.policies.api.policies import PolicyViewSet | ||||||
| from authentik.policies.dummy.api import DummyPolicyViewSet | from authentik.policies.dummy.api import DummyPolicyViewSet | ||||||
| @ -47,7 +47,6 @@ from authentik.policies.reputation.api import ( | |||||||
|     ReputationPolicyViewSet, |     ReputationPolicyViewSet, | ||||||
|     UserReputationViewSet, |     UserReputationViewSet, | ||||||
| ) | ) | ||||||
| from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet |  | ||||||
| from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet | from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet | ||||||
| from authentik.providers.oauth2.api.scope import ScopeMappingViewSet | from authentik.providers.oauth2.api.scope import ScopeMappingViewSet | ||||||
| from authentik.providers.oauth2.api.tokens import ( | from authentik.providers.oauth2.api.tokens import ( | ||||||
| @ -64,13 +63,7 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet | |||||||
| from authentik.sources.oauth.api.source_connection import ( | from authentik.sources.oauth.api.source_connection import ( | ||||||
|     UserOAuthSourceConnectionViewSet, |     UserOAuthSourceConnectionViewSet, | ||||||
| ) | ) | ||||||
| from authentik.sources.plex.api import PlexSourceViewSet |  | ||||||
| from authentik.sources.saml.api import SAMLSourceViewSet | from authentik.sources.saml.api import SAMLSourceViewSet | ||||||
| from authentik.stages.authenticator_duo.api import ( |  | ||||||
|     AuthenticatorDuoStageViewSet, |  | ||||||
|     DuoAdminDeviceViewSet, |  | ||||||
|     DuoDeviceViewSet, |  | ||||||
| ) |  | ||||||
| from authentik.stages.authenticator_static.api import ( | from authentik.stages.authenticator_static.api import ( | ||||||
|     AuthenticatorStaticStageViewSet, |     AuthenticatorStaticStageViewSet, | ||||||
|     StaticAdminDeviceViewSet, |     StaticAdminDeviceViewSet, | ||||||
| @ -102,21 +95,24 @@ from authentik.stages.user_delete.api import UserDeleteStageViewSet | |||||||
| from authentik.stages.user_login.api import UserLoginStageViewSet | from authentik.stages.user_login.api import UserLoginStageViewSet | ||||||
| from authentik.stages.user_logout.api import UserLogoutStageViewSet | from authentik.stages.user_logout.api import UserLogoutStageViewSet | ||||||
| from authentik.stages.user_write.api import UserWriteStageViewSet | from authentik.stages.user_write.api import UserWriteStageViewSet | ||||||
| from authentik.tenants.api import TenantViewSet |  | ||||||
|  |  | ||||||
| router = routers.DefaultRouter() | router = routers.DefaultRouter() | ||||||
|  |  | ||||||
|  | router.register("root/config", ConfigsViewSet, basename="configs") | ||||||
|  |  | ||||||
|  | router.register("admin/version", VersionViewSet, basename="admin_version") | ||||||
|  | router.register("admin/workers", WorkerViewSet, basename="admin_workers") | ||||||
|  | router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics") | ||||||
| router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | ||||||
| router.register("admin/apps", AppsViewSet, basename="apps") | router.register("admin/apps", AppsViewSet, basename="apps") | ||||||
|  |  | ||||||
| router.register("core/authenticated_sessions", AuthenticatedSessionViewSet) |  | ||||||
| router.register("core/applications", ApplicationViewSet) | router.register("core/applications", ApplicationViewSet) | ||||||
| router.register("core/groups", GroupViewSet) | router.register("core/groups", GroupViewSet) | ||||||
| router.register("core/users", UserViewSet) | router.register("core/users", UserViewSet) | ||||||
| router.register("core/user_consent", UserConsentViewSet) | router.register("core/user_consent", UserConsentViewSet) | ||||||
| router.register("core/tokens", TokenViewSet) | router.register("core/tokens", TokenViewSet) | ||||||
| router.register("core/tenants", TenantViewSet) |  | ||||||
|  |  | ||||||
|  | router.register("outposts/outposts", OutpostViewSet) | ||||||
| router.register("outposts/instances", OutpostViewSet) | router.register("outposts/instances", OutpostViewSet) | ||||||
| router.register("outposts/service_connections/all", ServiceConnectionViewSet) | router.register("outposts/service_connections/all", ServiceConnectionViewSet) | ||||||
| router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) | router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) | ||||||
| @ -124,7 +120,6 @@ router.register( | |||||||
|     "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet |     "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet | ||||||
| ) | ) | ||||||
| router.register("outposts/proxy", ProxyOutpostConfigViewSet) | router.register("outposts/proxy", ProxyOutpostConfigViewSet) | ||||||
| router.register("outposts/ldap", LDAPOutpostConfigViewSet) |  | ||||||
|  |  | ||||||
| router.register("flows/instances", FlowViewSet) | router.register("flows/instances", FlowViewSet) | ||||||
| router.register("flows/bindings", FlowStageBindingViewSet) | router.register("flows/bindings", FlowStageBindingViewSet) | ||||||
| @ -141,7 +136,6 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS | |||||||
| router.register("sources/ldap", LDAPSourceViewSet) | router.register("sources/ldap", LDAPSourceViewSet) | ||||||
| router.register("sources/saml", SAMLSourceViewSet) | router.register("sources/saml", SAMLSourceViewSet) | ||||||
| router.register("sources/oauth", OAuthSourceViewSet) | router.register("sources/oauth", OAuthSourceViewSet) | ||||||
| router.register("sources/plex", PlexSourceViewSet) |  | ||||||
|  |  | ||||||
| router.register("policies/all", PolicyViewSet) | router.register("policies/all", PolicyViewSet) | ||||||
| router.register("policies/bindings", PolicyBindingViewSet) | router.register("policies/bindings", PolicyBindingViewSet) | ||||||
| @ -155,7 +149,6 @@ router.register("policies/reputation/ips", IPReputationViewSet) | |||||||
| router.register("policies/reputation", ReputationPolicyViewSet) | router.register("policies/reputation", ReputationPolicyViewSet) | ||||||
|  |  | ||||||
| router.register("providers/all", ProviderViewSet) | router.register("providers/all", ProviderViewSet) | ||||||
| router.register("providers/ldap", LDAPProviderViewSet) |  | ||||||
| router.register("providers/proxy", ProxyProviderViewSet) | router.register("providers/proxy", ProxyProviderViewSet) | ||||||
| router.register("providers/oauth2", OAuth2ProviderViewSet) | router.register("providers/oauth2", OAuth2ProviderViewSet) | ||||||
| router.register("providers/saml", SAMLProviderViewSet) | router.register("providers/saml", SAMLProviderViewSet) | ||||||
| @ -168,31 +161,14 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) | |||||||
| router.register("propertymappings/saml", SAMLPropertyMappingViewSet) | router.register("propertymappings/saml", SAMLPropertyMappingViewSet) | ||||||
| router.register("propertymappings/scope", ScopeMappingViewSet) | router.register("propertymappings/scope", ScopeMappingViewSet) | ||||||
|  |  | ||||||
| router.register("authenticators/duo", DuoDeviceViewSet) |  | ||||||
| router.register("authenticators/static", StaticDeviceViewSet) | router.register("authenticators/static", StaticDeviceViewSet) | ||||||
| router.register("authenticators/totp", TOTPDeviceViewSet) | router.register("authenticators/totp", TOTPDeviceViewSet) | ||||||
| router.register("authenticators/webauthn", WebAuthnDeviceViewSet) | router.register("authenticators/webauthn", WebAuthnDeviceViewSet) | ||||||
| router.register( | router.register("authenticators/admin/static", StaticAdminDeviceViewSet) | ||||||
|     "authenticators/admin/duo", | router.register("authenticators/admin/totp", TOTPAdminDeviceViewSet) | ||||||
|     DuoAdminDeviceViewSet, | router.register("authenticators/admin/webauthn", WebAuthnAdminDeviceViewSet) | ||||||
|     basename="admin-duodevice", |  | ||||||
| ) |  | ||||||
| router.register( |  | ||||||
|     "authenticators/admin/static", |  | ||||||
|     StaticAdminDeviceViewSet, |  | ||||||
|     basename="admin-staticdevice", |  | ||||||
| ) |  | ||||||
| router.register( |  | ||||||
|     "authenticators/admin/totp", TOTPAdminDeviceViewSet, basename="admin-totpdevice" |  | ||||||
| ) |  | ||||||
| router.register( |  | ||||||
|     "authenticators/admin/webauthn", |  | ||||||
|     WebAuthnAdminDeviceViewSet, |  | ||||||
|     basename="admin-webauthndevice", |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| router.register("stages/all", StageViewSet) | router.register("stages/all", StageViewSet) | ||||||
| router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet) |  | ||||||
| router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet) | router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet) | ||||||
| router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet) | router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet) | ||||||
| router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet) | router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet) | ||||||
| @ -215,26 +191,32 @@ router.register("stages/user_write", UserWriteStageViewSet) | |||||||
| router.register("stages/dummy", DummyStageViewSet) | router.register("stages/dummy", DummyStageViewSet) | ||||||
| router.register("policies/dummy", DummyPolicyViewSet) | router.register("policies/dummy", DummyPolicyViewSet) | ||||||
|  |  | ||||||
|  | info = openapi.Info( | ||||||
|  |     title="authentik API", | ||||||
|  |     default_version="v2beta", | ||||||
|  |     contact=openapi.Contact(email="hello@beryju.org"), | ||||||
|  |     license=openapi.License( | ||||||
|  |         name="GNU GPLv3", | ||||||
|  |         url="https://github.com/goauthentik/authentik/blob/master/LICENSE", | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  | SchemaView = get_schema_view(info, public=True, permission_classes=(AllowAny,)) | ||||||
|  |  | ||||||
| urlpatterns = ( | urlpatterns = ( | ||||||
|     [ |     [ | ||||||
|         path("", APIBrowserView.as_view(), name="schema-browser"), |         path("", SwaggerView.as_view(), name="swagger"), | ||||||
|     ] |     ] | ||||||
|     + router.urls |     + router.urls | ||||||
|     + [ |     + [ | ||||||
|         path( |  | ||||||
|             "admin/metrics/", |  | ||||||
|             AdministrationMetricsViewSet.as_view(), |  | ||||||
|             name="admin_metrics", |  | ||||||
|         ), |  | ||||||
|         path("admin/version/", VersionView.as_view(), name="admin_version"), |  | ||||||
|         path("admin/workers/", WorkerView.as_view(), name="admin_workers"), |  | ||||||
|         path("admin/system/", SystemView.as_view(), name="admin_system"), |  | ||||||
|         path("root/config/", ConfigView.as_view(), name="config"), |  | ||||||
|         path( |         path( | ||||||
|             "flows/executor/<slug:flow_slug>/", |             "flows/executor/<slug:flow_slug>/", | ||||||
|             FlowExecutorView.as_view(), |             FlowExecutorView.as_view(), | ||||||
|             name="flow-executor", |             name="flow-executor", | ||||||
|         ), |         ), | ||||||
|         path("schema/", SpectacularAPIView.as_view(), name="schema"), |         re_path( | ||||||
|  |             r"^swagger(?P<format>\.json|\.yaml)$", | ||||||
|  |             SchemaView.without_ui(cache_timeout=0), | ||||||
|  |             name="schema-json", | ||||||
|  |         ), | ||||||
|     ] |     ] | ||||||
| ) | ) | ||||||
|  | |||||||
| @ -5,15 +5,18 @@ from django.urls import reverse | |||||||
| from django.views.generic import TemplateView | from django.views.generic import TemplateView | ||||||
|  |  | ||||||
|  |  | ||||||
| class APIBrowserView(TemplateView): | class SwaggerView(TemplateView): | ||||||
|     """Show browser view based on rapi-doc""" |     """Show swagger view based on rapi-doc""" | ||||||
|  |  | ||||||
|     template_name = "api/browser.html" |     template_name = "api/swagger.html" | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: |     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||||
|         path = self.request.build_absolute_uri( |         path = self.request.build_absolute_uri( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:schema", |                 "authentik_api:schema-json", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "format": ".json", | ||||||
|  |                 }, | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         return super().get_context_data(path=path, **kwargs) |         return super().get_context_data(path=path, **kwargs) | ||||||
|  | |||||||
| @ -1,23 +1,13 @@ | |||||||
| """Application API Views""" | """Application API Views""" | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.http.response import HttpResponseBadRequest | from django.http.response import HttpResponseBadRequest | ||||||
| from django.shortcuts import get_object_or_404 | from drf_yasg import openapi | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_yasg.utils import no_body, swagger_auto_schema | ||||||
| from drf_spectacular.utils import ( |  | ||||||
|     OpenApiParameter, |  | ||||||
|     OpenApiResponse, |  | ||||||
|     extend_schema, |  | ||||||
|     inline_serializer, |  | ||||||
| ) |  | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import ( | from rest_framework.fields import SerializerMethodField | ||||||
|     BooleanField, |  | ||||||
|     CharField, |  | ||||||
|     FileField, |  | ||||||
|     IntegerField, |  | ||||||
|     ReadOnlyField, |  | ||||||
| ) |  | ||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -29,13 +19,9 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.models import Application | ||||||
| from authentik.core.models import Application, User |  | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
| from authentik.policies.api.exec import PolicyTestResultSerializer |  | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.types import PolicyResult |  | ||||||
| from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -48,10 +34,12 @@ def user_app_cache_key(user_pk: str) -> str: | |||||||
| class ApplicationSerializer(ModelSerializer): | class ApplicationSerializer(ModelSerializer): | ||||||
|     """Application Serializer""" |     """Application Serializer""" | ||||||
|  |  | ||||||
|     launch_url = ReadOnlyField(source="get_launch_url") |     launch_url = SerializerMethodField() | ||||||
|     provider_obj = ProviderSerializer(source="get_provider", required=False) |     provider_obj = ProviderSerializer(source="get_provider", required=False) | ||||||
|  |  | ||||||
|     meta_icon = ReadOnlyField(source="get_meta_icon") |     def get_launch_url(self, instance: Application) -> Optional[str]: | ||||||
|  |         """Get generated launch URL""" | ||||||
|  |         return instance.get_launch_url() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -69,12 +57,9 @@ class ApplicationSerializer(ModelSerializer): | |||||||
|             "meta_publisher", |             "meta_publisher", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |  | ||||||
|             "meta_icon": {"read_only": True}, |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationViewSet(UsedByMixin, ModelViewSet): | class ApplicationViewSet(ModelViewSet): | ||||||
|     """Application Viewset""" |     """Application Viewset""" | ||||||
|  |  | ||||||
|     queryset = Application.objects.all() |     queryset = Application.objects.all() | ||||||
| @ -106,48 +91,17 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|                 applications.append(application) |                 applications.append(application) | ||||||
|         return applications |         return applications | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request=inline_serializer( |         manual_parameters=[ | ||||||
|             "CheckAccessRequest", fields={"for_user": IntegerField(required=False)} |             openapi.Parameter( | ||||||
|         ), |  | ||||||
|         responses={ |  | ||||||
|             200: PolicyTestResultSerializer(), |  | ||||||
|             404: OpenApiResponse(description="for_user user not found"), |  | ||||||
|         }, |  | ||||||
|     ) |  | ||||||
|     @action(detail=True, methods=["POST"]) |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def check_access(self, request: Request, slug: str) -> Response: |  | ||||||
|         """Check access to a single application by slug""" |  | ||||||
|         # Don't use self.get_object as that checks for view_application permission |  | ||||||
|         # which the user might not have, even if they have access |  | ||||||
|         application = get_object_or_404(Application, slug=slug) |  | ||||||
|         # If the current user is superuser, they can set `for_user` |  | ||||||
|         for_user = self.request.user |  | ||||||
|         if self.request.user.is_superuser and "for_user" in request.data: |  | ||||||
|             for_user = get_object_or_404(User, pk=request.data.get("for_user")) |  | ||||||
|         engine = PolicyEngine(application, for_user, self.request) |  | ||||||
|         engine.build() |  | ||||||
|         result = engine.result |  | ||||||
|         response = PolicyTestResultSerializer(PolicyResult(False)) |  | ||||||
|         if result.passing: |  | ||||||
|             response = PolicyTestResultSerializer(PolicyResult(True)) |  | ||||||
|         if self.request.user.is_superuser: |  | ||||||
|             response = PolicyTestResultSerializer(result) |  | ||||||
|         return Response(response.data) |  | ||||||
|  |  | ||||||
|     @extend_schema( |  | ||||||
|         parameters=[ |  | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="superuser_full_list", |                 name="superuser_full_list", | ||||||
|                 location=OpenApiParameter.QUERY, |                 in_=openapi.IN_QUERY, | ||||||
|                 type=OpenApiTypes.BOOL, |                 type=openapi.TYPE_BOOLEAN, | ||||||
|             ) |             ) | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Custom list method that checks Policy based access instead of guardian""" |         """Custom list method that checks Policy based access instead of guardian""" | ||||||
|         self.request.session.pop(USER_LOGIN_AUTHENTICATED, None) |  | ||||||
|         queryset = self._filter_queryset_for_list(self.get_queryset()) |         queryset = self._filter_queryset_for_list(self.get_queryset()) | ||||||
|         self.paginate_queryset(queryset) |         self.paginate_queryset(queryset) | ||||||
|  |  | ||||||
| @ -177,20 +131,17 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         return self.get_paginated_response(serializer.data) |         return self.get_paginated_response(serializer.data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.change_application") |     @permission_required("authentik_core.change_application") | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request={ |         request_body=no_body, | ||||||
|             "multipart/form-data": inline_serializer( |         manual_parameters=[ | ||||||
|                 "SetIcon", |             openapi.Parameter( | ||||||
|                 fields={ |                 name="file", | ||||||
|                     "file": FileField(required=False), |                 in_=openapi.IN_FORM, | ||||||
|                     "clear": BooleanField(default=False), |                 type=openapi.TYPE_FILE, | ||||||
|                 }, |                 required=True, | ||||||
|             ) |             ) | ||||||
|         }, |         ], | ||||||
|         responses={ |         responses={200: "Success", 400: "Bad request"}, | ||||||
|             200: OpenApiResponse(description="Success"), |  | ||||||
|             400: OpenApiResponse(description="Bad request"), |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     @action( |     @action( | ||||||
|         detail=True, |         detail=True, | ||||||
| @ -204,46 +155,16 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         """Set application icon""" |         """Set application icon""" | ||||||
|         app: Application = self.get_object() |         app: Application = self.get_object() | ||||||
|         icon = request.FILES.get("file", None) |         icon = request.FILES.get("file", None) | ||||||
|         clear = request.data.get("clear", False) |         if not icon: | ||||||
|         if clear: |             return HttpResponseBadRequest() | ||||||
|             # .delete() saves the model by default |  | ||||||
|             app.meta_icon.delete() |  | ||||||
|             return Response({}) |  | ||||||
|         if icon: |  | ||||||
|         app.meta_icon = icon |         app.meta_icon = icon | ||||||
|         app.save() |         app.save() | ||||||
|         return Response({}) |         return Response({}) | ||||||
|         return HttpResponseBadRequest() |  | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.change_application") |  | ||||||
|     @extend_schema( |  | ||||||
|         request=inline_serializer("SetIconURL", fields={"url": CharField()}), |  | ||||||
|         responses={ |  | ||||||
|             200: OpenApiResponse(description="Success"), |  | ||||||
|             400: OpenApiResponse(description="Bad request"), |  | ||||||
|         }, |  | ||||||
|     ) |  | ||||||
|     @action( |  | ||||||
|         detail=True, |  | ||||||
|         pagination_class=None, |  | ||||||
|         filter_backends=[], |  | ||||||
|         methods=["POST"], |  | ||||||
|     ) |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_icon_url(self, request: Request, slug: str): |  | ||||||
|         """Set application icon (as URL)""" |  | ||||||
|         app: Application = self.get_object() |  | ||||||
|         url = request.data.get("url", None) |  | ||||||
|         if url is None: |  | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|         app.meta_icon.name = url |  | ||||||
|         app.save() |  | ||||||
|         return Response({}) |  | ||||||
|  |  | ||||||
|     @permission_required( |     @permission_required( | ||||||
|         "authentik_core.view_application", ["authentik_events.view_event"] |         "authentik_core.view_application", ["authentik_events.view_event"] | ||||||
|     ) |     ) | ||||||
|     @extend_schema(responses={200: CoordinateSerializer(many=True)}) |     @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def metrics(self, request: Request, slug: str): |     def metrics(self, request: Request, slug: str): | ||||||
|  | |||||||
| @ -1,117 +0,0 @@ | |||||||
| """AuthenticatedSessions API Viewset""" |  | ||||||
| from typing import Optional, TypedDict |  | ||||||
|  |  | ||||||
| from django_filters.rest_framework import DjangoFilterBackend |  | ||||||
| from guardian.utils import get_anonymous_user |  | ||||||
| from rest_framework import mixins |  | ||||||
| from rest_framework.fields import SerializerMethodField |  | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter |  | ||||||
| from rest_framework.request import Request |  | ||||||
| from rest_framework.serializers import ModelSerializer |  | ||||||
| from rest_framework.viewsets import GenericViewSet |  | ||||||
| from ua_parser import user_agent_parser |  | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.models import AuthenticatedSession |  | ||||||
| from authentik.events.geo import GEOIP_READER, GeoIPDict |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserAgentDeviceDict(TypedDict): |  | ||||||
|     """User agent device""" |  | ||||||
|  |  | ||||||
|     brand: str |  | ||||||
|     family: str |  | ||||||
|     model: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserAgentOSDict(TypedDict): |  | ||||||
|     """User agent os""" |  | ||||||
|  |  | ||||||
|     family: str |  | ||||||
|     major: str |  | ||||||
|     minor: str |  | ||||||
|     patch: str |  | ||||||
|     patch_minor: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserAgentBrowserDict(TypedDict): |  | ||||||
|     """User agent browser""" |  | ||||||
|  |  | ||||||
|     family: str |  | ||||||
|     major: str |  | ||||||
|     minor: str |  | ||||||
|     patch: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserAgentDict(TypedDict): |  | ||||||
|     """User agent details""" |  | ||||||
|  |  | ||||||
|     device: UserAgentDeviceDict |  | ||||||
|     os: UserAgentOSDict |  | ||||||
|     user_agent: UserAgentBrowserDict |  | ||||||
|     string: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatedSessionSerializer(ModelSerializer): |  | ||||||
|     """AuthenticatedSession Serializer""" |  | ||||||
|  |  | ||||||
|     current = SerializerMethodField() |  | ||||||
|     user_agent = SerializerMethodField() |  | ||||||
|     geo_ip = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     def get_current(self, instance: AuthenticatedSession) -> bool: |  | ||||||
|         """Check if session is currently active session""" |  | ||||||
|         request: Request = self.context["request"] |  | ||||||
|         return request._request.session.session_key == instance.session_key |  | ||||||
|  |  | ||||||
|     def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict: |  | ||||||
|         """Get parsed user agent""" |  | ||||||
|         return user_agent_parser.Parse(instance.last_user_agent) |  | ||||||
|  |  | ||||||
|     def get_geo_ip( |  | ||||||
|         self, instance: AuthenticatedSession |  | ||||||
|     ) -> Optional[GeoIPDict]:  # pragma: no cover |  | ||||||
|         """Get parsed user agent""" |  | ||||||
|         return GEOIP_READER.city_dict(instance.last_ip) |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|  |  | ||||||
|         model = AuthenticatedSession |  | ||||||
|         fields = [ |  | ||||||
|             "uuid", |  | ||||||
|             "current", |  | ||||||
|             "user_agent", |  | ||||||
|             "geo_ip", |  | ||||||
|             "user", |  | ||||||
|             "last_ip", |  | ||||||
|             "last_user_agent", |  | ||||||
|             "last_used", |  | ||||||
|             "expires", |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatedSessionViewSet( |  | ||||||
|     mixins.RetrieveModelMixin, |  | ||||||
|     mixins.DestroyModelMixin, |  | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |  | ||||||
|     GenericViewSet, |  | ||||||
| ): |  | ||||||
|     """AuthenticatedSession Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = AuthenticatedSession.objects.all() |  | ||||||
|     serializer_class = AuthenticatedSessionSerializer |  | ||||||
|     search_fields = ["user__username", "last_ip", "last_user_agent"] |  | ||||||
|     filterset_fields = ["user__username", "last_ip", "last_user_agent"] |  | ||||||
|     ordering = ["user__username"] |  | ||||||
|     filter_backends = [ |  | ||||||
|         DjangoFilterBackend, |  | ||||||
|         OrderingFilter, |  | ||||||
|         SearchFilter, |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     def get_queryset(self): |  | ||||||
|         user = self.request.user if self.request else get_anonymous_user() |  | ||||||
|         if user.is_superuser: |  | ||||||
|             return super().get_queryset() |  | ||||||
|         return super().get_queryset().filter(user=user.pk) |  | ||||||
| @ -1,11 +1,8 @@ | |||||||
| """Groups API Viewset""" | """Groups API Viewset""" | ||||||
| from django.db.models.query import QuerySet |  | ||||||
| from rest_framework.fields import JSONField | from rest_framework.fields import JSONField | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter |  | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import is_dict | from authentik.core.api.utils import is_dict | ||||||
| from authentik.core.models import Group | from authentik.core.models import Group | ||||||
|  |  | ||||||
| @ -21,7 +18,7 @@ class GroupSerializer(ModelSerializer): | |||||||
|         fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] |         fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupViewSet(UsedByMixin, ModelViewSet): | class GroupViewSet(ModelViewSet): | ||||||
|     """Group Viewset""" |     """Group Viewset""" | ||||||
|  |  | ||||||
|     queryset = Group.objects.all() |     queryset = Group.objects.all() | ||||||
| @ -29,16 +26,3 @@ class GroupViewSet(UsedByMixin, ModelViewSet): | |||||||
|     search_fields = ["name", "is_superuser"] |     search_fields = ["name", "is_superuser"] | ||||||
|     filterset_fields = ["name", "is_superuser"] |     filterset_fields = ["name", "is_superuser"] | ||||||
|     ordering = ["name"] |     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) |  | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| """PropertyMapping API Views""" | """PropertyMapping API Views""" | ||||||
| from json import dumps | from json import dumps | ||||||
|  |  | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_yasg import openapi | ||||||
| from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | from drf_yasg.utils import swagger_auto_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| @ -14,7 +14,6 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField | |||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import ( | ||||||
|     MetaNameSerializer, |     MetaNameSerializer, | ||||||
|     PassiveSerializer, |     PassiveSerializer, | ||||||
| @ -66,7 +65,6 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri | |||||||
| class PropertyMappingViewSet( | class PropertyMappingViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -80,10 +78,10 @@ class PropertyMappingViewSet( | |||||||
|     filterset_fields = {"managed": ["isnull"]} |     filterset_fields = {"managed": ["isnull"]} | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self): | ||||||
|         return PropertyMapping.objects.select_subclasses() |         return PropertyMapping.objects.select_subclasses() | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable property-mapping types""" |         """Get all creatable property-mapping types""" | ||||||
| @ -102,17 +100,14 @@ class PropertyMappingViewSet( | |||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_propertymapping") |     @permission_required("authentik_core.view_propertymapping") | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request=PolicyTestSerializer(), |         request_body=PolicyTestSerializer(), | ||||||
|         responses={ |         responses={200: PropertyMappingTestResultSerializer, 400: "Invalid parameters"}, | ||||||
|             200: PropertyMappingTestResultSerializer, |         manual_parameters=[ | ||||||
|             400: OpenApiResponse(description="Invalid parameters"), |             openapi.Parameter( | ||||||
|         }, |  | ||||||
|         parameters=[ |  | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="format_result", |                 name="format_result", | ||||||
|                 location=OpenApiParameter.QUERY, |                 in_=openapi.IN_QUERY, | ||||||
|                 type=OpenApiTypes.BOOL, |                 type=openapi.TYPE_BOOLEAN, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """Provider API Views""" | """Provider API Views""" | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_spectacular.utils import extend_schema | from drf_yasg.utils import swagger_auto_schema | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import ReadOnlyField | from rest_framework.fields import ReadOnlyField | ||||||
| @ -9,7 +9,6 @@ from rest_framework.response import Response | |||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
| from authentik.lib.utils.reflection import all_subclasses | from authentik.lib.utils.reflection import all_subclasses | ||||||
| @ -23,7 +22,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|  |  | ||||||
|     component = SerializerMethodField() |     component = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_component(self, obj: Provider) -> str:  # pragma: no cover |     def get_component(self, obj: Provider):  # pragma: no cover | ||||||
|         """Get object component so that we know how to edit the object""" |         """Get object component so that we know how to edit the object""" | ||||||
|         # pyright: reportGeneralTypeIssues=false |         # pyright: reportGeneralTypeIssues=false | ||||||
|         if obj.__class__ == Provider: |         if obj.__class__ == Provider: | ||||||
| @ -49,7 +48,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | |||||||
| class ProviderViewSet( | class ProviderViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -65,10 +63,10 @@ class ProviderViewSet( | |||||||
|         "application__name", |         "application__name", | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self): | ||||||
|         return Provider.objects.select_subclasses() |         return Provider.objects.select_subclasses() | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable provider types""" |         """Get all creatable provider types""" | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Source API Views""" | """Source API Views""" | ||||||
| from typing import Iterable | from typing import Iterable | ||||||
|  |  | ||||||
| from drf_spectacular.utils import extend_schema | from drf_yasg.utils import swagger_auto_schema | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -10,7 +10,6 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField | |||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.models import Source | from authentik.core.models import Source | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| @ -25,7 +24,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|  |  | ||||||
|     component = SerializerMethodField() |     component = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_component(self, obj: Source) -> str: |     def get_component(self, obj: Source): | ||||||
|         """Get object component so that we know how to edit the object""" |         """Get object component so that we know how to edit the object""" | ||||||
|         # pyright: reportGeneralTypeIssues=false |         # pyright: reportGeneralTypeIssues=false | ||||||
|         if obj.__class__ == Source: |         if obj.__class__ == Source: | ||||||
| @ -46,14 +45,12 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|             "verbose_name", |             "verbose_name", | ||||||
|             "verbose_name_plural", |             "verbose_name_plural", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|             "user_matching_mode", |  | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class SourceViewSet( | class SourceViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -63,10 +60,10 @@ class SourceViewSet( | |||||||
|     serializer_class = SourceSerializer |     serializer_class = SourceSerializer | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self): | ||||||
|         return Source.objects.select_subclasses() |         return Source.objects.select_subclasses() | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable source types""" |         """Get all creatable source types""" | ||||||
| @ -89,7 +86,7 @@ class SourceViewSet( | |||||||
|             ) |             ) | ||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: UserSettingSerializer(many=True)}) |     @swagger_auto_schema(responses={200: UserSettingSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def user_settings(self, request: Request) -> Response: |     def user_settings(self, request: Request) -> Response: | ||||||
|         """Get all sources the user can configure""" |         """Get all sources the user can configure""" | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """Tokens API Viewset""" | """Tokens API Viewset""" | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | from drf_yasg.utils import swagger_auto_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -9,7 +9,6 @@ from rest_framework.serializers import ModelSerializer | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import Token, TokenIntents | from authentik.core.models import Token, TokenIntents | ||||||
| @ -44,7 +43,7 @@ class TokenViewSerializer(PassiveSerializer): | |||||||
|     key = CharField(read_only=True) |     key = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenViewSet(UsedByMixin, ModelViewSet): | class TokenViewSet(ModelViewSet): | ||||||
|     """Token Viewset""" |     """Token Viewset""" | ||||||
|  |  | ||||||
|     lookup_field = "identifier" |     lookup_field = "identifier" | ||||||
| @ -68,10 +67,10 @@ class TokenViewSet(UsedByMixin, ModelViewSet): | |||||||
|         serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API) |         serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_token_key") |     @permission_required("authentik_core.view_token_key") | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: TokenViewSerializer(many=False), |             200: TokenViewSerializer(many=False), | ||||||
|             404: OpenApiResponse(description="Token not found or expired"), |             404: "Token not found or expired", | ||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|  | |||||||
| @ -1,102 +0,0 @@ | |||||||
| """used_by mixin""" |  | ||||||
| from enum import Enum |  | ||||||
| from inspect import getmembers |  | ||||||
|  |  | ||||||
| from django.db.models.base import Model |  | ||||||
| from django.db.models.deletion import SET_DEFAULT, SET_NULL |  | ||||||
| from django.db.models.manager import Manager |  | ||||||
| from drf_spectacular.utils import extend_schema |  | ||||||
| from guardian.shortcuts import get_objects_for_user |  | ||||||
| from rest_framework.decorators import action |  | ||||||
| from rest_framework.fields import CharField, ChoiceField |  | ||||||
| from rest_framework.request import Request |  | ||||||
| from rest_framework.response import Response |  | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeleteAction(Enum): |  | ||||||
|     """Which action a delete will have on a used object""" |  | ||||||
|  |  | ||||||
|     CASCADE = "cascade" |  | ||||||
|     CASCADE_MANY = "cascade_many" |  | ||||||
|     SET_NULL = "set_null" |  | ||||||
|     SET_DEFAULT = "set_default" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UsedBySerializer(PassiveSerializer): |  | ||||||
|     """A list of all objects referencing the queried object""" |  | ||||||
|  |  | ||||||
|     app = CharField() |  | ||||||
|     model_name = CharField() |  | ||||||
|     pk = CharField() |  | ||||||
|     name = CharField() |  | ||||||
|     action = ChoiceField(choices=[(x.name, x.name) for x in DeleteAction]) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_delete_action(manager: Manager) -> str: |  | ||||||
|     """Get the delete action from the Foreign key, falls back to cascade""" |  | ||||||
|     if hasattr(manager, "field"): |  | ||||||
|         if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__: |  | ||||||
|             return DeleteAction.SET_NULL.name |  | ||||||
|         if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__: |  | ||||||
|             return DeleteAction.SET_DEFAULT.name |  | ||||||
|     if hasattr(manager, "source_field"): |  | ||||||
|         return DeleteAction.CASCADE_MANY.name |  | ||||||
|     return DeleteAction.CASCADE.name |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UsedByMixin: |  | ||||||
|     """Mixin to add a used_by endpoint to return a list of all objects using this object""" |  | ||||||
|  |  | ||||||
|     @extend_schema( |  | ||||||
|         responses={200: UsedBySerializer(many=True)}, |  | ||||||
|     ) |  | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |  | ||||||
|     # pylint: disable=invalid-name, unused-argument, too-many-locals |  | ||||||
|     def used_by(self, request: Request, *args, **kwargs) -> Response: |  | ||||||
|         """Get a list of all objects that use this object""" |  | ||||||
|         # pyright: reportGeneralTypeIssues=false |  | ||||||
|         model: Model = self.get_object() |  | ||||||
|         used_by = [] |  | ||||||
|         shadows = [] |  | ||||||
|         for attr_name, manager in getmembers(model, lambda x: isinstance(x, Manager)): |  | ||||||
|             if attr_name == "objects":  # pragma: no cover |  | ||||||
|                 continue |  | ||||||
|             manager: Manager |  | ||||||
|             if manager.model._meta.abstract: |  | ||||||
|                 continue |  | ||||||
|             app = manager.model._meta.app_label |  | ||||||
|             model_name = manager.model._meta.model_name |  | ||||||
|             delete_action = get_delete_action(manager) |  | ||||||
|  |  | ||||||
|             # To make sure we only apply shadows when there are any objects, |  | ||||||
|             # but so we only apply them once, have a simple flag for the first object |  | ||||||
|             first_object = True |  | ||||||
|  |  | ||||||
|             for obj in get_objects_for_user( |  | ||||||
|                 request.user, f"{app}.view_{model_name}", manager |  | ||||||
|             ).all(): |  | ||||||
|                 # Only merge shadows on first object |  | ||||||
|                 if first_object: |  | ||||||
|                     shadows += getattr( |  | ||||||
|                         manager.model._meta, "authentik_used_by_shadows", [] |  | ||||||
|                     ) |  | ||||||
|                 first_object = False |  | ||||||
|                 serializer = UsedBySerializer( |  | ||||||
|                     data={ |  | ||||||
|                         "app": app, |  | ||||||
|                         "model_name": model_name, |  | ||||||
|                         "pk": str(obj.pk), |  | ||||||
|                         "name": str(obj), |  | ||||||
|                         "action": delete_action, |  | ||||||
|                     } |  | ||||||
|                 ) |  | ||||||
|                 serializer.is_valid() |  | ||||||
|                 used_by.append(serializer.data) |  | ||||||
|         # Check the shadows map and remove anything that should be shadowed |  | ||||||
|         for idx, user in enumerate(used_by): |  | ||||||
|             full_model_name = f"{user['app']}.{user['model_name']}" |  | ||||||
|             if full_model_name in shadows: |  | ||||||
|                 del used_by[idx] |  | ||||||
|         return Response(used_by) |  | ||||||
| @ -1,31 +1,18 @@ | |||||||
| """User API Views""" | """User API Views""" | ||||||
| from json import loads |  | ||||||
|  |  | ||||||
| from django.db.models.query import QuerySet |  | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
| from django.urls import reverse_lazy | from django.urls import reverse_lazy | ||||||
| from django.utils.http import urlencode | from django.utils.http import urlencode | ||||||
| from django_filters.filters import BooleanFilter, CharFilter | from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method | ||||||
| from django_filters.filterset import FilterSet |  | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field |  | ||||||
| from guardian.utils import get_anonymous_user | from guardian.utils import get_anonymous_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, JSONField, SerializerMethodField | from rest_framework.fields import CharField, JSONField, SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ( | from rest_framework.serializers import BooleanField, ModelSerializer | ||||||
|     BooleanField, |  | ||||||
|     ListSerializer, |  | ||||||
|     ModelSerializer, |  | ||||||
|     ValidationError, |  | ||||||
| ) |  | ||||||
| from rest_framework.viewsets import ModelViewSet | 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.admin.api.metrics import CoordinateSerializer, get_events_per_1h | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.groups import GroupSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | ||||||
| from authentik.core.middleware import ( | from authentik.core.middleware import ( | ||||||
|     SESSION_IMPERSONATE_ORIGINAL_USER, |     SESSION_IMPERSONATE_ORIGINAL_USER, | ||||||
| @ -33,7 +20,7 @@ from authentik.core.middleware import ( | |||||||
| ) | ) | ||||||
| from authentik.core.models import Token, TokenIntents, User | from authentik.core.models import Token, TokenIntents, User | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
| from authentik.tenants.models import Tenant | from authentik.flows.models import Flow, FlowDesignation | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserSerializer(ModelSerializer): | class UserSerializer(ModelSerializer): | ||||||
| @ -42,8 +29,6 @@ class UserSerializer(ModelSerializer): | |||||||
|     is_superuser = BooleanField(read_only=True) |     is_superuser = BooleanField(read_only=True) | ||||||
|     avatar = CharField(read_only=True) |     avatar = CharField(read_only=True) | ||||||
|     attributes = JSONField(validators=[is_dict], required=False) |     attributes = JSONField(validators=[is_dict], required=False) | ||||||
|     groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups") |  | ||||||
|     uid = CharField(read_only=True) |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -55,11 +40,9 @@ class UserSerializer(ModelSerializer): | |||||||
|             "is_active", |             "is_active", | ||||||
|             "last_login", |             "last_login", | ||||||
|             "is_superuser", |             "is_superuser", | ||||||
|             "groups", |  | ||||||
|             "email", |             "email", | ||||||
|             "avatar", |             "avatar", | ||||||
|             "attributes", |             "attributes", | ||||||
|             "uid", |  | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -78,13 +61,13 @@ class UserMetricsSerializer(PassiveSerializer): | |||||||
|     logins_failed_per_1h = SerializerMethodField() |     logins_failed_per_1h = SerializerMethodField() | ||||||
|     authorizations_per_1h = SerializerMethodField() |     authorizations_per_1h = SerializerMethodField() | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||||
|     def get_logins_per_1h(self, _): |     def get_logins_per_1h(self, _): | ||||||
|         """Get successful logins per hour for the last 24 hours""" |         """Get successful logins per hour for the last 24 hours""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk) |         return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk) | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||||
|     def get_logins_failed_per_1h(self, _): |     def get_logins_failed_per_1h(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per hour for the last 24 hours""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
| @ -92,7 +75,7 @@ class UserMetricsSerializer(PassiveSerializer): | |||||||
|             action=EventAction.LOGIN_FAILED, context__username=user.username |             action=EventAction.LOGIN_FAILED, context__username=user.username | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema_field(CoordinateSerializer(many=True)) |     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||||
|     def get_authorizations_per_1h(self, _): |     def get_authorizations_per_1h(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per hour for the last 24 hours""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
| @ -101,49 +84,18 @@ class UserMetricsSerializer(PassiveSerializer): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class UsersFilter(FilterSet): | class UserViewSet(ModelViewSet): | ||||||
|     """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(UsedByMixin, ModelViewSet): |  | ||||||
|     """User Viewset""" |     """User Viewset""" | ||||||
|  |  | ||||||
|     queryset = User.objects.none() |     queryset = User.objects.none() | ||||||
|     serializer_class = UserSerializer |     serializer_class = UserSerializer | ||||||
|     search_fields = ["username", "name", "is_active"] |     search_fields = ["username", "name", "is_active"] | ||||||
|     filterset_class = UsersFilter |     filterset_fields = ["username", "name", "is_active"] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self): | ||||||
|         return User.objects.all().exclude(pk=get_anonymous_user().pk) |         return User.objects.all().exclude(pk=get_anonymous_user().pk) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: SessionUserSerializer(many=False)}) |     @swagger_auto_schema(responses={200: SessionUserSerializer(many=False)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|     def me(self, request: Request) -> Response: |     def me(self, request: Request) -> Response: | ||||||
| @ -159,7 +111,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) |     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) | ||||||
|     @extend_schema(responses={200: UserMetricsSerializer(many=False)}) |     @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def metrics(self, request: Request, pk: int) -> Response: |     def metrics(self, request: Request, pk: int) -> Response: | ||||||
| @ -170,19 +122,15 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.reset_user_password") |     @permission_required("authentik_core.reset_user_password") | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={"200": LinkSerializer(many=False), "404": "No recovery flow found."}, | ||||||
|             "200": LinkSerializer(many=False), |  | ||||||
|             "404": OpenApiResponse(description="No recovery flow found."), |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def recovery(self, request: Request, pk: int) -> Response: |     def recovery(self, request: Request, pk: int) -> Response: | ||||||
|         """Create a temporary link that a user can use to recover their accounts""" |         """Create a temporary link that a user can use to recover their accounts""" | ||||||
|         tenant: Tenant = request._request.tenant |  | ||||||
|         # Check that there is a recovery flow, if not return an error |         # Check that there is a recovery flow, if not return an error | ||||||
|         flow = tenant.flow_recovery |         flow = Flow.with_policy(request, designation=FlowDesignation.RECOVERY) | ||||||
|         if not flow: |         if not flow: | ||||||
|             raise Http404 |             raise Http404 | ||||||
|         user: User = self.get_object() |         user: User = self.get_object() | ||||||
| @ -193,20 +141,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         ) |         ) | ||||||
|         querystring = urlencode({"token": token.key}) |         querystring = urlencode({"token": token.key}) | ||||||
|         link = request.build_absolute_uri( |         link = request.build_absolute_uri( | ||||||
|             reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) |             reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}" | ||||||
|             + f"?{querystring}" |  | ||||||
|         ) |         ) | ||||||
|         return Response({"link": link}) |         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,17 +20,12 @@ def is_dict(value: Any): | |||||||
| class PassiveSerializer(Serializer): | class PassiveSerializer(Serializer): | ||||||
|     """Base serializer class which doesn't implement create/update methods""" |     """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() |         return Model() | ||||||
|  |  | ||||||
|     def update( |     def update(self, instance: Model, validated_data: dict) -> Model: | ||||||
|         self, instance: Model, validated_data: dict |  | ||||||
|     ) -> Model:  # pragma: no cover |  | ||||||
|         return Model() |         return Model() | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = Model |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MetaNameSerializer(PassiveSerializer): | class MetaNameSerializer(PassiveSerializer): | ||||||
|     """Add verbose names to response""" |     """Add verbose names to response""" | ||||||
|  | |||||||
| @ -2,10 +2,6 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.db import ProgrammingError |  | ||||||
|  |  | ||||||
| from authentik.core.signals import GAUGE_MODELS |  | ||||||
| from authentik.lib.utils.reflection import get_apps |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikCoreConfig(AppConfig): | class AuthentikCoreConfig(AppConfig): | ||||||
| @ -19,12 +15,3 @@ class AuthentikCoreConfig(AppConfig): | |||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.core.signals") |         import_module("authentik.core.signals") | ||||||
|         import_module("authentik.core.managed") |         import_module("authentik.core.managed") | ||||||
|         try: |  | ||||||
|             for app in get_apps(): |  | ||||||
|                 for model in app.get_models(): |  | ||||||
|                     GAUGE_MODELS.labels( |  | ||||||
|                         model_name=model._meta.model_name, |  | ||||||
|                         app=model._meta.app_label, |  | ||||||
|                     ).set(model.objects.count()) |  | ||||||
|         except ProgrammingError: |  | ||||||
|             pass |  | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from channels.generic.websocket import JsonWebsocketConsumer | |||||||
| from rest_framework.exceptions import AuthenticationFailed | from rest_framework.exceptions import AuthenticationFailed | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.authentication import token_from_header | from authentik.api.auth import token_from_header | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | |||||||
| @ -42,14 +42,10 @@ class RequestIDMiddleware: | |||||||
|         if not hasattr(request, "request_id"): |         if not hasattr(request, "request_id"): | ||||||
|             request_id = uuid4().hex |             request_id = uuid4().hex | ||||||
|             setattr(request, "request_id", request_id) |             setattr(request, "request_id", request_id) | ||||||
|             LOCAL.authentik = { |             LOCAL.authentik = {"request_id": request_id} | ||||||
|                 "request_id": request_id, |  | ||||||
|                 "host": request.get_host(), |  | ||||||
|             } |  | ||||||
|         response = self.get_response(request) |         response = self.get_response(request) | ||||||
|         response[RESPONSE_HEADER_ID] = request.request_id |         response[RESPONSE_HEADER_ID] = request.request_id | ||||||
|         del LOCAL.authentik["request_id"] |         del LOCAL.authentik["request_id"] | ||||||
|         del LOCAL.authentik["host"] |  | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -58,5 +54,4 @@ def structlog_add_request_id(logger: Logger, method_name: str, event_dict): | |||||||
|     """If threadlocal has authentik defined, add request_id to log""" |     """If threadlocal has authentik defined, add request_id to log""" | ||||||
|     if hasattr(LOCAL, "authentik"): |     if hasattr(LOCAL, "authentik"): | ||||||
|         event_dict["request_id"] = LOCAL.authentik.get("request_id", "") |         event_dict["request_id"] = LOCAL.authentik.get("request_id", "") | ||||||
|         event_dict["host"] = LOCAL.authentik.get("host", "") |  | ||||||
|     return event_dict |     return event_dict | ||||||
|  | |||||||
| @ -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.", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,20 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-05-14 08:48 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0020_source_user_matching_mode"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="application", |  | ||||||
|             name="slug", |  | ||||||
|             field=models.SlugField( |  | ||||||
|                 help_text="Internal application name, used in URLs.", unique=True |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,63 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-05-29 22:14 |  | ||||||
|  |  | ||||||
| import uuid |  | ||||||
|  |  | ||||||
| import django.db.models.deletion |  | ||||||
| from django.apps.registry import Apps |  | ||||||
| from django.conf import settings |  | ||||||
| from django.db import migrations, models |  | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor |  | ||||||
|  |  | ||||||
| import authentik.core.models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): |  | ||||||
|     db_alias = schema_editor.connection.alias |  | ||||||
|     from django.contrib.sessions.backends.cache import KEY_PREFIX |  | ||||||
|     from django.core.cache import cache |  | ||||||
|  |  | ||||||
|     session_keys = cache.keys(KEY_PREFIX + "*") |  | ||||||
|     cache.delete_many(session_keys) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0021_alter_application_slug"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.CreateModel( |  | ||||||
|             name="AuthenticatedSession", |  | ||||||
|             fields=[ |  | ||||||
|                 ( |  | ||||||
|                     "expires", |  | ||||||
|                     models.DateTimeField( |  | ||||||
|                         default=authentik.core.models.default_token_duration |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("expiring", models.BooleanField(default=True)), |  | ||||||
|                 ( |  | ||||||
|                     "uuid", |  | ||||||
|                     models.UUIDField( |  | ||||||
|                         default=uuid.uuid4, primary_key=True, serialize=False |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|                 ("session_key", models.CharField(max_length=40)), |  | ||||||
|                 ("last_ip", models.TextField()), |  | ||||||
|                 ("last_user_agent", models.TextField(blank=True)), |  | ||||||
|                 ("last_used", models.DateTimeField(auto_now=True)), |  | ||||||
|                 ( |  | ||||||
|                     "user", |  | ||||||
|                     models.ForeignKey( |  | ||||||
|                         on_delete=django.db.models.deletion.CASCADE, |  | ||||||
|                         to=settings.AUTH_USER_MODEL, |  | ||||||
|                     ), |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             options={ |  | ||||||
|                 "abstract": False, |  | ||||||
|             }, |  | ||||||
|         ), |  | ||||||
|         migrations.RunPython(migrate_sessions), |  | ||||||
|     ] |  | ||||||
| @ -1,23 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-06-02 21:51 |  | ||||||
|  |  | ||||||
| import django.core.validators |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0022_authenticatedsession"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="application", |  | ||||||
|             name="meta_launch_url", |  | ||||||
|             field=models.TextField( |  | ||||||
|                 blank=True, |  | ||||||
|                 default="", |  | ||||||
|                 validators=[django.core.validators.URLValidator()], |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,35 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-06-03 09:33 |  | ||||||
|  |  | ||||||
| from django.apps.registry import Apps |  | ||||||
| from django.db import migrations, models |  | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor |  | ||||||
| from django.db.models import Count |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): |  | ||||||
|     db_alias = schema_editor.connection.alias |  | ||||||
|     Token = apps.get_model("authentik_core", "token") |  | ||||||
|     identifiers = ( |  | ||||||
|         Token.objects.using(db_alias) |  | ||||||
|         .values("identifier") |  | ||||||
|         .annotate(identifier_count=Count("identifier")) |  | ||||||
|         .filter(identifier_count__gt=1) |  | ||||||
|     ) |  | ||||||
|     for ident in identifiers: |  | ||||||
|         Token.objects.using(db_alias).filter(identifier=ident["identifier"]).delete() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0023_alter_application_meta_launch_url"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.RunPython(fix_duplicates), |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="token", |  | ||||||
|             name="identifier", |  | ||||||
|             field=models.SlugField(max_length=255, unique=True), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,20 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-06-05 19:04 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_core", "0024_alter_token_identifier"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="application", |  | ||||||
|             name="meta_icon", |  | ||||||
|             field=models.FileField( |  | ||||||
|                 default=None, null=True, upload_to="application-icons/" |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -5,11 +5,9 @@ from typing import Any, Optional, Type | |||||||
| from urllib.parse import urlencode | from urllib.parse import urlencode | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| import django.db.models.options as options |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.models import AbstractUser | from django.contrib.auth.models import AbstractUser | ||||||
| from django.contrib.auth.models import UserManager as DjangoUserManager | from django.contrib.auth.models import UserManager as DjangoUserManager | ||||||
| from django.core import validators |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models import Q, QuerySet | from django.db.models import Q, QuerySet | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| @ -25,26 +23,22 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.core.exceptions import PropertyMappingExpressionException | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
| from authentik.core.signals import password_changed | from authentik.core.signals import password_changed | ||||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | from authentik.core.types import UILoginButton | ||||||
|  | from authentik.flows.challenge import Challenge | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.models import CreatedUpdatedModel, SerializerModel | from authentik.lib.models import CreatedUpdatedModel, SerializerModel | ||||||
| from authentik.lib.utils.http import get_client_ip |  | ||||||
| from authentik.managed.models import ManagedModel | from authentik.managed.models import ManagedModel | ||||||
| from authentik.policies.models import PolicyBindingModel | from authentik.policies.models import PolicyBindingModel | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" | USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" | ||||||
| USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" | USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" | ||||||
| USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" |  | ||||||
|  |  | ||||||
| GRAVATAR_URL = "https://secure.gravatar.com" | GRAVATAR_URL = "https://secure.gravatar.com" | ||||||
| DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | ||||||
|  |  | ||||||
|  |  | ||||||
| options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_token_duration(): | def default_token_duration(): | ||||||
|     """Default duration a Token is valid""" |     """Default duration a Token is valid""" | ||||||
|     return now() + timedelta(minutes=30) |     return now() + timedelta(minutes=30) | ||||||
| @ -212,35 +206,17 @@ class Application(PolicyBindingModel): | |||||||
|     add custom fields and other properties""" |     add custom fields and other properties""" | ||||||
|  |  | ||||||
|     name = models.TextField(help_text=_("Application's display Name.")) |     name = models.TextField(help_text=_("Application's display Name.")) | ||||||
|     slug = models.SlugField( |     slug = models.SlugField(help_text=_("Internal application name, used in URLs.")) | ||||||
|         help_text=_("Internal application name, used in URLs."), unique=True |  | ||||||
|     ) |  | ||||||
|     provider = models.OneToOneField( |     provider = models.OneToOneField( | ||||||
|         "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT |         "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     meta_launch_url = models.TextField( |     meta_launch_url = models.URLField(default="", blank=True) | ||||||
|         default="", blank=True, validators=[validators.URLValidator()] |  | ||||||
|     ) |  | ||||||
|     # For template applications, this can be set to /static/authentik/applications/* |     # For template applications, this can be set to /static/authentik/applications/* | ||||||
|     meta_icon = models.FileField( |     meta_icon = models.FileField(upload_to="application-icons/", default="", blank=True) | ||||||
|         upload_to="application-icons/", default=None, null=True |  | ||||||
|     ) |  | ||||||
|     meta_description = models.TextField(default="", blank=True) |     meta_description = models.TextField(default="", blank=True) | ||||||
|     meta_publisher = models.TextField(default="", blank=True) |     meta_publisher = models.TextField(default="", blank=True) | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def get_meta_icon(self) -> Optional[str]: |  | ||||||
|         """Get the URL to the App Icon image. If the name is /static or starts with http |  | ||||||
|         it is returned as-is""" |  | ||||||
|         if not self.meta_icon: |  | ||||||
|             return None |  | ||||||
|         if self.meta_icon.name.startswith("http") or self.meta_icon.name.startswith( |  | ||||||
|             "/static" |  | ||||||
|         ): |  | ||||||
|             return self.meta_icon.name |  | ||||||
|         return self.meta_icon.url |  | ||||||
|  |  | ||||||
|     def get_launch_url(self) -> Optional[str]: |     def get_launch_url(self) -> Optional[str]: | ||||||
|         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" |         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" | ||||||
|         if self.meta_launch_url: |         if self.meta_launch_url: | ||||||
| @ -264,30 +240,6 @@ class Application(PolicyBindingModel): | |||||||
|         verbose_name_plural = _("Applications") |         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): | class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||||
|     """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" |     """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" | ||||||
|  |  | ||||||
| @ -320,17 +272,6 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|         related_name="source_enrollment", |         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() |     objects = InheritanceManager() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -345,9 +286,9 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: |     def ui_user_settings(self) -> Optional[Challenge]: | ||||||
|         """Entrypoint to integrate with User settings. Can either return None if no |         """Entrypoint to integrate with User settings. Can either return None if no | ||||||
|         user settings are available, or UserSettingSerializer.""" |         user settings are available, or a challenge.""" | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
| @ -360,8 +301,6 @@ class UserSourceConnection(CreatedUpdatedModel): | |||||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) |     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||||
|     source = models.ForeignKey(Source, on_delete=models.CASCADE) |     source = models.ForeignKey(Source, on_delete=models.CASCADE) | ||||||
|  |  | ||||||
|     objects = InheritanceManager() |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         unique_together = (("user", "source"),) |         unique_together = (("user", "source"),) | ||||||
| @ -409,7 +348,7 @@ class Token(ManagedModel, ExpiringModel): | |||||||
|     """Token used to authenticate the User for API Access or confirm another Stage like Email.""" |     """Token used to authenticate the User for API Access or confirm another Stage like Email.""" | ||||||
|  |  | ||||||
|     token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) |     token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|     identifier = models.SlugField(max_length=255, unique=True) |     identifier = models.SlugField(max_length=255) | ||||||
|     key = models.TextField(default=default_token_key) |     key = models.TextField(default=default_token_key) | ||||||
|     intent = models.TextField( |     intent = models.TextField( | ||||||
|         choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION |         choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION | ||||||
| @ -473,33 +412,3 @@ class PropertyMapping(SerializerModel, ManagedModel): | |||||||
|  |  | ||||||
|         verbose_name = _("Property Mapping") |         verbose_name = _("Property Mapping") | ||||||
|         verbose_name_plural = _("Property Mappings") |         verbose_name_plural = _("Property Mappings") | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatedSession(ExpiringModel): |  | ||||||
|     """Additional session class for authenticated users. Augments the standard django session |  | ||||||
|     to achieve the following: |  | ||||||
|         - Make it queryable by user |  | ||||||
|         - Have a direct connection to user objects |  | ||||||
|         - Allow users to view their own sessions and terminate them |  | ||||||
|         - Save structured and well-defined information. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     uuid = models.UUIDField(default=uuid4, primary_key=True) |  | ||||||
|  |  | ||||||
|     session_key = models.CharField(max_length=40) |  | ||||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) |  | ||||||
|  |  | ||||||
|     last_ip = models.TextField() |  | ||||||
|     last_user_agent = models.TextField(blank=True) |  | ||||||
|     last_used = models.DateTimeField(auto_now=True) |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def from_request(request: HttpRequest, user: User) -> "AuthenticatedSession": |  | ||||||
|         """Create a new session from a http request""" |  | ||||||
|         return AuthenticatedSession( |  | ||||||
|             session_key=request.session.session_key, |  | ||||||
|             user=user, |  | ||||||
|             last_ip=get_client_ip(request), |  | ||||||
|             last_user_agent=request.META.get("HTTP_USER_AGENT", ""), |  | ||||||
|             expires=request.session.get_expiry_date(), |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -1,38 +1,20 @@ | |||||||
| """authentik core signals""" | """authentik core signals""" | ||||||
| from typing import TYPE_CHECKING |  | ||||||
|  |  | ||||||
| from django.contrib.auth.signals import user_logged_in, user_logged_out |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.core.signals import Signal | from django.core.signals import Signal | ||||||
| from django.db.models import Model |  | ||||||
| from django.db.models.signals import post_save | from django.db.models.signals import post_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http.request import HttpRequest |  | ||||||
| from prometheus_client import Gauge |  | ||||||
|  |  | ||||||
| # Arguments: user: User, password: str | # Arguments: user: User, password: str | ||||||
| password_changed = Signal() | password_changed = Signal() | ||||||
|  |  | ||||||
| GAUGE_MODELS = Gauge( |  | ||||||
|     "authentik_models", "Count of various objects", ["model_name", "app"] |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| if TYPE_CHECKING: |  | ||||||
|     from authentik.core.models import User |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def post_save_application(sender: type[Model], instance, created: bool, **_): | def post_save_application(sender, instance, created: bool, **_): | ||||||
|     """Clear user's application cache upon application creation""" |     """Clear user's application cache upon application creation""" | ||||||
|     from authentik.core.api.applications import user_app_cache_key |     from authentik.core.api.applications import user_app_cache_key | ||||||
|     from authentik.core.models import Application |     from authentik.core.models import Application | ||||||
|  |  | ||||||
|     GAUGE_MODELS.labels( |  | ||||||
|         model_name=sender._meta.model_name, |  | ||||||
|         app=sender._meta.app_label, |  | ||||||
|     ).set(sender.objects.count()) |  | ||||||
|  |  | ||||||
|     if sender != Application: |     if sender != Application: | ||||||
|         return |         return | ||||||
|     if not created:  # pragma: no cover |     if not created:  # pragma: no cover | ||||||
| @ -40,23 +22,3 @@ def post_save_application(sender: type[Model], instance, created: bool, **_): | |||||||
|     # Also delete user application cache |     # Also delete user application cache | ||||||
|     keys = cache.keys(user_app_cache_key("*")) |     keys = cache.keys(user_app_cache_key("*")) | ||||||
|     cache.delete_many(keys) |     cache.delete_many(keys) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_in) |  | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): |  | ||||||
|     """Create an AuthenticatedSession from request""" |  | ||||||
|     from authentik.core.models import AuthenticatedSession |  | ||||||
|  |  | ||||||
|     AuthenticatedSession.from_request(request, user).save() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) |  | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): |  | ||||||
|     """Delete AuthenticatedSession if it exists""" |  | ||||||
|     from authentik.core.models import AuthenticatedSession |  | ||||||
|  |  | ||||||
|     AuthenticatedSession.objects.filter( |  | ||||||
|         session_key=request.session.session_key |  | ||||||
|     ).delete() |  | ||||||
|  | |||||||
| @ -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, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
| @ -75,6 +75,5 @@ def backup_database(self: MonitoredTask):  # pragma: no cover | |||||||
|         Boto3Error, |         Boto3Error, | ||||||
|         PermissionError, |         PermissionError, | ||||||
|         CommandConnectorError, |         CommandConnectorError, | ||||||
|         ValueError, |  | ||||||
|     ) as exc: |     ) as exc: | ||||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|  | |||||||
| @ -7,15 +7,16 @@ | |||||||
|     <head> |     <head> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|         <title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title> |         <title>{% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %}</title> | ||||||
|  |         <link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> | ||||||
|         <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> |         <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}"> |         <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/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/spinner.css' %}?v={{ ak_version }}"> | ||||||
|  |         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}"> | ||||||
|         {% block head_before %} |         {% block head_before %} | ||||||
|         {% endblock %} |         {% 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 src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script> | ||||||
|         <script>window["polymerSkipLoadingFontRoboto"] = true;</script> |         <script>window["polymerSkipLoadingFontRoboto"] = true;</script> | ||||||
|         {% block head %} |         {% block head %} | ||||||
|  | |||||||
| @ -4,9 +4,7 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| {% block head_before %} | {% block head_before %} | ||||||
| {% if flow.compatibility_mode %} |  | ||||||
| <script>ShadyDOM = { force: !navigator.webdriver };</script> | <script>ShadyDOM = { force: !navigator.webdriver };</script> | ||||||
| {% endif %} |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
|  | |||||||
| @ -3,10 +3,6 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| {% block head_before %} |  | ||||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}"> |  | ||||||
| {% endblock %} |  | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <div class="pf-c-background-image"> | <div class="pf-c-background-image"> | ||||||
|     <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> |     <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> | ||||||
| @ -26,7 +22,10 @@ | |||||||
|     <div class="ak-login-container"> |     <div class="ak-login-container"> | ||||||
|         <header class="pf-c-login__header"> |         <header class="pf-c-login__header"> | ||||||
|             <div class="pf-c-brand ak-brand"> |             <div class="pf-c-brand ak-brand"> | ||||||
|                 <img src="{{ tenant.branding_logo }}" alt="authentik icon" /> |                 <img src="{{ config.authentik.branding.logo }}" alt="authentik icon" /> | ||||||
|  |                 {% if config.authentik.branding.title_show %} | ||||||
|  |                 <p>{{ config.authentik.branding.title }}</p> | ||||||
|  |                 {% endif %} | ||||||
|             </div> |             </div> | ||||||
|         </header> |         </header> | ||||||
|         {% block main_container %} |         {% block main_container %} | ||||||
| @ -46,12 +45,12 @@ | |||||||
|         <footer class="pf-c-login__footer"> |         <footer class="pf-c-login__footer"> | ||||||
|             <p></p> |             <p></p> | ||||||
|             <ul class="pf-c-list pf-m-inline"> |             <ul class="pf-c-list pf-m-inline"> | ||||||
|                 {% for link in footer_links %} |                 {% for link in config.authentik.footer_links %} | ||||||
|                 <li> |                 <li> | ||||||
|                     <a href="{{ link.href }}">{{ link.name }}</a> |                     <a href="{{ link.href }}">{{ link.name }}</a> | ||||||
|                 </li> |                 </li> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|                 {% if tenant.branding_title != "authentik" %} |                 {% if config.authentik.branding.title != "authentik" %} | ||||||
|                 <li> |                 <li> | ||||||
|                     <a href="https://goauthentik.io"> |                     <a href="https://goauthentik.io"> | ||||||
|                         {% trans 'Powered by authentik' %} |                         {% trans 'Powered by authentik' %} | ||||||
|  | |||||||
| @ -1,131 +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.post( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:application-check-access", |  | ||||||
|                 kwargs={"slug": self.allowed.slug}, |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             force_str(response.content), {"messages": [], "passing": True} |  | ||||||
|         ) |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:application-check-access", |  | ||||||
|                 kwargs={"slug": self.denied.slug}, |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             force_str(response.content), {"messages": ["dummy"], "passing": False} |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     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,31 +0,0 @@ | |||||||
| """Test AuthenticatedSessions API""" |  | ||||||
| from json import loads |  | ||||||
|  |  | ||||||
| from django.urls.base import reverse |  | ||||||
| from django.utils.encoding import force_str |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
|  |  | ||||||
| from authentik.core.models import User |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestAuthenticatedSessionsAPI(APITestCase): |  | ||||||
|     """Test AuthenticatedSessions API""" |  | ||||||
|  |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         super().setUp() |  | ||||||
|         self.user = User.objects.get(username="akadmin") |  | ||||||
|         self.other_user = User.objects.create(username="normal-user") |  | ||||||
|  |  | ||||||
|     def test_list(self): |  | ||||||
|         """Test session list endpoint""" |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         response = self.client.get(reverse("authentik_api:authenticatedsession-list")) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|     def test_non_admin_list(self): |  | ||||||
|         """Test non-admin list""" |  | ||||||
|         self.client.force_login(self.other_user) |  | ||||||
|         response = self.client.get(reverse("authentik_api:authenticatedsession-list")) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|         body = loads(force_str(response.content)) |  | ||||||
|         self.assertEqual(body["pagination"]["count"], 1) |  | ||||||
| @ -1,14 +1,11 @@ | |||||||
| """authentik core models tests""" | """authentik core models tests""" | ||||||
| from time import sleep | from time import sleep | ||||||
| from typing import Callable, Type |  | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import Provider, Source, Token | from authentik.core.models import Token | ||||||
| from authentik.flows.models import Stage |  | ||||||
| from authentik.lib.utils.reflection import all_subclasses |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestModels(TestCase): | class TestModels(TestCase): | ||||||
| @ -21,46 +18,9 @@ class TestModels(TestCase): | |||||||
|         self.assertTrue(token.is_expired) |         self.assertTrue(token.is_expired) | ||||||
|  |  | ||||||
|     def test_token_expire_no_expire(self): |     def test_token_expire_no_expire(self): | ||||||
|         """Test token expiring with "expiring" set""" |         """Test token expiring with "expiring" set """ | ||||||
|         token = Token.objects.create( |         token = Token.objects.create( | ||||||
|             expires=now(), user=get_anonymous_user(), expiring=False |             expires=now(), user=get_anonymous_user(), expiring=False | ||||||
|         ) |         ) | ||||||
|         sleep(0.5) |         sleep(0.5) | ||||||
|         self.assertFalse(token.is_expired) |         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)) |  | ||||||
|  | |||||||
| @ -1,29 +0,0 @@ | |||||||
| """Test Users API""" |  | ||||||
| from django.urls.base import reverse |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
|  |  | ||||||
| from authentik.core.models import User |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestUsersAPI(APITestCase): |  | ||||||
|     """Test Users API""" |  | ||||||
|  |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         self.admin = User.objects.get(username="akadmin") |  | ||||||
|         self.user = User.objects.create(username="test-user") |  | ||||||
|  |  | ||||||
|     def test_metrics(self): |  | ||||||
|         """Test user's metrics""" |  | ||||||
|         self.client.force_login(self.admin) |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk}) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|     def test_metrics_denied(self): |  | ||||||
|         """Test user's metrics (non-superuser)""" |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk}) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 403) |  | ||||||
| @ -2,10 +2,9 @@ | |||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
| from typing import Optional | 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.core.api.utils import PassiveSerializer | ||||||
| from authentik.flows.challenge import Challenge |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
| @ -15,8 +14,8 @@ class UILoginButton: | |||||||
|     # Name, ran through i18n |     # Name, ran through i18n | ||||||
|     name: str |     name: str | ||||||
|  |  | ||||||
|     # Challenge which is presented to the user when they click the button |     # URL Which Button points to | ||||||
|     challenge: Challenge |     url: str | ||||||
|  |  | ||||||
|     # Icon URL, used as-is |     # Icon URL, used as-is | ||||||
|     icon_url: Optional[str] = None |     icon_url: Optional[str] = None | ||||||
| @ -26,7 +25,7 @@ class UILoginButtonSerializer(PassiveSerializer): | |||||||
|     """Serializer for Login buttons of sources""" |     """Serializer for Login buttons of sources""" | ||||||
|  |  | ||||||
|     name = CharField() |     name = CharField() | ||||||
|     challenge = DictField() |     url = CharField() | ||||||
|     icon_url = CharField(required=False, allow_null=True) |     icon_url = CharField(required=False, allow_null=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -36,4 +35,3 @@ class UserSettingSerializer(PassiveSerializer): | |||||||
|     object_uid = CharField() |     object_uid = CharField() | ||||||
|     component = CharField() |     component = CharField() | ||||||
|     title = CharField() |     title = CharField() | ||||||
|     configure_url = CharField() |  | ||||||
|  | |||||||
| @ -6,8 +6,6 @@ from django.views.generic import RedirectView | |||||||
| from django.views.generic.base import TemplateView | from django.views.generic.base import TemplateView | ||||||
|  |  | ||||||
| from authentik.core.views import impersonate | from authentik.core.views import impersonate | ||||||
| from authentik.core.views.interface import FlowInterfaceView |  | ||||||
| from authentik.core.views.session import EndSessionView |  | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path( |     path( | ||||||
| @ -34,18 +32,7 @@ urlpatterns = [ | |||||||
|     ), |     ), | ||||||
|     path( |     path( | ||||||
|         "if/flow/<slug:flow_slug>/", |         "if/flow/<slug:flow_slug>/", | ||||||
|         ensure_csrf_cookie(FlowInterfaceView.as_view()), |         ensure_csrf_cookie(TemplateView.as_view(template_name="if/flow.html")), | ||||||
|         name="if-flow", |         name="if-flow", | ||||||
|     ), |     ), | ||||||
|     path( |  | ||||||
|         "if/session-end/<slug:application_slug>/", |  | ||||||
|         ensure_csrf_cookie(EndSessionView.as_view()), |  | ||||||
|         name="if-session-end", |  | ||||||
|     ), |  | ||||||
|     # Fallback for WS |  | ||||||
|     path("ws/outpost/<uuid:pk>/", TemplateView.as_view(template_name="if/admin.html")), |  | ||||||
|     path( |  | ||||||
|         "ws/client/", |  | ||||||
|         TemplateView.as_view(template_name="if/admin.html"), |  | ||||||
|     ), |  | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -1,17 +0,0 @@ | |||||||
| """Interface views""" |  | ||||||
| from typing import Any |  | ||||||
|  |  | ||||||
| from django.shortcuts import get_object_or_404 |  | ||||||
| from django.views.generic.base import TemplateView |  | ||||||
|  |  | ||||||
| from authentik.flows.models import Flow |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowInterfaceView(TemplateView): |  | ||||||
|     """Flow interface""" |  | ||||||
|  |  | ||||||
|     template_name = "if/flow.html" |  | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: |  | ||||||
|         kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) |  | ||||||
|         return super().get_context_data(**kwargs) |  | ||||||
| @ -1,14 +1,10 @@ | |||||||
| """Crypto API Views""" | """Crypto API Views""" | ||||||
|  | import django_filters | ||||||
| from cryptography.hazmat.backends import default_backend | from cryptography.hazmat.backends import default_backend | ||||||
| from cryptography.hazmat.primitives.serialization import load_pem_private_key | from cryptography.hazmat.primitives.serialization import load_pem_private_key | ||||||
| from cryptography.x509 import load_pem_x509_certificate | from cryptography.x509 import load_pem_x509_certificate | ||||||
| from django.http.response import HttpResponse |  | ||||||
| from django.urls import reverse |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from django_filters import FilterSet | from drf_yasg.utils import swagger_auto_schema | ||||||
| from django_filters.filters import BooleanFilter |  | ||||||
| from drf_spectacular.types import OpenApiTypes |  | ||||||
| from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema |  | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import ( | from rest_framework.fields import ( | ||||||
|     CharField, |     CharField, | ||||||
| @ -22,7 +18,6 @@ from rest_framework.serializers import ModelSerializer, ValidationError | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.crypto.builder import CertificateBuilder | from authentik.crypto.builder import CertificateBuilder | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| @ -36,9 +31,6 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|     cert_subject = SerializerMethodField() |     cert_subject = SerializerMethodField() | ||||||
|     private_key_available = SerializerMethodField() |     private_key_available = SerializerMethodField() | ||||||
|  |  | ||||||
|     certificate_download_url = SerializerMethodField() |  | ||||||
|     private_key_download_url = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     def get_cert_subject(self, instance: CertificateKeyPair) -> str: |     def get_cert_subject(self, instance: CertificateKeyPair) -> str: | ||||||
|         """Get certificate subject as full rfc4514""" |         """Get certificate subject as full rfc4514""" | ||||||
|         return instance.certificate.subject.rfc4514_string() |         return instance.certificate.subject.rfc4514_string() | ||||||
| @ -47,27 +39,7 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|         """Show if this keypair has a private key configured or not""" |         """Show if this keypair has a private key configured or not""" | ||||||
|         return instance.key_data != "" and instance.key_data is not None |         return instance.key_data != "" and instance.key_data is not None | ||||||
|  |  | ||||||
|     def get_certificate_download_url(self, instance: CertificateKeyPair) -> str: |     def validate_certificate_data(self, value): | ||||||
|         """Get URL to download certificate""" |  | ||||||
|         return ( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:certificatekeypair-view-certificate", |  | ||||||
|                 kwargs={"pk": instance.pk}, |  | ||||||
|             ) |  | ||||||
|             + "?download" |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def get_private_key_download_url(self, instance: CertificateKeyPair) -> str: |  | ||||||
|         """Get URL to download private key""" |  | ||||||
|         return ( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:certificatekeypair-view-private-key", |  | ||||||
|                 kwargs={"pk": instance.pk}, |  | ||||||
|             ) |  | ||||||
|             + "?download" |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def validate_certificate_data(self, value: str) -> str: |  | ||||||
|         """Verify that input is a valid PEM x509 Certificate""" |         """Verify that input is a valid PEM x509 Certificate""" | ||||||
|         try: |         try: | ||||||
|             load_pem_x509_certificate(value.encode("utf-8"), default_backend()) |             load_pem_x509_certificate(value.encode("utf-8"), default_backend()) | ||||||
| @ -75,7 +47,7 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|             raise ValidationError("Unable to load certificate.") |             raise ValidationError("Unable to load certificate.") | ||||||
|         return value |         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""" |         """Verify that input is a valid PEM RSA Key""" | ||||||
|         # Since this field is optional, data can be empty. |         # Since this field is optional, data can be empty. | ||||||
|         if value != "": |         if value != "": | ||||||
| @ -85,10 +57,8 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|                     password=None, |                     password=None, | ||||||
|                     backend=default_backend(), |                     backend=default_backend(), | ||||||
|                 ) |                 ) | ||||||
|             except (ValueError, TypeError): |             except ValueError: | ||||||
|                 raise ValidationError( |                 raise ValidationError("Unable to load private key.") | ||||||
|                     "Unable to load private key (possibly encrypted?)." |  | ||||||
|                 ) |  | ||||||
|         return value |         return value | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @ -103,8 +73,6 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|             "cert_expiry", |             "cert_expiry", | ||||||
|             "cert_subject", |             "cert_subject", | ||||||
|             "private_key_available", |             "private_key_available", | ||||||
|             "certificate_download_url", |  | ||||||
|             "private_key_download_url", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "key_data": {"write_only": True}, |             "key_data": {"write_only": True}, | ||||||
| @ -128,10 +96,10 @@ class CertificateGenerationSerializer(PassiveSerializer): | |||||||
|     validity_days = IntegerField(initial=365) |     validity_days = IntegerField(initial=365) | ||||||
|  |  | ||||||
|  |  | ||||||
| class CertificateKeyPairFilter(FilterSet): | class CertificateKeyPairFilter(django_filters.FilterSet): | ||||||
|     """Filter for certificates""" |     """Filter for certificates""" | ||||||
|  |  | ||||||
|     has_key = BooleanFilter( |     has_key = django_filters.BooleanFilter( | ||||||
|         label="Only return certificate-key pairs with keys", method="filter_has_key" |         label="Only return certificate-key pairs with keys", method="filter_has_key" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
| @ -145,7 +113,7 @@ class CertificateKeyPairFilter(FilterSet): | |||||||
|         fields = ["name"] |         fields = ["name"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | class CertificateKeyPairViewSet(ModelViewSet): | ||||||
|     """CertificateKeyPair Viewset""" |     """CertificateKeyPair Viewset""" | ||||||
|  |  | ||||||
|     queryset = CertificateKeyPair.objects.all() |     queryset = CertificateKeyPair.objects.all() | ||||||
| @ -153,12 +121,9 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|     filterset_class = CertificateKeyPairFilter |     filterset_class = CertificateKeyPairFilter | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_crypto.add_certificatekeypair"]) |     @permission_required(None, ["authentik_crypto.add_certificatekeypair"]) | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request=CertificateGenerationSerializer(), |         request_body=CertificateGenerationSerializer(), | ||||||
|         responses={ |         responses={200: CertificateKeyPairSerializer, 400: "Bad request"}, | ||||||
|             200: CertificateKeyPairSerializer, |  | ||||||
|             400: OpenApiResponse(description="Bad request"), |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     @action(detail=False, methods=["POST"]) |     @action(detail=False, methods=["POST"]) | ||||||
|     def generate(self, request: Request) -> Response: |     def generate(self, request: Request) -> Response: | ||||||
| @ -178,16 +143,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|         serializer = self.get_serializer(instance) |         serializer = self.get_serializer(instance) | ||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)}) | ||||||
|         parameters=[ |  | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="download", |  | ||||||
|                 location=OpenApiParameter.QUERY, |  | ||||||
|                 type=OpenApiTypes.BOOL, |  | ||||||
|             ) |  | ||||||
|         ], |  | ||||||
|         responses={200: CertificateDataSerializer(many=False)}, |  | ||||||
|     ) |  | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def view_certificate(self, request: Request, pk: str) -> Response: |     def view_certificate(self, request: Request, pk: str) -> Response: | ||||||
| @ -198,29 +154,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|             secret=certificate, |             secret=certificate, | ||||||
|             type="certificate", |             type="certificate", | ||||||
|         ).from_http(request) |         ).from_http(request) | ||||||
|         if "download" in request._request.GET: |  | ||||||
|             # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html |  | ||||||
|             response = HttpResponse( |  | ||||||
|                 certificate.certificate_data, content_type="application/x-pem-file" |  | ||||||
|             ) |  | ||||||
|             response[ |  | ||||||
|                 "Content-Disposition" |  | ||||||
|             ] = f'attachment; filename="{certificate.name}_certificate.pem"' |  | ||||||
|             return response |  | ||||||
|         return Response( |         return Response( | ||||||
|             CertificateDataSerializer({"data": certificate.certificate_data}).data |             CertificateDataSerializer({"data": certificate.certificate_data}).data | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)}) | ||||||
|         parameters=[ |  | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="download", |  | ||||||
|                 location=OpenApiParameter.QUERY, |  | ||||||
|                 type=OpenApiTypes.BOOL, |  | ||||||
|             ) |  | ||||||
|         ], |  | ||||||
|         responses={200: CertificateDataSerializer(many=False)}, |  | ||||||
|     ) |  | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def view_private_key(self, request: Request, pk: str) -> Response: |     def view_private_key(self, request: Request, pk: str) -> Response: | ||||||
| @ -231,13 +169,4 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|             secret=certificate, |             secret=certificate, | ||||||
|             type="private_key", |             type="private_key", | ||||||
|         ).from_http(request) |         ).from_http(request) | ||||||
|         if "download" in request._request.GET: |  | ||||||
|             # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html |  | ||||||
|             response = HttpResponse( |  | ||||||
|                 certificate.key_data, content_type="application/x-pem-file" |  | ||||||
|             ) |  | ||||||
|             response[ |  | ||||||
|                 "Content-Disposition" |  | ||||||
|             ] = f'attachment; filename="{certificate.name}_private_key.pem"' |  | ||||||
|             return response |  | ||||||
|         return Response(CertificateDataSerializer({"data": certificate.key_data}).data) |         return Response(CertificateDataSerializer({"data": certificate.key_data}).data) | ||||||
|  | |||||||
| @ -33,7 +33,7 @@ class CertificateBuilder: | |||||||
|     def save(self) -> Optional[CertificateKeyPair]: |     def save(self) -> Optional[CertificateKeyPair]: | ||||||
|         """Save generated certificate as model""" |         """Save generated certificate as model""" | ||||||
|         if not self.__certificate: |         if not self.__certificate: | ||||||
|             raise ValueError("Certificated hasn't been built yet") |             return None | ||||||
|         return CertificateKeyPair.objects.create( |         return CertificateKeyPair.objects.create( | ||||||
|             name=self.common_name, |             name=self.common_name, | ||||||
|             certificate_data=self.certificate, |             certificate_data=self.certificate, | ||||||
|  | |||||||
| @ -2,16 +2,10 @@ | |||||||
| import datetime | import datetime | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from django.urls import reverse |  | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import DeleteAction |  | ||||||
| from authentik.core.models import User |  | ||||||
| from authentik.crypto.api import CertificateKeyPairSerializer | from authentik.crypto.api import CertificateKeyPairSerializer | ||||||
| from authentik.crypto.builder import CertificateBuilder | from authentik.crypto.builder import CertificateBuilder | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.flows.models import Flow |  | ||||||
| from authentik.providers.oauth2.generators import generate_client_secret |  | ||||||
| from authentik.providers.oauth2.models import OAuth2Provider |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestCrypto(TestCase): | class TestCrypto(TestCase): | ||||||
| @ -43,8 +37,6 @@ class TestCrypto(TestCase): | |||||||
|         """Test Builder""" |         """Test Builder""" | ||||||
|         builder = CertificateBuilder() |         builder = CertificateBuilder() | ||||||
|         builder.common_name = "test-cert" |         builder.common_name = "test-cert" | ||||||
|         with self.assertRaises(ValueError): |  | ||||||
|             builder.save() |  | ||||||
|         builder.build( |         builder.build( | ||||||
|             subject_alt_names=[], |             subject_alt_names=[], | ||||||
|             validity_days=3, |             validity_days=3, | ||||||
| @ -53,77 +45,3 @@ class TestCrypto(TestCase): | |||||||
|         now = datetime.datetime.today() |         now = datetime.datetime.today() | ||||||
|         self.assertEqual(instance.name, "test-cert") |         self.assertEqual(instance.name, "test-cert") | ||||||
|         self.assertEqual((instance.certificate.not_valid_after - now).days, 2) |         self.assertEqual((instance.certificate.not_valid_after - now).days, 2) | ||||||
|  |  | ||||||
|     def test_certificate_download(self): |  | ||||||
|         """Test certificate export (download)""" |  | ||||||
|         self.client.force_login(User.objects.get(username="akadmin")) |  | ||||||
|         keypair = CertificateKeyPair.objects.first() |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:certificatekeypair-view-certificate", |  | ||||||
|                 kwargs={"pk": keypair.pk}, |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(200, response.status_code) |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:certificatekeypair-view-certificate", |  | ||||||
|                 kwargs={"pk": keypair.pk}, |  | ||||||
|             ) |  | ||||||
|             + "?download", |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(200, response.status_code) |  | ||||||
|         self.assertIn("Content-Disposition", response) |  | ||||||
|  |  | ||||||
|     def test_private_key_download(self): |  | ||||||
|         """Test private_key export (download)""" |  | ||||||
|         self.client.force_login(User.objects.get(username="akadmin")) |  | ||||||
|         keypair = CertificateKeyPair.objects.first() |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:certificatekeypair-view-private-key", |  | ||||||
|                 kwargs={"pk": keypair.pk}, |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(200, response.status_code) |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:certificatekeypair-view-private-key", |  | ||||||
|                 kwargs={"pk": keypair.pk}, |  | ||||||
|             ) |  | ||||||
|             + "?download", |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(200, response.status_code) |  | ||||||
|         self.assertIn("Content-Disposition", response) |  | ||||||
|  |  | ||||||
|     def test_used_by(self): |  | ||||||
|         """Test used_by endpoint""" |  | ||||||
|         self.client.force_login(User.objects.get(username="akadmin")) |  | ||||||
|         keypair = CertificateKeyPair.objects.first() |  | ||||||
|         provider = OAuth2Provider.objects.create( |  | ||||||
|             name="test", |  | ||||||
|             client_id="test", |  | ||||||
|             client_secret=generate_client_secret(), |  | ||||||
|             authorization_flow=Flow.objects.first(), |  | ||||||
|             redirect_uris="http://localhost", |  | ||||||
|             rsa_key=CertificateKeyPair.objects.first(), |  | ||||||
|         ) |  | ||||||
|         response = self.client.get( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:certificatekeypair-used-by", |  | ||||||
|                 kwargs={"pk": keypair.pk}, |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(200, response.status_code) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             response.content.decode(), |  | ||||||
|             [ |  | ||||||
|                 { |  | ||||||
|                     "app": "authentik_providers_oauth2", |  | ||||||
|                     "model_name": "oauth2provider", |  | ||||||
|                     "pk": str(provider.pk), |  | ||||||
|                     "name": str(provider), |  | ||||||
|                     "action": DeleteAction.SET_NULL.name, |  | ||||||
|                 } |  | ||||||
|             ], |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -2,17 +2,16 @@ | |||||||
| import django_filters | import django_filters | ||||||
| from django.db.models.aggregates import Count | from django.db.models.aggregates import Count | ||||||
| from django.db.models.fields.json import KeyTextTransform | from django.db.models.fields.json import KeyTextTransform | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_yasg.utils import swagger_auto_schema | ||||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema |  | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, DictField, IntegerField | from rest_framework.fields import CharField, DictField, IntegerField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer, Serializer | ||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | 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 | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -39,13 +38,31 @@ class EventSerializer(ModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventTopPerUserSerializer(PassiveSerializer): | class EventTopPerUserParams(Serializer): | ||||||
|  |     """Query params for top_per_user""" | ||||||
|  |  | ||||||
|  |     top_n = IntegerField(default=15) | ||||||
|  |  | ||||||
|  |     def create(self, request: Request) -> Response: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def update(self, request: Request) -> Response: | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EventTopPerUserSerializer(Serializer): | ||||||
|     """Response object of Event's top_per_user""" |     """Response object of Event's top_per_user""" | ||||||
|  |  | ||||||
|     application = DictField() |     application = DictField() | ||||||
|     counted_events = IntegerField() |     counted_events = IntegerField() | ||||||
|     unique_users = 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): | class EventsFilter(django_filters.FilterSet): | ||||||
|     """Filter for events""" |     """Filter for events""" | ||||||
| @ -106,23 +123,16 @@ class EventViewSet(ReadOnlyModelViewSet): | |||||||
|     ] |     ] | ||||||
|     filterset_class = EventsFilter |     filterset_class = EventsFilter | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         methods=["GET"], |         method="GET", | ||||||
|         responses={200: EventTopPerUserSerializer(many=True)}, |         responses={200: EventTopPerUserSerializer(many=True)}, | ||||||
|         parameters=[ |         query_serializer=EventTopPerUserParams, | ||||||
|             OpenApiParameter( |  | ||||||
|                 "top_n", |  | ||||||
|                 type=OpenApiTypes.INT, |  | ||||||
|                 location=OpenApiParameter.QUERY, |  | ||||||
|                 required=False, |  | ||||||
|     ) |     ) | ||||||
|         ], |     @action(detail=False, methods=["GET"]) | ||||||
|     ) |  | ||||||
|     @action(detail=False, methods=["GET"], pagination_class=None) |  | ||||||
|     def top_per_user(self, request: Request): |     def top_per_user(self, request: Request): | ||||||
|         """Get the top_n events grouped by user count""" |         """Get the top_n events grouped by user count""" | ||||||
|         filtered_action = request.query_params.get("action", EventAction.LOGIN) |         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( |         return Response( | ||||||
|             get_objects_for_user(request.user, "authentik_events.view_event") |             get_objects_for_user(request.user, "authentik_events.view_event") | ||||||
|             .filter(action=filtered_action) |             .filter(action=filtered_action) | ||||||
| @ -136,7 +146,7 @@ class EventViewSet(ReadOnlyModelViewSet): | |||||||
|             .order_by("-counted_events")[:top_n] |             .order_by("-counted_events")[:top_n] | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def actions(self, request: Request) -> Response: |     def actions(self, request: Request) -> Response: | ||||||
|         """Get all actions""" |         """Get all actions""" | ||||||
|  | |||||||
| @ -1,13 +1,9 @@ | |||||||
| """Notification API Views""" | """Notification API Views""" | ||||||
| from django_filters.rest_framework import DjangoFilterBackend |  | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.fields import ReadOnlyField | from rest_framework.fields import ReadOnlyField | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter |  | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerFilter, OwnerPermissions |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.events.api.event import EventSerializer | from authentik.events.api.event import EventSerializer | ||||||
| from authentik.events.models import Notification | from authentik.events.models import Notification | ||||||
|  |  | ||||||
| @ -36,7 +32,6 @@ class NotificationViewSet( | |||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.UpdateModelMixin, |     mixins.UpdateModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -51,5 +46,8 @@ class NotificationViewSet( | |||||||
|         "event", |         "event", | ||||||
|         "seen", |         "seen", | ||||||
|     ] |     ] | ||||||
|     permission_classes = [OwnerPermissions] |  | ||||||
|     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] |     def get_queryset(self): | ||||||
|  |         if not self.request: | ||||||
|  |             return super().get_queryset() | ||||||
|  |         return Notification.objects.filter(user=self.request.user) | ||||||
|  | |||||||
| @ -2,30 +2,26 @@ | |||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.events.models import NotificationRule | from authentik.events.models import NotificationRule | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotificationRuleSerializer(ModelSerializer): | class NotificationRuleSerializer(ModelSerializer): | ||||||
|     """NotificationRule Serializer""" |     """NotificationRule Serializer""" | ||||||
|  |  | ||||||
|     group_obj = GroupSerializer(read_only=True, source="group") |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = NotificationRule |         model = NotificationRule | ||||||
|  |         depth = 2 | ||||||
|         fields = [ |         fields = [ | ||||||
|             "pk", |             "pk", | ||||||
|             "name", |             "name", | ||||||
|             "transports", |             "transports", | ||||||
|             "severity", |             "severity", | ||||||
|             "group", |             "group", | ||||||
|             "group_obj", |  | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotificationRuleViewSet(UsedByMixin, ModelViewSet): | class NotificationRuleViewSet(ModelViewSet): | ||||||
|     """NotificationRule Viewset""" |     """NotificationRule Viewset""" | ||||||
|  |  | ||||||
|     queryset = NotificationRule.objects.all() |     queryset = NotificationRule.objects.all() | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """NotificationTransport API Views""" | """NotificationTransport API Views""" | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_yasg.utils import no_body, swagger_auto_schema | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema |  | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -9,7 +8,6 @@ from rest_framework.serializers import ModelSerializer, Serializer | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.events.models import ( | from authentik.events.models import ( | ||||||
|     Notification, |     Notification, | ||||||
|     NotificationSeverity, |     NotificationSeverity, | ||||||
| @ -24,7 +22,7 @@ class NotificationTransportSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     mode_verbose = SerializerMethodField() |     mode_verbose = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_mode_verbose(self, instance: NotificationTransport) -> str: |     def get_mode_verbose(self, instance: NotificationTransport): | ||||||
|         """Return selected mode with a UI Label""" |         """Return selected mode with a UI Label""" | ||||||
|         return TransportMode(instance.mode).label |         return TransportMode(instance.mode).label | ||||||
|  |  | ||||||
| @ -53,19 +51,19 @@ class NotificationTransportTestSerializer(Serializer): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotificationTransportViewSet(UsedByMixin, ModelViewSet): | class NotificationTransportViewSet(ModelViewSet): | ||||||
|     """NotificationTransport Viewset""" |     """NotificationTransport Viewset""" | ||||||
|  |  | ||||||
|     queryset = NotificationTransport.objects.all() |     queryset = NotificationTransport.objects.all() | ||||||
|     serializer_class = NotificationTransportSerializer |     serializer_class = NotificationTransportSerializer | ||||||
|  |  | ||||||
|     @permission_required("authentik_events.change_notificationtransport") |     @permission_required("authentik_events.change_notificationtransport") | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: NotificationTransportTestSerializer(many=False), |             200: NotificationTransportTestSerializer(many=False), | ||||||
|             500: OpenApiResponse(description="Failed to test transport"), |             503: "Failed to test transport", | ||||||
|         }, |         }, | ||||||
|         request=OpenApiTypes.NONE, |         request_body=no_body, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
| @ -85,4 +83,4 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet): | |||||||
|             response.is_valid() |             response.is_valid() | ||||||
|             return Response(response.data) |             return Response(response.data) | ||||||
|         except NotificationTransportError as exc: |         except NotificationTransportError as exc: | ||||||
|             return Response(str(exc.__cause__ or None), status=500) |             return Response(str(exc.__cause__ or None), status=503) | ||||||
|  | |||||||
| @ -1,10 +1,7 @@ | |||||||
| """authentik events app""" | """authentik events app""" | ||||||
| from datetime import timedelta |  | ||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.db import ProgrammingError |  | ||||||
| from django.utils.timezone import now |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikEventsConfig(AppConfig): | class AuthentikEventsConfig(AppConfig): | ||||||
| @ -16,12 +13,3 @@ class AuthentikEventsConfig(AppConfig): | |||||||
|  |  | ||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.events.signals") |         import_module("authentik.events.signals") | ||||||
|         try: |  | ||||||
|             from authentik.events.models import Event |  | ||||||
|  |  | ||||||
|             date_from = now() - timedelta(days=1) |  | ||||||
|  |  | ||||||
|             for event in Event.objects.filter(created__gte=date_from): |  | ||||||
|                 event._set_prom_metrics() |  | ||||||
|         except ProgrammingError: |  | ||||||
|             pass |  | ||||||
|  | |||||||
| @ -1,12 +1,7 @@ | |||||||
| """events GeoIP Reader""" | """events GeoIP Reader""" | ||||||
| from datetime import datetime | from typing import Optional | ||||||
| from os import stat |  | ||||||
| from time import time |  | ||||||
| from typing import Optional, TypedDict |  | ||||||
|  |  | ||||||
| from geoip2.database import Reader | from geoip2.database import Reader | ||||||
| from geoip2.errors import GeoIP2Error |  | ||||||
| from geoip2.models import City |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| @ -14,78 +9,17 @@ from authentik.lib.config import CONFIG | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class GeoIPDict(TypedDict): | def get_geoip_reader() -> Optional[Reader]: | ||||||
|     """GeoIP Details""" |  | ||||||
|  |  | ||||||
|     continent: str |  | ||||||
|     country: str |  | ||||||
|     lat: float |  | ||||||
|     long: float |  | ||||||
|     city: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class GeoIPReader: |  | ||||||
|     """Slim wrapper around GeoIP API""" |  | ||||||
|  |  | ||||||
|     __reader: Optional[Reader] = None |  | ||||||
|     __last_mtime: float = 0.0 |  | ||||||
|  |  | ||||||
|     def __init__(self): |  | ||||||
|         self.__open() |  | ||||||
|  |  | ||||||
|     def __open(self): |  | ||||||
|     """Get GeoIP Reader, if configured, otherwise none""" |     """Get GeoIP Reader, if configured, otherwise none""" | ||||||
|     path = CONFIG.y("authentik.geoip") |     path = CONFIG.y("authentik.geoip") | ||||||
|     if path == "" or not path: |     if path == "" or not path: | ||||||
|             return |         return None | ||||||
|     try: |     try: | ||||||
|         reader = Reader(path) |         reader = Reader(path) | ||||||
|             LOGGER.info("Loaded GeoIP database") |         LOGGER.info("Enabled GeoIP support") | ||||||
|             self.__reader = reader |         return reader | ||||||
|             self.__last_mtime = stat(path).st_mtime |     except OSError: | ||||||
|         except OSError as exc: |  | ||||||
|             LOGGER.warning("Failed to load GeoIP database", exc=exc) |  | ||||||
|  |  | ||||||
|     def __check_expired(self): |  | ||||||
|         """Check if the geoip database has been opened longer than 8 hours, |  | ||||||
|         and re-open it, as it will probably will have been re-downloaded""" |  | ||||||
|         now = time() |  | ||||||
|         diff = datetime.fromtimestamp(now) - datetime.fromtimestamp(self.__last_mtime) |  | ||||||
|         diff_hours = diff.total_seconds() // 3600 |  | ||||||
|         if diff_hours >= 8: |  | ||||||
|             LOGGER.info("GeoIP databased loaded too long, re-opening", diff=diff) |  | ||||||
|             self.__open() |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def enabled(self) -> bool: |  | ||||||
|         """Check if GeoIP is enabled""" |  | ||||||
|         return bool(self.__reader) |  | ||||||
|  |  | ||||||
|     def city(self, ip_address: str) -> Optional[City]: |  | ||||||
|         """Wrapper for Reader.city""" |  | ||||||
|         if not self.enabled: |  | ||||||
|             return None |  | ||||||
|         self.__check_expired() |  | ||||||
|         try: |  | ||||||
|             return self.__reader.city(ip_address) |  | ||||||
|         except (GeoIP2Error, ValueError): |  | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def city_dict(self, ip_address: str) -> Optional[GeoIPDict]: |  | ||||||
|         """Wrapper for self.city that returns a dict""" |  | ||||||
|         city = self.city(ip_address) |  | ||||||
|         if not city: |  | ||||||
|             return None |  | ||||||
|         city_dict: GeoIPDict = { |  | ||||||
|             "continent": city.continent.code, |  | ||||||
|             "country": city.country.iso_code, |  | ||||||
|             "lat": city.location.latitude, |  | ||||||
|             "long": city.location.longitude, |  | ||||||
|             "city": "", |  | ||||||
|         } |  | ||||||
|         if city.city.name: |  | ||||||
|             city_dict["city"] = city.city.name |  | ||||||
|         return city_dict |  | ||||||
|  |  | ||||||
|  | GEOIP_READER = get_geoip_reader() | ||||||
| GEOIP_READER = GeoIPReader() |  | ||||||
|  | |||||||
| @ -1,45 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-06-09 07:58 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_events", "0014_expiry"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="event", |  | ||||||
|             name="action", |  | ||||||
|             field=models.TextField( |  | ||||||
|                 choices=[ |  | ||||||
|                     ("login", "Login"), |  | ||||||
|                     ("login_failed", "Login Failed"), |  | ||||||
|                     ("logout", "Logout"), |  | ||||||
|                     ("user_write", "User Write"), |  | ||||||
|                     ("suspicious_request", "Suspicious Request"), |  | ||||||
|                     ("password_set", "Password Set"), |  | ||||||
|                     ("secret_view", "Secret View"), |  | ||||||
|                     ("invitation_used", "Invite Used"), |  | ||||||
|                     ("authorize_application", "Authorize Application"), |  | ||||||
|                     ("source_linked", "Source Linked"), |  | ||||||
|                     ("impersonation_started", "Impersonation Started"), |  | ||||||
|                     ("impersonation_ended", "Impersonation Ended"), |  | ||||||
|                     ("policy_execution", "Policy Execution"), |  | ||||||
|                     ("policy_exception", "Policy Exception"), |  | ||||||
|                     ("property_mapping_exception", "Property Mapping Exception"), |  | ||||||
|                     ("system_task_execution", "System Task Execution"), |  | ||||||
|                     ("system_task_exception", "System Task Exception"), |  | ||||||
|                     ("configuration_error", "Configuration Error"), |  | ||||||
|                     ("model_created", "Model Created"), |  | ||||||
|                     ("model_updated", "Model Updated"), |  | ||||||
|                     ("model_deleted", "Model Deleted"), |  | ||||||
|                     ("email_sent", "Email Sent"), |  | ||||||
|                     ("update_available", "Update Available"), |  | ||||||
|                     ("custom_", "Custom Prefix"), |  | ||||||
|                 ] |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -10,7 +10,7 @@ from django.db import models | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from prometheus_client import Gauge | from geoip2.errors import GeoIP2Error | ||||||
| from requests import RequestException, post | from requests import RequestException, post | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -28,11 +28,6 @@ from authentik.policies.models import PolicyBindingModel | |||||||
| from authentik.stages.email.utils import TemplateEmailMessage | from authentik.stages.email.utils import TemplateEmailMessage | ||||||
|  |  | ||||||
| LOGGER = get_logger("authentik.events") | LOGGER = get_logger("authentik.events") | ||||||
| GAUGE_EVENTS = Gauge( |  | ||||||
|     "authentik_events", |  | ||||||
|     "Events in authentik", |  | ||||||
|     ["action", "user_username", "app", "client_ip"], |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_event_duration(): | def default_event_duration(): | ||||||
| @ -77,7 +72,6 @@ class EventAction(models.TextChoices): | |||||||
|     MODEL_CREATED = "model_created" |     MODEL_CREATED = "model_created" | ||||||
|     MODEL_UPDATED = "model_updated" |     MODEL_UPDATED = "model_updated" | ||||||
|     MODEL_DELETED = "model_deleted" |     MODEL_DELETED = "model_deleted" | ||||||
|     EMAIL_SENT = "email_sent" |  | ||||||
|  |  | ||||||
|     UPDATE_AVAILABLE = "update_available" |     UPDATE_AVAILABLE = "update_available" | ||||||
|  |  | ||||||
| @ -149,7 +143,7 @@ class Event(ExpiringModel): | |||||||
|                     request.session[SESSION_IMPERSONATE_USER] |                     request.session[SESSION_IMPERSONATE_USER] | ||||||
|                 ) |                 ) | ||||||
|         # User 255.255.255.255 as fallback if IP cannot be determined |         # User 255.255.255.255 as fallback if IP cannot be determined | ||||||
|         self.client_ip = get_client_ip(request) |         self.client_ip = get_client_ip(request) or "255.255.255.255" | ||||||
|         # Apply GeoIP Data, when enabled |         # Apply GeoIP Data, when enabled | ||||||
|         self.with_geoip() |         self.with_geoip() | ||||||
|         # If there's no app set, we get it from the requests too |         # If there's no app set, we get it from the requests too | ||||||
| @ -158,20 +152,22 @@ class Event(ExpiringModel): | |||||||
|         self.save() |         self.save() | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|     def with_geoip(self):  # pragma: no cover |     def with_geoip(self): | ||||||
|         """Apply GeoIP Data, when enabled""" |         """Apply GeoIP Data, when enabled""" | ||||||
|         city = GEOIP_READER.city_dict(self.client_ip) |         if not GEOIP_READER: | ||||||
|         if not city: |  | ||||||
|             return |             return | ||||||
|         self.context["geo"] = city |         try: | ||||||
|  |             response = GEOIP_READER.city(self.client_ip) | ||||||
|     def _set_prom_metrics(self): |             self.context["geo"] = { | ||||||
|         GAUGE_EVENTS.labels( |                 "continent": response.continent.code, | ||||||
|             action=self.action, |                 "country": response.country.iso_code, | ||||||
|             user_username=self.user.get("username"), |                 "lat": response.location.latitude, | ||||||
|             app=self.app, |                 "long": response.location.longitude, | ||||||
|             client_ip=self.client_ip, |             } | ||||||
|         ).set(self.created.timestamp()) |             if response.city.name: | ||||||
|  |                 self.context["geo"]["city"] = response.city.name | ||||||
|  |         except GeoIP2Error as exc: | ||||||
|  |             LOGGER.warning("Failed to add geoIP Data to event", exc=exc) | ||||||
|  |  | ||||||
|     def save(self, *args, **kwargs): |     def save(self, *args, **kwargs): | ||||||
|         if self._state.adding: |         if self._state.adding: | ||||||
| @ -182,8 +178,7 @@ class Event(ExpiringModel): | |||||||
|                 client_ip=self.client_ip, |                 client_ip=self.client_ip, | ||||||
|                 user=self.user, |                 user=self.user, | ||||||
|             ) |             ) | ||||||
|         super().save(*args, **kwargs) |         return super().save(*args, **kwargs) | ||||||
|         self._set_prom_metrics() |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def summary(self) -> str: |     def summary(self) -> str: | ||||||
|  | |||||||
| @ -2,22 +2,14 @@ | |||||||
| from dataclasses import dataclass, field | from dataclasses import dataclass, field | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from timeit import default_timer |  | ||||||
| from traceback import format_tb | from traceback import format_tb | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from celery import Task | from celery import Task | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from prometheus_client import Gauge |  | ||||||
|  |  | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
| GAUGE_TASKS = Gauge( |  | ||||||
|     "authentik_system_tasks", |  | ||||||
|     "System tasks and their status", |  | ||||||
|     ["task_name", "task_uid", "status"], |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TaskResultStatus(Enum): | class TaskResultStatus(Enum): | ||||||
|     """Possible states of tasks""" |     """Possible states of tasks""" | ||||||
| @ -51,9 +43,7 @@ class TaskInfo: | |||||||
|     """Info about a task run""" |     """Info about a task run""" | ||||||
|  |  | ||||||
|     task_name: str |     task_name: str | ||||||
|     start_timestamp: float |     finish_timestamp: datetime | ||||||
|     finish_timestamp: float |  | ||||||
|     finish_time: datetime |  | ||||||
|  |  | ||||||
|     result: TaskResult |     result: TaskResult | ||||||
|  |  | ||||||
| @ -83,28 +73,12 @@ class TaskInfo: | |||||||
|         """Delete task info from cache""" |         """Delete task info from cache""" | ||||||
|         return cache.delete(f"task_{self.task_name}") |         return cache.delete(f"task_{self.task_name}") | ||||||
|  |  | ||||||
|     def set_prom_metrics(self): |  | ||||||
|         """Update prometheus metrics""" |  | ||||||
|         start = default_timer() |  | ||||||
|         if hasattr(self, "start_timestamp"): |  | ||||||
|             start = self.start_timestamp |  | ||||||
|         try: |  | ||||||
|             duration = max(self.finish_timestamp - start, 0) |  | ||||||
|         except TypeError: |  | ||||||
|             duration = 0 |  | ||||||
|         GAUGE_TASKS.labels( |  | ||||||
|             task_name=self.task_name, |  | ||||||
|             task_uid=self.result.uid or "", |  | ||||||
|             status=self.result.status, |  | ||||||
|         ).set(duration) |  | ||||||
|  |  | ||||||
|     def save(self, timeout_hours=6): |     def save(self, timeout_hours=6): | ||||||
|         """Save task into cache""" |         """Save task into cache""" | ||||||
|         key = f"task_{self.task_name}" |         key = f"task_{self.task_name}" | ||||||
|         if self.result.uid: |         if self.result.uid: | ||||||
|             key += f"_{self.result.uid}" |             key += f"_{self.result.uid}" | ||||||
|             self.task_name += f"_{self.result.uid}" |             self.task_name += f"_{self.result.uid}" | ||||||
|         self.set_prom_metrics() |  | ||||||
|         cache.set(key, self, timeout=timeout_hours * 60 * 60) |         cache.set(key, self, timeout=timeout_hours * 60 * 60) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -124,7 +98,6 @@ class MonitoredTask(Task): | |||||||
|         self._uid = None |         self._uid = None | ||||||
|         self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[]) |         self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[]) | ||||||
|         self.result_timeout_hours = 6 |         self.result_timeout_hours = 6 | ||||||
|         self.start = default_timer() |  | ||||||
|  |  | ||||||
|     def set_uid(self, uid: str): |     def set_uid(self, uid: str): | ||||||
|         """Set UID, so in the case of an unexpected error its saved correctly""" |         """Set UID, so in the case of an unexpected error its saved correctly""" | ||||||
| @ -144,9 +117,7 @@ class MonitoredTask(Task): | |||||||
|             TaskInfo( |             TaskInfo( | ||||||
|                 task_name=self.__name__, |                 task_name=self.__name__, | ||||||
|                 task_description=self.__doc__, |                 task_description=self.__doc__, | ||||||
|                 start_timestamp=self.start, |                 finish_timestamp=datetime.now(), | ||||||
|                 finish_timestamp=default_timer(), |  | ||||||
|                 finish_time=datetime.now(), |  | ||||||
|                 result=self._result, |                 result=self._result, | ||||||
|                 task_call_module=self.__module__, |                 task_call_module=self.__module__, | ||||||
|                 task_call_func=self.__name__, |                 task_call_func=self.__name__, | ||||||
| @ -162,9 +133,7 @@ class MonitoredTask(Task): | |||||||
|         TaskInfo( |         TaskInfo( | ||||||
|             task_name=self.__name__, |             task_name=self.__name__, | ||||||
|             task_description=self.__doc__, |             task_description=self.__doc__, | ||||||
|             start_timestamp=self.start, |             finish_timestamp=datetime.now(), | ||||||
|             finish_timestamp=default_timer(), |  | ||||||
|             finish_time=datetime.now(), |  | ||||||
|             result=self._result, |             result=self._result, | ||||||
|             task_call_module=self.__module__, |             task_call_module=self.__module__, | ||||||
|             task_call_func=self.__name__, |             task_call_func=self.__name__, | ||||||
| @ -182,7 +151,3 @@ class MonitoredTask(Task): | |||||||
|  |  | ||||||
|     def run(self, *args, **kwargs): |     def run(self, *args, **kwargs): | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
| for task in TaskInfo.all().values(): |  | ||||||
|     task.set_prom_metrics() |  | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """Event notification tasks""" | """Event notification tasks""" | ||||||
| from guardian.shortcuts import get_anonymous_user | 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.core.models import User | ||||||
| from authentik.events.models import ( | 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) |         LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid) | ||||||
|         return |         return | ||||||
|     event: Event = events.first() |     event: Event = events.first() | ||||||
|     triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name) |     trigger: NotificationRule = NotificationRule.objects.get(name=trigger_name) | ||||||
|     if not triggers.exists(): |  | ||||||
|         return |  | ||||||
|     trigger = triggers.first() |  | ||||||
|  |  | ||||||
|     if "policy_uuid" in event.context: |     if "policy_uuid" in event.context: | ||||||
|         policy_uuid = event.context["policy_uuid"] |         policy_uuid = event.context["policy_uuid"] | ||||||
| @ -61,13 +58,7 @@ def event_trigger_handler(event_uuid: str, trigger_name: str): | |||||||
|         return |         return | ||||||
|  |  | ||||||
|     LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger) |     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() | ||||||
|         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 |  | ||||||
|     policy_engine = PolicyEngine(trigger, user) |     policy_engine = PolicyEngine(trigger, user) | ||||||
|     policy_engine.mode = PolicyEngineMode.MODE_ANY |     policy_engine.mode = PolicyEngineMode.MODE_ANY | ||||||
|     policy_engine.empty_result = False |     policy_engine.empty_result = False | ||||||
|  | |||||||
| @ -1,26 +0,0 @@ | |||||||
| """Test GeoIP Wrapper""" |  | ||||||
| from django.test import TestCase |  | ||||||
|  |  | ||||||
| from authentik.events.geo import GeoIPReader |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestGeoIP(TestCase): |  | ||||||
|     """Test GeoIP Wrapper""" |  | ||||||
|  |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         self.reader = GeoIPReader() |  | ||||||
|  |  | ||||||
|     def test_simple(self): |  | ||||||
|         """Test simple city wrapper""" |  | ||||||
|         # IPs from |  | ||||||
|         # https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json |  | ||||||
|         self.assertEqual( |  | ||||||
|             self.reader.city_dict("2.125.160.216"), |  | ||||||
|             { |  | ||||||
|                 "city": "Boxford", |  | ||||||
|                 "continent": "EU", |  | ||||||
|                 "country": "GB", |  | ||||||
|                 "lat": 51.75, |  | ||||||
|                 "long": -1.25, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
| @ -2,7 +2,6 @@ | |||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.flows.api.stages import StageSerializer | from authentik.flows.api.stages import StageSerializer | ||||||
| from authentik.flows.models import FlowStageBinding | from authentik.flows.models import FlowStageBinding | ||||||
|  |  | ||||||
| @ -28,7 +27,7 @@ class FlowStageBindingSerializer(ModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowStageBindingViewSet(UsedByMixin, ModelViewSet): | class FlowStageBindingViewSet(ModelViewSet): | ||||||
|     """FlowStageBinding Viewset""" |     """FlowStageBinding Viewset""" | ||||||
|  |  | ||||||
|     queryset = FlowStageBinding.objects.all() |     queryset = FlowStageBinding.objects.all() | ||||||
|  | |||||||
| @ -6,11 +6,10 @@ from django.db.models import Model | |||||||
| from django.http.response import HttpResponseBadRequest, JsonResponse | from django.http.response import HttpResponseBadRequest, JsonResponse | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_yasg import openapi | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer | from drf_yasg.utils import no_body, swagger_auto_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import BooleanField, FileField, ReadOnlyField |  | ||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -24,7 +23,6 @@ from rest_framework.viewsets import ModelViewSet | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import CacheSerializer, LinkSerializer | from authentik.core.api.utils import CacheSerializer, LinkSerializer | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
| @ -43,18 +41,10 @@ class FlowSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     cache_count = SerializerMethodField() |     cache_count = SerializerMethodField() | ||||||
|  |  | ||||||
|     background = ReadOnlyField(source="background_url") |     def get_cache_count(self, flow: Flow): | ||||||
|  |  | ||||||
|     export_url = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     def get_cache_count(self, flow: Flow) -> int: |  | ||||||
|         """Get count of cached flows""" |         """Get count of cached flows""" | ||||||
|         return len(cache.keys(f"{cache_key(flow)}*")) |         return len(cache.keys(f"{cache_key(flow)}*")) | ||||||
|  |  | ||||||
|     def get_export_url(self, flow: Flow) -> str: |  | ||||||
|         """Get export URL for flow""" |  | ||||||
|         return reverse("authentik_api:flow-export", kwargs={"slug": flow.slug}) |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Flow |         model = Flow | ||||||
| @ -70,12 +60,7 @@ class FlowSerializer(ModelSerializer): | |||||||
|             "policies", |             "policies", | ||||||
|             "cache_count", |             "cache_count", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|             "compatibility_mode", |  | ||||||
|             "export_url", |  | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |  | ||||||
|             "background": {"read_only": True}, |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowDiagramSerializer(Serializer): | class FlowDiagramSerializer(Serializer): | ||||||
| @ -102,7 +87,7 @@ class DiagramElement: | |||||||
|         return f"{self.identifier}=>{self.type}: {self.rest}" |         return f"{self.identifier}=>{self.type}: {self.rest}" | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowViewSet(UsedByMixin, ModelViewSet): | class FlowViewSet(ModelViewSet): | ||||||
|     """Flow Viewset""" |     """Flow Viewset""" | ||||||
|  |  | ||||||
|     queryset = Flow.objects.all() |     queryset = Flow.objects.all() | ||||||
| @ -112,19 +97,16 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     filterset_fields = ["flow_uuid", "name", "slug", "designation"] |     filterset_fields = ["flow_uuid", "name", "slug", "designation"] | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_flows.view_flow_cache"]) |     @permission_required(None, ["authentik_flows.view_flow_cache"]) | ||||||
|     @extend_schema(responses={200: CacheSerializer(many=False)}) |     @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def cache_info(self, request: Request) -> Response: |     def cache_info(self, request: Request) -> Response: | ||||||
|         """Info about cached flows""" |         """Info about cached flows""" | ||||||
|         return Response(data={"count": len(cache.keys("flow_*"))}) |         return Response(data={"count": len(cache.keys("flow_*"))}) | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_flows.clear_flow_cache"]) |     @permission_required(None, ["authentik_flows.clear_flow_cache"]) | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request=OpenApiTypes.NONE, |         request_body=no_body, | ||||||
|         responses={ |         responses={204: "Successfully cleared cache", 400: "Bad request"}, | ||||||
|             204: OpenApiResponse(description="Successfully cleared cache"), |  | ||||||
|             400: OpenApiResponse(description="Bad request"), |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     @action(detail=False, methods=["POST"]) |     @action(detail=False, methods=["POST"]) | ||||||
|     def cache_clear(self, request: Request) -> Response: |     def cache_clear(self, request: Request) -> Response: | ||||||
| @ -151,16 +133,17 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|             "authentik_stages_prompt.change_prompt", |             "authentik_stages_prompt.change_prompt", | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request={ |         request_body=no_body, | ||||||
|             "multipart/form-data": inline_serializer( |         manual_parameters=[ | ||||||
|                 "SetIcon", fields={"file": FileField()} |             openapi.Parameter( | ||||||
|  |                 name="file", | ||||||
|  |                 in_=openapi.IN_FORM, | ||||||
|  |                 type=openapi.TYPE_FILE, | ||||||
|  |                 required=True, | ||||||
|             ) |             ) | ||||||
|         }, |         ], | ||||||
|         responses={ |         responses={204: "Successfully imported flow", 400: "Bad request"}, | ||||||
|             204: OpenApiResponse(description="Successfully imported flow"), |  | ||||||
|             400: OpenApiResponse(description="Bad request"), |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) |     @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) | ||||||
|     def import_flow(self, request: Request) -> Response: |     def import_flow(self, request: Request) -> Response: | ||||||
| @ -174,8 +157,8 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|         successful = importer.apply() |         successful = importer.apply() | ||||||
|         if not successful: |         if not successful: | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|             return Response(status=204) |             return Response(status=204) | ||||||
|  |         return HttpResponseBadRequest() | ||||||
|  |  | ||||||
|     @permission_required( |     @permission_required( | ||||||
|         "authentik_flows.export_flow", |         "authentik_flows.export_flow", | ||||||
| @ -188,9 +171,11 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|             "authentik_stages_prompt.view_prompt", |             "authentik_stages_prompt.view_prompt", | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             "200": OpenApiResponse(response=OpenApiTypes.BINARY), |             "200": openapi.Response( | ||||||
|  |                 "File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE) | ||||||
|  |             ), | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
| @ -203,7 +188,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|         response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' |         response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: FlowDiagramSerializer()}) |     @swagger_auto_schema(responses={200: FlowDiagramSerializer()}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def diagram(self, request: Request, slug: str) -> Response: |     def diagram(self, request: Request, slug: str) -> Response: | ||||||
| @ -225,7 +210,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|                     request.user, "authentik_policies.view_policybinding" |                     request.user, "authentik_policies.view_policybinding" | ||||||
|                 ) |                 ) | ||||||
|                 .filter(target=stage_binding) |                 .filter(target=stage_binding) | ||||||
|                 .exclude(policy__isnull=True) |  | ||||||
|                 .order_by("order") |                 .order_by("order") | ||||||
|             ): |             ): | ||||||
|                 body.append( |                 body.append( | ||||||
| @ -274,20 +258,17 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|         return Response({"diagram": diagram}) |         return Response({"diagram": diagram}) | ||||||
|  |  | ||||||
|     @permission_required("authentik_flows.change_flow") |     @permission_required("authentik_flows.change_flow") | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         request={ |         request_body=no_body, | ||||||
|             "multipart/form-data": inline_serializer( |         manual_parameters=[ | ||||||
|                 "SetIcon", |             openapi.Parameter( | ||||||
|                 fields={ |                 name="file", | ||||||
|                     "file": FileField(required=False), |                 in_=openapi.IN_FORM, | ||||||
|                     "clear": BooleanField(default=False), |                 type=openapi.TYPE_FILE, | ||||||
|                 }, |                 required=True, | ||||||
|             ) |             ) | ||||||
|         }, |         ], | ||||||
|         responses={ |         responses={200: "Success", 400: "Bad request"}, | ||||||
|             200: OpenApiResponse(description="Success"), |  | ||||||
|             400: OpenApiResponse(description="Bad request"), |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     @action( |     @action( | ||||||
|         detail=True, |         detail=True, | ||||||
| @ -299,49 +280,16 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def set_background(self, request: Request, slug: str): |     def set_background(self, request: Request, slug: str): | ||||||
|         """Set Flow background""" |         """Set Flow background""" | ||||||
|         flow: Flow = self.get_object() |         app: Flow = self.get_object() | ||||||
|         background = request.FILES.get("file", None) |         icon = request.FILES.get("file", None) | ||||||
|         clear = request.data.get("clear", False) |         if not icon: | ||||||
|         if clear: |  | ||||||
|             # .delete() saves the model by default |  | ||||||
|             flow.background.delete() |  | ||||||
|             return Response({}) |  | ||||||
|         if background: |  | ||||||
|             flow.background = background |  | ||||||
|             flow.save() |  | ||||||
|             return Response({}) |  | ||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|  |         app.background = icon | ||||||
|     @permission_required("authentik_core.change_application") |         app.save() | ||||||
|     @extend_schema( |  | ||||||
|         request=inline_serializer("SetIconURL", fields={"url": CharField()}), |  | ||||||
|         responses={ |  | ||||||
|             200: OpenApiResponse(description="Success"), |  | ||||||
|             400: OpenApiResponse(description="Bad request"), |  | ||||||
|         }, |  | ||||||
|     ) |  | ||||||
|     @action( |  | ||||||
|         detail=True, |  | ||||||
|         pagination_class=None, |  | ||||||
|         filter_backends=[], |  | ||||||
|         methods=["POST"], |  | ||||||
|     ) |  | ||||||
|     # pylint: disable=unused-argument |  | ||||||
|     def set_background_url(self, request: Request, slug: str): |  | ||||||
|         """Set Flow background (as URL)""" |  | ||||||
|         flow: Flow = self.get_object() |  | ||||||
|         url = request.data.get("url", None) |  | ||||||
|         if not url: |  | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|         flow.background.name = url |  | ||||||
|         flow.save() |  | ||||||
|         return Response({}) |         return Response({}) | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={200: LinkSerializer(many=False), 400: "Flow not applicable"}, | ||||||
|             200: LinkSerializer(many=False), |  | ||||||
|             400: OpenApiResponse(description="Flow not applicable"), |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|  | |||||||
| @ -1,17 +1,16 @@ | |||||||
| """Flow Stage API Views""" | """Flow Stage API Views""" | ||||||
| from typing import Iterable | from typing import Iterable | ||||||
|  |  | ||||||
| from django.urls.base import reverse | from drf_yasg.utils import swagger_auto_schema | ||||||
| from drf_spectacular.utils import extend_schema |  | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
|  | from rest_framework.fields import BooleanField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin |  | ||||||
| from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| from authentik.flows.api.flows import FlowSerializer | from authentik.flows.api.flows import FlowSerializer | ||||||
| @ -21,6 +20,12 @@ from authentik.lib.utils.reflection import all_subclasses | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class StageUserSettingSerializer(UserSettingSerializer): | ||||||
|  |     """User settings but can include a configure flow""" | ||||||
|  |  | ||||||
|  |     configure_flow = BooleanField(required=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| class StageSerializer(ModelSerializer, MetaNameSerializer): | class StageSerializer(ModelSerializer, MetaNameSerializer): | ||||||
|     """Stage Serializer""" |     """Stage Serializer""" | ||||||
|  |  | ||||||
| @ -50,7 +55,6 @@ class StageSerializer(ModelSerializer, MetaNameSerializer): | |||||||
| class StageViewSet( | class StageViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -61,10 +65,10 @@ class StageViewSet( | |||||||
|     search_fields = ["name"] |     search_fields = ["name"] | ||||||
|     filterset_fields = ["name"] |     filterset_fields = ["name"] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self): | ||||||
|         return Stage.objects.select_subclasses() |         return Stage.objects.select_subclasses() | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable stage types""" |         """Get all creatable stage types""" | ||||||
| @ -82,7 +86,7 @@ class StageViewSet( | |||||||
|         data = sorted(data, key=lambda x: x["name"]) |         data = sorted(data, key=lambda x: x["name"]) | ||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: UserSettingSerializer(many=True)}) |     @swagger_auto_schema(responses={200: StageUserSettingSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def user_settings(self, request: Request) -> Response: |     def user_settings(self, request: Request) -> Response: | ||||||
|         """Get all stages the user can configure""" |         """Get all stages the user can configure""" | ||||||
| @ -94,9 +98,8 @@ class StageViewSet( | |||||||
|                 continue |                 continue | ||||||
|             user_settings.initial_data["object_uid"] = str(stage.pk) |             user_settings.initial_data["object_uid"] = str(stage.pk) | ||||||
|             if hasattr(stage, "configure_flow"): |             if hasattr(stage, "configure_flow"): | ||||||
|                 user_settings.initial_data["configure_url"] = reverse( |                 user_settings.initial_data["configure_flow"] = bool( | ||||||
|                     "authentik_flows:configure", |                     stage.configure_flow | ||||||
|                     kwargs={"stage_uuid": stage.pk}, |  | ||||||
|                 ) |                 ) | ||||||
|             if not user_settings.is_valid(): |             if not user_settings.is_valid(): | ||||||
|                 LOGGER.warning(user_settings.errors) |                 LOGGER.warning(user_settings.errors) | ||||||
|  | |||||||
| @ -2,9 +2,6 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.db.utils import ProgrammingError |  | ||||||
|  |  | ||||||
| from authentik.lib.utils.reflection import all_subclasses |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikFlowsConfig(AppConfig): | class AuthentikFlowsConfig(AppConfig): | ||||||
| @ -17,10 +14,3 @@ class AuthentikFlowsConfig(AppConfig): | |||||||
|  |  | ||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.flows.signals") |         import_module("authentik.flows.signals") | ||||||
|         try: |  | ||||||
|             from authentik.flows.models import Stage |  | ||||||
|  |  | ||||||
|             for stage in all_subclasses(Stage): |  | ||||||
|                 _ = stage().type |  | ||||||
|         except ProgrammingError: |  | ||||||
|             pass |  | ||||||
|  | |||||||
| @ -28,14 +28,6 @@ class ErrorDetailSerializer(PassiveSerializer): | |||||||
|     code = CharField() |     code = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
| class ContextualFlowInfo(PassiveSerializer): |  | ||||||
|     """Contextual flow information for a challenge""" |  | ||||||
|  |  | ||||||
|     title = CharField(required=False, allow_blank=True) |  | ||||||
|     background = CharField(required=False) |  | ||||||
|     cancel_url = CharField() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Challenge(PassiveSerializer): | class Challenge(PassiveSerializer): | ||||||
|     """Challenge that gets sent to the client based on which stage |     """Challenge that gets sent to the client based on which stage | ||||||
|     is currently active""" |     is currently active""" | ||||||
| @ -43,8 +35,9 @@ class Challenge(PassiveSerializer): | |||||||
|     type = ChoiceField( |     type = ChoiceField( | ||||||
|         choices=[(x.value, x.name) for x in ChallengeTypes], |         choices=[(x.value, x.name) for x in ChallengeTypes], | ||||||
|     ) |     ) | ||||||
|     flow_info = ContextualFlowInfo(required=False) |     component = CharField(required=False) | ||||||
|     component = CharField(default="") |     title = CharField(required=False) | ||||||
|  |     background = CharField(required=False) | ||||||
|  |  | ||||||
|     response_errors = DictField( |     response_errors = DictField( | ||||||
|         child=ErrorDetailSerializer(many=True), allow_empty=True, required=False |         child=ErrorDetailSerializer(many=True), allow_empty=True, required=False | ||||||
| @ -55,20 +48,18 @@ class RedirectChallenge(Challenge): | |||||||
|     """Challenge type to redirect the client""" |     """Challenge type to redirect the client""" | ||||||
|  |  | ||||||
|     to = CharField() |     to = CharField() | ||||||
|     component = CharField(default="xak-flow-redirect") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ShellChallenge(Challenge): | class ShellChallenge(Challenge): | ||||||
|     """challenge type to render HTML as-is""" |     """Legacy challenge type to render HTML as-is""" | ||||||
|  |  | ||||||
|     body = CharField() |     body = CharField() | ||||||
|     component = CharField(default="xak-flow-shell") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class WithUserInfoChallenge(Challenge): | class WithUserInfoChallenge(Challenge): | ||||||
|     """Challenge base which shows some user info""" |     """Challenge base which shows some user info""" | ||||||
|  |  | ||||||
|     pending_user = CharField(allow_blank=True) |     pending_user = CharField() | ||||||
|     pending_user_avatar = CharField() |     pending_user_avatar = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -76,7 +67,6 @@ class AccessDeniedChallenge(Challenge): | |||||||
|     """Challenge when a flow's active stage calls `stage_invalid()`.""" |     """Challenge when a flow's active stage calls `stage_invalid()`.""" | ||||||
|  |  | ||||||
|     error_message = CharField(required=False) |     error_message = CharField(required=False) | ||||||
|     component = CharField(default="ak-stage-access-denied") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PermissionSerializer(PassiveSerializer): | class PermissionSerializer(PassiveSerializer): | ||||||
| @ -90,7 +80,6 @@ class ChallengeResponse(PassiveSerializer): | |||||||
|     """Base class for all challenge responses""" |     """Base class for all challenge responses""" | ||||||
|  |  | ||||||
|     stage: Optional["StageView"] |     stage: Optional["StageView"] | ||||||
|     component = CharField(default="xak-flow-response-default") |  | ||||||
|  |  | ||||||
|     def __init__(self, instance=None, data=None, **kwargs): |     def __init__(self, instance=None, data=None, **kwargs): | ||||||
|         self.stage = kwargs.pop("stage", None) |         self.stage = kwargs.pop("stage", None) | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ context["user_backend"] = "django.contrib.auth.backends.ModelBackend" | |||||||
| return True""" | return True""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     from authentik.stages.prompt.models import FieldTypes |     from authentik.stages.prompt.models import FieldTypes | ||||||
|  |  | ||||||
|     User = apps.get_model("authentik_core", "User") |     User = apps.get_model("authentik_core", "User") | ||||||
| @ -52,20 +52,20 @@ def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor | |||||||
|  |  | ||||||
|     # Create a policy that sets the flow's user |     # Create a policy that sets the flow's user | ||||||
|     prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( |     prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( | ||||||
|         name="default-oobe-prefill-user", |         name="default-oob-prefill-user", | ||||||
|         defaults={"expression": PREFILL_POLICY_EXPRESSION}, |         defaults={"expression": PREFILL_POLICY_EXPRESSION}, | ||||||
|     ) |     ) | ||||||
|     password_usable_policy, _ = ExpressionPolicy.objects.using( |     password_usable_policy, _ = ExpressionPolicy.objects.using( | ||||||
|         db_alias |         db_alias | ||||||
|     ).update_or_create( |     ).update_or_create( | ||||||
|         name="default-oobe-password-usable", |         name="default-oob-password-usable", | ||||||
|         defaults={"expression": PW_USABLE_POLICY_EXPRESSION}, |         defaults={"expression": PW_USABLE_POLICY_EXPRESSION}, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     prompt_header, _ = Prompt.objects.using(db_alias).update_or_create( |     prompt_header, _ = Prompt.objects.using(db_alias).update_or_create( | ||||||
|         field_key="oobe-header-text", |         field_key="oob-header-text", | ||||||
|         defaults={ |         defaults={ | ||||||
|             "label": "oobe-header-text", |             "label": "oob-header-text", | ||||||
|             "type": FieldTypes.STATIC, |             "type": FieldTypes.STATIC, | ||||||
|             "placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.", |             "placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.", | ||||||
|             "order": 100, |             "order": 100, | ||||||
| @ -84,7 +84,7 @@ def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor | |||||||
|     password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat") |     password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat") | ||||||
|  |  | ||||||
|     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( |     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( | ||||||
|         name="default-oobe-password", |         name="default-oob-password", | ||||||
|     ) |     ) | ||||||
|     prompt_stage.fields.set( |     prompt_stage.fields.set( | ||||||
|         [prompt_header, prompt_email, password_first, password_second] |         [prompt_header, prompt_email, password_first, password_second] | ||||||
| @ -102,7 +102,7 @@ def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor | |||||||
|         slug="initial-setup", |         slug="initial-setup", | ||||||
|         designation=FlowDesignation.STAGE_CONFIGURATION, |         designation=FlowDesignation.STAGE_CONFIGURATION, | ||||||
|         defaults={ |         defaults={ | ||||||
|             "name": "default-oobe-setup", |             "name": "default-oob-setup", | ||||||
|             "title": "Welcome to authentik!", |             "title": "Welcome to authentik!", | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
| @ -146,5 +146,5 @@ class Migration(migrations.Migration): | |||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     operations = [ |     operations = [ | ||||||
|         migrations.RunPython(create_default_oobe_flow), |         migrations.RunPython(create_default_oob_flow), | ||||||
|     ] |     ] | ||||||
|  | |||||||
| @ -1,23 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-06-05 17:34 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_flows", "0018_oob_flows"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="flow", |  | ||||||
|             name="background", |  | ||||||
|             field=models.FileField( |  | ||||||
|                 default=None, |  | ||||||
|                 help_text="Background shown during execution", |  | ||||||
|                 null=True, |  | ||||||
|                 upload_to="flow-backgrounds/", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -1,21 +0,0 @@ | |||||||
| # Generated by Django 3.2.3 on 2021-06-05 17:56 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("authentik_flows", "0019_alter_flow_background"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="flow", |  | ||||||
|             name="compatibility_mode", |  | ||||||
|             field=models.BooleanField( |  | ||||||
|                 default=True, |  | ||||||
|                 help_text="Enable compatibility mode, increases compatibility with password managers on mobile devices.", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @ -72,7 +72,7 @@ class Stage(SerializerModel): | |||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         if hasattr(self, "__in_memory_type"): |         if hasattr(self, "__in_memory_type"): | ||||||
|             return f"In-memory Stage {getattr(self, '__in_memory_type')}" |             return f"In-memory Stage {getattr(self, '__in_memory_type')}" | ||||||
|         return f"Stage {self.name}" |         return self.name | ||||||
|  |  | ||||||
|  |  | ||||||
| def in_memory_stage(view: Type["StageView"]) -> Stage: | def in_memory_stage(view: Type["StageView"]) -> Stage: | ||||||
| @ -110,31 +110,11 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|  |  | ||||||
|     background = models.FileField( |     background = models.FileField( | ||||||
|         upload_to="flow-backgrounds/", |         upload_to="flow-backgrounds/", | ||||||
|         default=None, |         default="../static/dist/assets/images/flow_background.jpg", | ||||||
|         null=True, |         blank=True, | ||||||
|         help_text=_("Background shown during execution"), |         help_text=_("Background shown during execution"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     compatibility_mode = models.BooleanField( |  | ||||||
|         default=True, |  | ||||||
|         help_text=_( |  | ||||||
|             "Enable compatibility mode, increases compatibility with " |  | ||||||
|             "password managers on mobile devices." |  | ||||||
|         ), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def background_url(self) -> str: |  | ||||||
|         """Get the URL to the background image. If the name is /static or starts with http |  | ||||||
|         it is returned as-is""" |  | ||||||
|         if not self.background: |  | ||||||
|             return "/static/dist/assets/images/flow_background.jpg" |  | ||||||
|         if self.background.name.startswith("http") or self.background.name.startswith( |  | ||||||
|             "/static" |  | ||||||
|         ): |  | ||||||
|             return self.background.name |  | ||||||
|         return self.background.url |  | ||||||
|  |  | ||||||
|     stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) |     stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -162,6 +142,11 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|         LOGGER.debug("with_policy: no flow found", filters=flow_filter) |         LOGGER.debug("with_policy: no flow found", filters=flow_filter) | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|  |     def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]: | ||||||
|  |         """Get a related flow with `designation`. Currently this only queries | ||||||
|  |         Flows by `designation`, but will eventually use `self` for related lookups.""" | ||||||
|  |         return Flow.with_policy(request, designation=designation) | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Flow {self.name} ({self.slug})" |         return f"Flow {self.name} ({self.slug})" | ||||||
|  |  | ||||||
| @ -212,7 +197,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel): | |||||||
|         return FlowStageBindingSerializer |         return FlowStageBindingSerializer | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Flow-stage binding #{self.order} to {self.target}" |         return f"{self.target} #{self.order}" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,7 +4,6 @@ from typing import Any, Optional | |||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from prometheus_client import Histogram |  | ||||||
| from sentry_sdk.hub import Hub | from sentry_sdk.hub import Hub | ||||||
| from sentry_sdk.tracing import Span | from sentry_sdk.tracing import Span | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
| @ -15,7 +14,6 @@ from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableExce | |||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowStageBinding, Stage | from authentik.flows.models import Flow, FlowStageBinding, Stage | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.root.monitoring import UpdatingGauge |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||||
| @ -23,16 +21,6 @@ PLAN_CONTEXT_SSO = "is_sso" | |||||||
| PLAN_CONTEXT_REDIRECT = "redirect" | PLAN_CONTEXT_REDIRECT = "redirect" | ||||||
| PLAN_CONTEXT_APPLICATION = "application" | PLAN_CONTEXT_APPLICATION = "application" | ||||||
| PLAN_CONTEXT_SOURCE = "source" | PLAN_CONTEXT_SOURCE = "source" | ||||||
| GAUGE_FLOWS_CACHED = UpdatingGauge( |  | ||||||
|     "authentik_flows_cached", |  | ||||||
|     "Cached flows", |  | ||||||
|     update_func=lambda: len(cache.keys("flow_*") or []), |  | ||||||
| ) |  | ||||||
| HIST_FLOWS_PLAN_TIME = Histogram( |  | ||||||
|     "authentik_flows_plan_time", |  | ||||||
|     "Duration to build a plan for a flow", |  | ||||||
|     ["flow_slug"], |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def cache_key(flow: Flow, user: Optional[User] = None) -> str: | def cache_key(flow: Flow, user: Optional[User] = None) -> str: | ||||||
| @ -158,7 +146,6 @@ class FlowPlanner: | |||||||
|             ) |             ) | ||||||
|             plan = self._build_plan(user, request, default_context) |             plan = self._build_plan(user, request, default_context) | ||||||
|             cache.set(cache_key(self.flow, user), plan) |             cache.set(cache_key(self.flow, user), plan) | ||||||
|             GAUGE_FLOWS_CACHED.update() |  | ||||||
|             if not plan.stages and not self.allow_empty_flows: |             if not plan.stages and not self.allow_empty_flows: | ||||||
|                 raise EmptyFlowException() |                 raise EmptyFlowException() | ||||||
|             return plan |             return plan | ||||||
| @ -171,9 +158,7 @@ class FlowPlanner: | |||||||
|     ) -> FlowPlan: |     ) -> FlowPlan: | ||||||
|         """Build flow plan by checking each stage in their respective |         """Build flow plan by checking each stage in their respective | ||||||
|         order and checking the applied policies""" |         order and checking the applied policies""" | ||||||
|         with Hub.current.start_span( |         with Hub.current.start_span(op="flow.planner.build_plan") as span: | ||||||
|             op="flow.planner.build_plan" |  | ||||||
|         ) as span, HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time(): |  | ||||||
|             span: Span |             span: Span | ||||||
|             span.set_data("flow", self.flow) |             span.set_data("flow", self.flow) | ||||||
|             span.set_data("user", user) |             span.set_data("user", user) | ||||||
| @ -217,7 +202,6 @@ class FlowPlanner: | |||||||
|                     marker = ReevaluateMarker(binding=binding, user=user) |                     marker = ReevaluateMarker(binding=binding, user=user) | ||||||
|                 if stage: |                 if stage: | ||||||
|                     plan.append(stage, marker) |                     plan.append(stage, marker) | ||||||
|             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) |  | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(plan): finished building", |             "f(plan): finished building", | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ from django.contrib.auth.models import AnonymousUser | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.http.response import HttpResponse | from django.http.response import HttpResponse | ||||||
| from django.urls import reverse |  | ||||||
| from django.views.generic.base import View | from django.views.generic.base import View | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| @ -12,7 +11,6 @@ from authentik.core.models import DEFAULT_AVATAR, User | |||||||
| from authentik.flows.challenge import ( | from authentik.flows.challenge import ( | ||||||
|     Challenge, |     Challenge, | ||||||
|     ChallengeResponse, |     ChallengeResponse, | ||||||
|     ContextualFlowInfo, |  | ||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| @ -50,17 +48,14 @@ class StageView(View): | |||||||
|         self.executor = executor |         self.executor = executor | ||||||
|         super().__init__(**kwargs) |         super().__init__(**kwargs) | ||||||
|  |  | ||||||
|     def get_pending_user(self, for_display=False) -> User: |     def get_pending_user(self) -> User: | ||||||
|         """Either show the matched User object or show what the user entered, |         """Either show the matched User object or show what the user entered, | ||||||
|         based on what the earlier stage (mostly IdentificationStage) set. |         based on what the earlier stage (mostly IdentificationStage) set. | ||||||
|         _USER_IDENTIFIER overrides the first User, as PENDING_USER is used for |         _USER_IDENTIFIER overrides the first User, as PENDING_USER is used for | ||||||
|         other things besides the form display. |         other things besides the form display. | ||||||
|  |  | ||||||
|         If no user is pending, returns request.user""" |         If no user is pending, returns request.user""" | ||||||
|         if ( |         if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context: | ||||||
|             PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context |  | ||||||
|             and for_display |  | ||||||
|         ): |  | ||||||
|             return User( |             return User( | ||||||
|                 username=self.executor.plan.context.get( |                 username=self.executor.plan.context.get( | ||||||
|                     PLAN_CONTEXT_PENDING_USER_IDENTIFIER |                     PLAN_CONTEXT_PENDING_USER_IDENTIFIER | ||||||
| @ -98,21 +93,15 @@ class ChallengeStageView(StageView): | |||||||
|  |  | ||||||
|     def _get_challenge(self, *args, **kwargs) -> Challenge: |     def _get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         challenge = self.get_challenge(*args, **kwargs) |         challenge = self.get_challenge(*args, **kwargs) | ||||||
|         if "flow_info" not in challenge.initial_data: |         if "title" not in challenge.initial_data: | ||||||
|             flow_info = ContextualFlowInfo( |             challenge.initial_data["title"] = self.executor.flow.title | ||||||
|                 data={ |         if "background" not in challenge.initial_data: | ||||||
|                     "title": self.executor.flow.title, |             challenge.initial_data["background"] = self.executor.flow.background.url | ||||||
|                     "background": self.executor.flow.background_url, |  | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                 } |  | ||||||
|             ) |  | ||||||
|             flow_info.is_valid() |  | ||||||
|             challenge.initial_data["flow_info"] = flow_info.data |  | ||||||
|         if isinstance(challenge, WithUserInfoChallenge): |         if isinstance(challenge, WithUserInfoChallenge): | ||||||
|             # If there's a pending user, update the `username` field |             # If there's a pending user, update the `username` field | ||||||
|             # this field is only used by password managers. |             # this field is only used by password managers. | ||||||
|             # If there's no user set, an error is raised later. |             # If there's no user set, an error is raised later. | ||||||
|             if user := self.get_pending_user(for_display=True): |             if user := self.get_pending_user(): | ||||||
|                 challenge.initial_data["pending_user"] = user.username |                 challenge.initial_data["pending_user"] = user.username | ||||||
|             challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR |             challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR | ||||||
|             if not isinstance(user, AnonymousUser): |             if not isinstance(user, AnonymousUser): | ||||||
|  | |||||||
| @ -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)) |  | ||||||
| @ -93,11 +93,7 @@ class TestFlowExecutor(TestCase): | |||||||
|             { |             { | ||||||
|                 "component": "ak-stage-access-denied", |                 "component": "ak-stage-access-denied", | ||||||
|                 "error_message": FlowNonApplicableException.__doc__, |                 "error_message": FlowNonApplicableException.__doc__, | ||||||
|                 "flow_info": { |  | ||||||
|                     "background": flow.background_url, |  | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                 "title": "", |                 "title": "", | ||||||
|                 }, |  | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
| @ -293,11 +289,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             { |             {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, | ||||||
|                 "component": "xak-flow-redirect", |  | ||||||
|                 "to": reverse("authentik_core:root-redirect"), |  | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_reevaluate_keep(self): |     def test_reevaluate_keep(self): | ||||||
| @ -374,11 +366,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             { |             {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, | ||||||
|                 "component": "xak-flow-redirect", |  | ||||||
|                 "to": reverse("authentik_core:root-redirect"), |  | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_reevaluate_remove_consecutive(self): |     def test_reevaluate_remove_consecutive(self): | ||||||
| @ -426,13 +414,10 @@ class TestFlowExecutor(TestCase): | |||||||
|             self.assertJSONEqual( |             self.assertJSONEqual( | ||||||
|                 force_str(response.content), |                 force_str(response.content), | ||||||
|                 { |                 { | ||||||
|  |                     "background": flow.background.url, | ||||||
|                     "type": ChallengeTypes.NATIVE.value, |                     "type": ChallengeTypes.NATIVE.value, | ||||||
|                     "component": "ak-stage-dummy", |                     "component": "ak-stage-dummy", | ||||||
|                     "flow_info": { |                     "title": binding.stage.name, | ||||||
|                         "background": flow.background_url, |  | ||||||
|                         "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                         "title": "", |  | ||||||
|                     }, |  | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
| @ -460,13 +445,10 @@ class TestFlowExecutor(TestCase): | |||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             { |             { | ||||||
|  |                 "background": flow.background.url, | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|                 "component": "ak-stage-dummy", |                 "component": "ak-stage-dummy", | ||||||
|                 "flow_info": { |                 "title": binding4.stage.name, | ||||||
|                     "background": flow.background_url, |  | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                     "title": "", |  | ||||||
|                 }, |  | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -476,11 +458,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             { |             {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, | ||||||
|                 "component": "xak-flow-redirect", |  | ||||||
|                 "to": reverse("authentik_core:root-redirect"), |  | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_stageview_user_identifier(self): |     def test_stageview_user_identifier(self): | ||||||
| @ -511,4 +489,4 @@ class TestFlowExecutor(TestCase): | |||||||
|         executor.flow = flow |         executor.flow = flow | ||||||
|  |  | ||||||
|         stage_view = StageView(executor) |         stage_view = StageView(executor) | ||||||
|         self.assertEqual(ident, stage_view.get_pending_user(for_display=True).username) |         self.assertEqual(ident, stage_view.get_pending_user().username) | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ from django.db.models.query_utils import Q | |||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.serializers import BaseSerializer, Serializer | 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.models import Flow, FlowStageBinding, Stage | ||||||
| from authentik.flows.transfer.common import ( | from authentik.flows.transfer.common import ( | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """flow urls""" | """flow urls""" | ||||||
| from django.urls import path | from django.urls import path | ||||||
|  | from django.views.generic import RedirectView | ||||||
|  |  | ||||||
| from authentik.flows.models import FlowDesignation | from authentik.flows.models import FlowDesignation | ||||||
| from authentik.flows.views import CancelView, ConfigureFlowInitView, ToDefaultFlow | from authentik.flows.views import CancelView, ConfigureFlowInitView, ToDefaultFlow | ||||||
| @ -15,10 +16,30 @@ urlpatterns = [ | |||||||
|         ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION), |         ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION), | ||||||
|         name="default-invalidation", |         name="default-invalidation", | ||||||
|     ), |     ), | ||||||
|  |     path( | ||||||
|  |         "-/default/recovery/", | ||||||
|  |         ToDefaultFlow.as_view(designation=FlowDesignation.RECOVERY), | ||||||
|  |         name="default-recovery", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "-/default/enrollment/", | ||||||
|  |         ToDefaultFlow.as_view(designation=FlowDesignation.ENROLLMENT), | ||||||
|  |         name="default-enrollment", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "-/default/unenrollment/", | ||||||
|  |         ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT), | ||||||
|  |         name="default-unenrollment", | ||||||
|  |     ), | ||||||
|     path("-/cancel/", CancelView.as_view(), name="cancel"), |     path("-/cancel/", CancelView.as_view(), name="cancel"), | ||||||
|     path( |     path( | ||||||
|         "-/configure/<uuid:stage_uuid>/", |         "-/configure/<uuid:stage_uuid>/", | ||||||
|         ConfigureFlowInitView.as_view(), |         ConfigureFlowInitView.as_view(), | ||||||
|         name="configure", |         name="configure", | ||||||
|     ), |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:flow_slug>/", | ||||||
|  |         RedirectView.as_view(pattern_name="authentik_core:if-flow"), | ||||||
|  |         name="flow-executor-shell", | ||||||
|  |     ), | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -2,23 +2,16 @@ | |||||||
| from traceback import format_tb | from traceback import format_tb | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.conf import settings |  | ||||||
| from django.contrib.auth.mixins import LoginRequiredMixin | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
| from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| from django.template.response import TemplateResponse | from django.template.response import TemplateResponse | ||||||
| from django.urls.base import reverse |  | ||||||
| from django.utils.decorators import method_decorator | from django.utils.decorators import method_decorator | ||||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||||
| from django.views.generic import View | from django.views.generic import View | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_yasg import openapi | ||||||
| from drf_spectacular.utils import ( | from drf_yasg.utils import no_body, swagger_auto_schema | ||||||
|     OpenApiParameter, |  | ||||||
|     OpenApiResponse, |  | ||||||
|     PolymorphicProxySerializer, |  | ||||||
|     extend_schema, |  | ||||||
| ) |  | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
| from sentry_sdk import capture_exception | from sentry_sdk import capture_exception | ||||||
| @ -34,7 +27,6 @@ from authentik.flows.challenge import ( | |||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|     RedirectChallenge, |     RedirectChallenge, | ||||||
|     ShellChallenge, |     ShellChallenge, | ||||||
|     WithUserInfoChallenge, |  | ||||||
| ) | ) | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | ||||||
| @ -44,9 +36,8 @@ from authentik.flows.planner import ( | |||||||
|     FlowPlan, |     FlowPlan, | ||||||
|     FlowPlanner, |     FlowPlanner, | ||||||
| ) | ) | ||||||
| from authentik.lib.utils.reflection import all_subclasses, class_to_path | from authentik.lib.utils.reflection import class_to_path | ||||||
| from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||||
| from authentik.tenants.models import Tenant |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| # Argument used to redirect user after login | # Argument used to redirect user after login | ||||||
| @ -56,43 +47,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre" | |||||||
| SESSION_KEY_GET = "authentik_flows_get" | SESSION_KEY_GET = "authentik_flows_get" | ||||||
|  |  | ||||||
|  |  | ||||||
| def challenge_types(): |  | ||||||
|     """This is a workaround for PolymorphicProxySerializer not accepting a callable for |  | ||||||
|     `serializers`. This function returns a class which is an iterator, which returns the |  | ||||||
|     subclasses of Challenge, and Challenge itself.""" |  | ||||||
|  |  | ||||||
|     class Inner(dict): |  | ||||||
|         """dummy class with custom callback on .items()""" |  | ||||||
|  |  | ||||||
|         def items(self): |  | ||||||
|             mapping = {} |  | ||||||
|             classes = all_subclasses(Challenge) |  | ||||||
|             classes.remove(WithUserInfoChallenge) |  | ||||||
|             for cls in classes: |  | ||||||
|                 mapping[cls().fields["component"].default] = cls |  | ||||||
|             return mapping.items() |  | ||||||
|  |  | ||||||
|     return Inner() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def challenge_response_types(): |  | ||||||
|     """This is a workaround for PolymorphicProxySerializer not accepting a callable for |  | ||||||
|     `serializers`. This function returns a class which is an iterator, which returns the |  | ||||||
|     subclasses of Challenge, and Challenge itself.""" |  | ||||||
|  |  | ||||||
|     class Inner(dict): |  | ||||||
|         """dummy class with custom callback on .items()""" |  | ||||||
|  |  | ||||||
|         def items(self): |  | ||||||
|             mapping = {} |  | ||||||
|             classes = all_subclasses(ChallengeResponse) |  | ||||||
|             for cls in classes: |  | ||||||
|                 mapping[cls(stage=None).fields["component"].default] = cls |  | ||||||
|             return mapping.items() |  | ||||||
|  |  | ||||||
|     return Inner() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @method_decorator(xframe_options_sameorigin, name="dispatch") | @method_decorator(xframe_options_sameorigin, name="dispatch") | ||||||
| class FlowExecutorView(APIView): | class FlowExecutorView(APIView): | ||||||
|     """Stage 1 Flow executor, passing requests to Stage Views""" |     """Stage 1 Flow executor, passing requests to Stage Views""" | ||||||
| @ -171,25 +125,19 @@ class FlowExecutorView(APIView): | |||||||
|         self.current_stage_view.request = request |         self.current_stage_view.request = request | ||||||
|         return super().dispatch(request) |         return super().dispatch(request) | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: PolymorphicProxySerializer( |             200: Challenge(), | ||||||
|                 component_name="FlowChallengeRequest", |             404: "No Token found",  # This error can be raised by the email stage | ||||||
|                 serializers=challenge_types(), |  | ||||||
|                 resource_type_field_name="component", |  | ||||||
|             ), |  | ||||||
|             404: OpenApiResponse( |  | ||||||
|                 description="No Token found" |  | ||||||
|             ),  # This error can be raised by the email stage |  | ||||||
|         }, |         }, | ||||||
|         request=OpenApiTypes.NONE, |         request_body=no_body, | ||||||
|         parameters=[ |         manual_parameters=[ | ||||||
|             OpenApiParameter( |             openapi.Parameter( | ||||||
|                 name="query", |                 "query", | ||||||
|                 location=OpenApiParameter.QUERY, |                 openapi.IN_QUERY, | ||||||
|                 required=True, |                 required=True, | ||||||
|                 description="Querystring as received", |                 description="Querystring as received", | ||||||
|                 type=OpenApiTypes.STR, |                 type=openapi.TYPE_STRING, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|         operation_id="flows_executor_get", |         operation_id="flows_executor_get", | ||||||
| @ -205,32 +153,20 @@ class FlowExecutorView(APIView): | |||||||
|             stage_response = self.current_stage_view.get(request, *args, **kwargs) |             stage_response = self.current_stage_view.get(request, *args, **kwargs) | ||||||
|             return to_stage_response(request, stage_response) |             return to_stage_response(request, stage_response) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|             if settings.DEBUG or settings.TEST: |  | ||||||
|                 raise exc |  | ||||||
|             capture_exception(exc) |             capture_exception(exc) | ||||||
|             self._logger.warning(exc) |             self._logger.warning(exc) | ||||||
|             return to_stage_response(request, FlowErrorResponse(request, exc)) |             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||||
|  |  | ||||||
|     @extend_schema( |     @swagger_auto_schema( | ||||||
|         responses={ |         responses={200: Challenge()}, | ||||||
|             200: PolymorphicProxySerializer( |         request_body=ChallengeResponse(), | ||||||
|                 component_name="FlowChallengeRequest", |         manual_parameters=[ | ||||||
|                 serializers=challenge_types(), |             openapi.Parameter( | ||||||
|                 resource_type_field_name="component", |                 "query", | ||||||
|             ), |                 openapi.IN_QUERY, | ||||||
|         }, |  | ||||||
|         request=PolymorphicProxySerializer( |  | ||||||
|             component_name="FlowChallengeResponse", |  | ||||||
|             serializers=challenge_response_types(), |  | ||||||
|             resource_type_field_name="component", |  | ||||||
|         ), |  | ||||||
|         parameters=[ |  | ||||||
|             OpenApiParameter( |  | ||||||
|                 name="query", |  | ||||||
|                 location=OpenApiParameter.QUERY, |  | ||||||
|                 required=True, |                 required=True, | ||||||
|                 description="Querystring as received", |                 description="Querystring as received", | ||||||
|                 type=OpenApiTypes.STR, |                 type=openapi.TYPE_STRING, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|         operation_id="flows_executor_solve", |         operation_id="flows_executor_solve", | ||||||
| @ -246,8 +182,6 @@ class FlowExecutorView(APIView): | |||||||
|             stage_response = self.current_stage_view.post(request, *args, **kwargs) |             stage_response = self.current_stage_view.post(request, *args, **kwargs) | ||||||
|             return to_stage_response(request, stage_response) |             return to_stage_response(request, stage_response) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|             if settings.DEBUG or settings.TEST: |  | ||||||
|                 raise exc |  | ||||||
|             capture_exception(exc) |             capture_exception(exc) | ||||||
|             self._logger.warning(exc) |             self._logger.warning(exc) | ||||||
|             return to_stage_response(request, FlowErrorResponse(request, exc)) |             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||||
| @ -284,7 +218,7 @@ class FlowExecutorView(APIView): | |||||||
|         if self.plan.stages: |         if self.plan.stages: | ||||||
|             self._logger.debug( |             self._logger.debug( | ||||||
|                 "f(exec): Continuing with next stage", |                 "f(exec): Continuing with next stage", | ||||||
|                 remaining=len(self.plan.stages), |                 reamining=len(self.plan.stages), | ||||||
|             ) |             ) | ||||||
|             kwargs = self.kwargs |             kwargs = self.kwargs | ||||||
|             kwargs.update({"flow_slug": self.flow.slug}) |             kwargs.update({"flow_slug": self.flow.slug}) | ||||||
| @ -310,13 +244,9 @@ class FlowExecutorView(APIView): | |||||||
|             AccessDeniedChallenge( |             AccessDeniedChallenge( | ||||||
|                 { |                 { | ||||||
|                     "error_message": error_message, |                     "error_message": error_message, | ||||||
|  |                     "title": self.flow.title, | ||||||
|                     "type": ChallengeTypes.NATIVE.value, |                     "type": ChallengeTypes.NATIVE.value, | ||||||
|                     "component": "ak-stage-access-denied", |                     "component": "ak-stage-access-denied", | ||||||
|                     "flow_info": { |  | ||||||
|                         "title": self.flow.title, |  | ||||||
|                         "background": self.flow.background_url, |  | ||||||
|                         "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                     }, |  | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
| @ -368,7 +298,7 @@ class CancelView(View): | |||||||
|         if SESSION_KEY_PLAN in request.session: |         if SESSION_KEY_PLAN in request.session: | ||||||
|             del request.session[SESSION_KEY_PLAN] |             del request.session[SESSION_KEY_PLAN] | ||||||
|             LOGGER.debug("Canceled current plan") |             LOGGER.debug("Canceled current plan") | ||||||
|         return redirect("authentik_flows:default-invalidation") |         return redirect("authentik_core:root-redirect") | ||||||
|  |  | ||||||
|  |  | ||||||
| class ToDefaultFlow(View): | class ToDefaultFlow(View): | ||||||
| @ -377,17 +307,7 @@ class ToDefaultFlow(View): | |||||||
|     designation: Optional[FlowDesignation] = None |     designation: Optional[FlowDesignation] = None | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: |     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||||
|         tenant: Tenant = request.tenant |  | ||||||
|         flow = None |  | ||||||
|         # First, attempt to get default flow from tenant |  | ||||||
|         if self.designation == FlowDesignation.AUTHENTICATION: |  | ||||||
|             flow = tenant.flow_authentication |  | ||||||
|         if self.designation == FlowDesignation.INVALIDATION: |  | ||||||
|             flow = tenant.flow_invalidation |  | ||||||
|         # If no flow was set, get the first based on slug and policy |  | ||||||
|         if not flow: |  | ||||||
|         flow = Flow.with_policy(request, designation=self.designation) |         flow = Flow.with_policy(request, designation=self.designation) | ||||||
|         # If we still don't have a flow, 404 |  | ||||||
|         if not flow: |         if not flow: | ||||||
|             raise Http404 |             raise Http404 | ||||||
|         # If user already has a pending plan, clear it so we don't have to later. |         # If user already has a pending plan, clear it so we don't have to later. | ||||||
| @ -418,10 +338,7 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons | |||||||
|         ) |         ) | ||||||
|         return HttpChallengeResponse( |         return HttpChallengeResponse( | ||||||
|             RedirectChallenge( |             RedirectChallenge( | ||||||
|                 { |                 {"type": ChallengeTypes.REDIRECT, "to": str(redirect_url)} | ||||||
|                     "type": ChallengeTypes.REDIRECT, |  | ||||||
|                     "to": str(redirect_url), |  | ||||||
|                 } |  | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     if isinstance(source, TemplateResponse): |     if isinstance(source, TemplateResponse): | ||||||
| @ -433,7 +350,7 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons | |||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     # Check for actual HttpResponse (without isinstance as we don't want to check inheritance) |     # Check for actual HttpResponse (without isinstance as we dont want to check inheritance) | ||||||
|     if source.__class__ == HttpResponse: |     if source.__class__ == HttpResponse: | ||||||
|         return HttpChallengeResponse( |         return HttpChallengeResponse( | ||||||
|             ShellChallenge( |             ShellChallenge( | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	