Compare commits
	
		
			366 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d78fda990a | |||
| 10d949f7a9 | |||
| 6661af032d | |||
| fb5e4a3af8 | |||
| 1dfad83a34 | |||
| 70025c648c | |||
| 676b77aa7c | |||
| e35e096266 | |||
| 7af12d4fec | |||
| 8d6db0fabf | |||
| 8ddcf99bf7 | |||
| e25f6aea8c | |||
| b1a9eda1d3 | |||
| 2c15ab9995 | |||
| b3c51e426d | |||
| 71578af47f | |||
| 6c985acb36 | |||
| d878d2140e | |||
| 4766d6ff3d | |||
| 3a64d97040 | |||
| 2275ba3add | |||
| 9f7c941426 | |||
| 34ae9e6dab | |||
| bf683514ee | |||
| 9b58bdb447 | |||
| 4237f20ccd | |||
| 2408719a47 | |||
| b33fef7929 | |||
| 73b9847e7d | |||
| a7e4eb021d | |||
| 11306770ad | |||
| 5235e00d3c | |||
| 7834146efc | |||
| d4379ecd31 | |||
| 7492608ace | |||
| 7eef501446 | |||
| b73de96aa6 | |||
| a7adeb917e | |||
| 4ee2f951da | |||
| 01c5235e82 | |||
| 0ce4f9fe12 | |||
| 2f4f951818 | |||
| a6c214e8fa | |||
| 57f8b108c4 | |||
| 7c1fe1243f | |||
| 3f69dd34ba | |||
| c81431895a | |||
| 560c979d26 | |||
| c5cc8842ec | |||
| 2a881d241d | |||
| 6291834573 | |||
| eeea36acea | |||
| e95b9da586 | |||
| f4a53c89ef | |||
| 20493252e2 | |||
| 2210497569 | |||
| 2addf71f37 | |||
| de11181890 | |||
| 66e3bc6b58 | |||
| 612679e8df | |||
| c9072f7403 | |||
| cacacb06af | |||
| 7da87a53b7 | |||
| 9f894881ca | |||
| dad24c03ff | |||
| fb8d67a9d9 | |||
| 029d58191e | |||
| 75404f1345 | |||
| ba1b23c879 | |||
| ae8cf00a21 | |||
| d9ffb23a80 | |||
| dab5f4c768 | |||
| cd6632fca6 | |||
| ea1741838c | |||
| 8256fa8c0b | |||
| 486a930163 | |||
| 8a58a31bd6 | |||
| deb0d3f7bc | |||
| 10208b45b6 | |||
| 25f987ba2b | |||
| f23111beff | |||
| 0f693158b6 | |||
| e51226432f | |||
| b1fbcef98a | |||
| ce56192412 | |||
| 70d72f340f | |||
| 7524e114d9 | |||
| 4d7dab92bc | |||
| a36e3aa3a4 | |||
| fceab788d2 | |||
| d55d44d664 | |||
| 88cc38394e | |||
| ea1696a275 | |||
| 552d26eb98 | |||
| 90a5c84ac8 | |||
| b55c3a687d | |||
| e786244988 | |||
| 68f1fbebf4 | |||
| 9180d448df | |||
| 67470590c2 | |||
| fe2e850303 | |||
| a7a3c158ea | |||
| 98d0986ac8 | |||
| bedf7fbcaa | |||
| 1f35f73c66 | |||
| 8ea02e4cc9 | |||
| f399b32135 | |||
| 0032f535da | |||
| 3c349b1f22 | |||
| 17326615b7 | |||
| f5dbdbd48b | |||
| 277c2f4aad | |||
| d38f944435 | |||
| ba3e0a0586 | |||
| 7581c84a37 | |||
| 86b450c6d1 | |||
| e43e42139a | |||
| 0b90cfcec4 | |||
| cefe3fa6dd | |||
| 24da24b5d5 | |||
| f996f9d4e3 | |||
| 5411412626 | |||
| f9050f9192 | |||
| bc75c07e65 | |||
| c02b943612 | |||
| 7b39718bd1 | |||
| e9621bae06 | |||
| 0eaabbc0f3 | |||
| 5e3628bea6 | |||
| 290ebef8e3 | |||
| 46ab1d20df | |||
| 48e68d6852 | |||
| cde056825e | |||
| de25b64f2b | |||
| 32f0c6abe1 | |||
| 960210f351 | |||
| 7c300f0858 | |||
| ed3859800c | |||
| 06b7f62a40 | |||
| 45b7c349f1 | |||
| 7bef6f7153 | |||
| d32e40b1f8 | |||
| cec47c3cfc | |||
| 4d773274d4 | |||
| 3ea2b16a12 | |||
| 974ddc07f7 | |||
| 2f64b76eba | |||
| a113778ca7 | |||
| 06caaa7c80 | |||
| b50ac96605 | |||
| 166b98fa34 | |||
| 6d0e0cbe5a | |||
| b339452843 | |||
| 4f04ab7a5f | |||
| 35bcd5d174 | |||
| 644ff4a90c | |||
| 05d45383be | |||
| 702fdfedb7 | |||
| 2a0af8750d | |||
| 770316a49f | |||
| 85d349e776 | |||
| f29344e91f | |||
| 9900cc5c81 | |||
| 3af48a81e2 | |||
| 5bebf26908 | |||
| eea831fb5a | |||
| 2e4a9219a2 | |||
| 7f1098ce9b | |||
| 6cd6224d2b | |||
| 43d85f8696 | |||
| ef8b26db13 | |||
| ebfa7c8dce | |||
| e295f18e78 | |||
| cef5c2b084 | |||
| e24a9e3119 | |||
| 264a170a7e | |||
| 8e1c2d7fc0 | |||
| 6c7f4197a1 | |||
| 1cd3866855 | |||
| 6a9c95c593 | |||
| 80adafdb48 | |||
| 72f5a4c460 | |||
| fb6242d2d3 | |||
| b9773d39c0 | |||
| 0e8d9aa45d | |||
| fc45d35699 | |||
| 7e8044619c | |||
| cf57660772 | |||
| 66a04aeec5 | |||
| 73338bdf32 | |||
| 059da74d1c | |||
| 45b8b1e198 | |||
| 5e43eb9838 | |||
| 11607622a3 | |||
| 133fc38c05 | |||
| f51ab7a878 | |||
| c89b8a5f7c | |||
| 31ad09c391 | |||
| 05b3c4ddb3 | |||
| d52cc30341 | |||
| d2e9683411 | |||
| a4c28a28b4 | |||
| 6232333a52 | |||
| a1203cf4b2 | |||
| 8427fb87f6 | |||
| e3578eb7ae | |||
| 5990b8d4de | |||
| 3b31b7ce83 | |||
| 4d9b362dbf | |||
| 7bd93ed18e | |||
| 477ff85109 | |||
| fae8b80ceb | |||
| df92f01719 | |||
| 9dd6b7d436 | |||
| 14f85ec980 | |||
| ff611f21cd | |||
| a1b6e09e8a | |||
| 02b5742228 | |||
| c5cc84c8b6 | |||
| 109ada570f | |||
| b9436c281a | |||
| 89f2f920cf | |||
| abd0d585a6 | |||
| ee74281537 | |||
| 5488db3574 | |||
| 61f92095a5 | |||
| 3a9f081e1b | |||
| a237ae3363 | |||
| 523621daa2 | |||
| 309d80a921 | |||
| 1bd41116a4 | |||
| a7b85aeda2 | |||
| 142861e3ee | |||
| 02411bb543 | |||
| c4453f38a2 | |||
| 250e23408e | |||
| 6f3eb4c068 | |||
| 58a4b20297 | |||
| 6d3e067a2b | |||
| 6db2bf2a21 | |||
| 6893948fa0 | |||
| 6317a8c5d0 | |||
| bc39320f86 | |||
| 2001cf0e04 | |||
| 712c5df5b1 | |||
| 8057c63cb4 | |||
| 7816a3075a | |||
| 1679e94956 | |||
| 8ecac59eca | |||
| af504e13a2 | |||
| 8183a51b72 | |||
| ab25610643 | |||
| 127ebed5c6 | |||
| 716923e17a | |||
| c6bb6709fd | |||
| fb4e0723ee | |||
| 8ecacb319c | |||
| 2a5926608f | |||
| 763c3fcfe0 | |||
| 1b346866da | |||
| 25a88c17d1 | |||
| 6f6ae7831e | |||
| 0062872e18 | |||
| e49fb3295f | |||
| 0e89353ac9 | |||
| b8f98881fa | |||
| f887850b95 | |||
| 2ec4b4ec98 | |||
| c98e4196bd | |||
| 3b41c662ed | |||
| 65522186f1 | |||
| 9f5a3c396d | |||
| 53e2b2c784 | |||
| a5cd9fa141 | |||
| 039a1e544e | |||
| 0768b201a7 | |||
| c1c55a6005 | |||
| 0144e1ad72 | |||
| 9b57f0b81d | |||
| 934cfa483c | |||
| 50308510b4 | |||
| bb89b9b572 | |||
| 1a0f72d0a8 | |||
| c526e5fb9a | |||
| 7aa903d715 | |||
| b826eb264e | |||
| a4960064c9 | |||
| 94bddb9886 | |||
| f38702f361 | |||
| c49fac39b1 | |||
| b3390f0ab4 | |||
| 7666c246c3 | |||
| 13cc33c39c | |||
| d2c06c40ea | |||
| 9a48c2fd9a | |||
| be5a6c0310 | |||
| 92106ca4bf | |||
| f6f93640c5 | |||
| b8c76eaf1c | |||
| 9dbbd4eff6 | |||
| 2908be5272 | |||
| 1324ec5146 | |||
| 0f556fe8a3 | |||
| 19371dad65 | |||
| acf1ad91d9 | |||
| a74419214c | |||
| 7bd8110984 | |||
| aa5623772c | |||
| 50ede4cc2c | |||
| 879ad27602 | |||
| 37a63d104f | |||
| bc6aef7af2 | |||
| 2498e72f5d | |||
| c61442c121 | |||
| 2d66837742 | |||
| 90e7fbe238 | |||
| 4447f737e8 | |||
| c13c747263 | |||
| cac23f2fa4 | |||
| 788ea46d8c | |||
| c285c6b476 | |||
| a7cf364e43 | |||
| 06dee5d5d8 | |||
| 3cf0f07baf | |||
| e177ab33e0 | |||
| 9e7c9ae649 | |||
| f016095891 | |||
| c4751e4b59 | |||
| 7f4bd27b85 | |||
| a51a18f3a3 | |||
| b13d6deda8 | |||
| 626006725e | |||
| f9ce41229d | |||
| ae6a406b1d | |||
| 330219e76f | |||
| 0db17b9729 | |||
| 9f9ee66cc4 | |||
| ab2bd622a8 | |||
| 6bd27d27ec | |||
| a5233f89b2 | |||
| 8b6292b3de | |||
| cbed5a6522 | |||
| 589f806b7c | |||
| 07dc648470 | |||
| 41f6d3b6e7 | |||
| ec8490e105 | |||
| 69668a2a05 | |||
| d0f1daf025 | |||
| d38fd603dd | |||
| ba5374f6e1 | |||
| 7152d7ee01 | |||
| ab07113530 | |||
| a7d7b46747 | |||
| dde1dabf97 | |||
| 1f05484e3c | |||
| 9a44088d2b | |||
| b351ae12c5 | |||
| 759bf59780 | |||
| 10cb60f48e | |||
| 99be97206b | |||
| ef9f08553c | |||
| 4fb71a6bdd | |||
| 3ab7588b73 | |||
| cac1f242dc | |||
| 0bac738090 | |||
| 1324d03815 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2021.5.4 | ||||
| current_version = 2021.6.1-rc5 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||
|  | ||||
							
								
								
									
										27
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| --- | ||||
| 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. | ||||
							
								
								
									
										13
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| # 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. | ||||
							
								
								
									
										39
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										39
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -14,7 +14,7 @@ jobs: | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.1.0 | ||||
|         uses: docker/setup-qemu-action@v1.2.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|       - name: Docker Login Registry | ||||
| @ -28,17 +28,14 @@ jobs: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.repository_owner }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: prepare ts api client | ||||
|         run: | | ||||
|           docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | ||||
|       - name: Building Docker Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           push: ${{ github.event_name == 'release' }} | ||||
|           tags: | | ||||
|             beryju/authentik:2021.5.4, | ||||
|             beryju/authentik:2021.6.1-rc5, | ||||
|             beryju/authentik:latest, | ||||
|             ghcr.io/goauthentik/server:2021.5.4, | ||||
|             ghcr.io/goauthentik/server:2021.6.1-rc5, | ||||
|             ghcr.io/goauthentik/server:latest | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           context: . | ||||
| @ -49,14 +46,8 @@ jobs: | ||||
|       - uses: actions/setup-go@v2 | ||||
|         with: | ||||
|           go-version: "^1.15" | ||||
|       - name: prepare go api client | ||||
|         run: | | ||||
|           cd outpost | ||||
|           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||
|           swagger generate client -f ../swagger.yaml -A authentik -t pkg/ | ||||
|           go build -v ./cmd/proxy/server.go | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.1.0 | ||||
|         uses: docker/setup-qemu-action@v1.2.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|       - name: Docker Login Registry | ||||
| @ -75,11 +66,10 @@ jobs: | ||||
|         with: | ||||
|           push: ${{ github.event_name == 'release' }} | ||||
|           tags: | | ||||
|             beryju/authentik-proxy:2021.5.4, | ||||
|             beryju/authentik-proxy:2021.6.1-rc5, | ||||
|             beryju/authentik-proxy:latest, | ||||
|             ghcr.io/goauthentik/proxy:2021.5.4, | ||||
|             ghcr.io/goauthentik/proxy:2021.6.1-rc5, | ||||
|             ghcr.io/goauthentik/proxy:latest | ||||
|           context: outpost/ | ||||
|           file: outpost/proxy.Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|   build-ldap: | ||||
| @ -89,14 +79,8 @@ jobs: | ||||
|       - uses: actions/setup-go@v2 | ||||
|         with: | ||||
|           go-version: "^1.15" | ||||
|       - name: prepare go api client | ||||
|         run: | | ||||
|           cd outpost | ||||
|           go get -u github.com/go-swagger/go-swagger/cmd/swagger | ||||
|           swagger generate client -f ../swagger.yaml -A authentik -t pkg/ | ||||
|           go build -v ./cmd/ldap/server.go | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1.1.0 | ||||
|         uses: docker/setup-qemu-action@v1.2.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|       - name: Docker Login Registry | ||||
| @ -115,11 +99,10 @@ jobs: | ||||
|         with: | ||||
|           push: ${{ github.event_name == 'release' }} | ||||
|           tags: | | ||||
|             beryju/authentik-ldap:2021.5.4, | ||||
|             beryju/authentik-ldap:2021.6.1-rc5, | ||||
|             beryju/authentik-ldap:latest, | ||||
|             ghcr.io/goauthentik/ldap:2021.5.4, | ||||
|             ghcr.io/goauthentik/ldap:2021.6.1-rc5, | ||||
|             ghcr.io/goauthentik/ldap:latest | ||||
|           context: outpost/ | ||||
|           file: outpost/ldap.Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|   test-release: | ||||
| @ -139,7 +122,7 @@ jobs: | ||||
|           docker-compose pull -q | ||||
|           docker-compose up --no-start | ||||
|           docker-compose start postgresql redis | ||||
|           docker-compose run -u root --entrypoint /bin/bash server -c "apt-get update && apt-get install -y --no-install-recommends git && pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik" | ||||
|           docker-compose run -u root server test | ||||
|   sentry-release: | ||||
|     if: ${{ github.event_name == 'release' }} | ||||
|     needs: | ||||
| @ -155,5 +138,5 @@ jobs: | ||||
|           SENTRY_PROJECT: authentik | ||||
|           SENTRY_URL: https://sentry.beryju.org | ||||
|         with: | ||||
|           version: authentik@2021.5.4 | ||||
|           version: authentik@2021.6.1-rc5 | ||||
|           environment: beryjuorg-prod | ||||
|  | ||||
							
								
								
									
										7
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,9 +11,6 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - name: prepare ts api client | ||||
|         run: | | ||||
|           docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | ||||
|       - name: Pre-release test | ||||
|         run: | | ||||
|           sudo apt-get install -y pwgen | ||||
| @ -23,11 +20,11 @@ jobs: | ||||
|           docker-compose pull -q | ||||
|           docker build \ | ||||
|             --no-cache \ | ||||
|             -t beryju/authentik:latest \ | ||||
|             -t ghcr.io/goauthentik/server:latest \ | ||||
|             -f Dockerfile . | ||||
|           docker-compose up --no-start | ||||
|           docker-compose start postgresql redis | ||||
|           docker-compose run -u root --entrypoint /bin/bash server -c "apt-get update && apt-get install -y --no-install-recommends git && pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik" | ||||
|           docker-compose run -u root server test | ||||
|       - name: Extract version number | ||||
|         id: get_version | ||||
|         uses: actions/github-script@v4.0.2 | ||||
|  | ||||
							
								
								
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -8,18 +8,30 @@ WORKDIR /app/ | ||||
|  | ||||
| RUN pip install pipenv && \ | ||||
|     pipenv lock -r > requirements.txt && \ | ||||
|     pipenv lock -rd > requirements-dev.txt | ||||
|     pipenv lock -r --dev-only > requirements-dev.txt | ||||
|  | ||||
| # Stage 2: Build webui | ||||
| # 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 --production=false && npm run build | ||||
| RUN cd /static && npm i && npm run build | ||||
|  | ||||
| # Stage 3: Build go proxy | ||||
| FROM golang:1.16.4 AS builder | ||||
| # Stage 4: Build go proxy | ||||
| FROM golang:1.16.5 AS builder | ||||
|  | ||||
| WORKDIR /work | ||||
|  | ||||
| @ -28,7 +40,6 @@ COPY --from=npm-builder /static/security.txt /work/web/security.txt | ||||
| COPY --from=npm-builder /static/dist/ /work/web/dist/ | ||||
| COPY --from=npm-builder /static/authentik/ /work/web/authentik/ | ||||
|  | ||||
| # RUN ls /work/web/static/authentik/ && exit 1 | ||||
| COPY ./cmd /work/cmd | ||||
| COPY ./web/static.go /work/web/static.go | ||||
| COPY ./internal /work/internal | ||||
| @ -37,7 +48,7 @@ COPY ./go.sum /work/go.sum | ||||
|  | ||||
| RUN go build -o /work/authentik ./cmd/server/main.go | ||||
|  | ||||
| # Stage 4: Run | ||||
| # Stage 5: Run | ||||
| FROM python:3.9-slim-buster | ||||
|  | ||||
| WORKDIR / | ||||
| @ -65,6 +76,7 @@ RUN apt-get update && \ | ||||
| COPY ./authentik/ /authentik | ||||
| COPY ./pyproject.toml / | ||||
| COPY ./xml /xml | ||||
| COPY ./tests /tests | ||||
| COPY ./manage.py / | ||||
| COPY ./lifecycle/ /lifecycle | ||||
| COPY --from=builder /work/authentik /authentik-proxy | ||||
|  | ||||
							
								
								
									
										31
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,5 +1,7 @@ | ||||
| .SHELLFLAGS += -x -e | ||||
| PWD = $(shell pwd) | ||||
| UID = $(shell id -u) | ||||
| GID = $(shell id -g) | ||||
|  | ||||
| all: lint-fix lint test gen | ||||
|  | ||||
| @ -25,16 +27,39 @@ lint: | ||||
| 	bandit -r authentik tests lifecycle -x node_modules | ||||
| 	pylint authentik tests lifecycle | ||||
|  | ||||
| gen: | ||||
| 	./manage.py generate_swagger -o swagger.yaml -f yaml | ||||
| gen-build: | ||||
| 	./manage.py spectacular --file schema.yml | ||||
|  | ||||
| gen-clean: | ||||
| 	rm -rf web/api/src/ | ||||
| 	rm -rf outpost/api/ | ||||
|  | ||||
| gen-web: | ||||
| 	docker run \ | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		openapitools/openapi-generator-cli generate \ | ||||
| 		-i /local/swagger.yaml \ | ||||
| 		-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 | ||||
|  | ||||
							
								
								
									
										4
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								Pipfile
									
									
									
									
									
								
							| @ -22,7 +22,7 @@ django-storages = "*" | ||||
| djangorestframework = "*" | ||||
| djangorestframework-guardian = "*" | ||||
| docker = "*" | ||||
| drf_yasg = "*" | ||||
| drf-spectacular = "*" | ||||
| facebook-sdk = "*" | ||||
| geoip2 = "*" | ||||
| gunicorn = "*" | ||||
| @ -44,6 +44,8 @@ urllib3 = {extras = ["secure"],version = "*"} | ||||
| uvicorn = {extras = ["standard"],version = "*"} | ||||
| webauthn = "*" | ||||
| xmlsec = "*" | ||||
| duo-client = "*" | ||||
| ua-parser = "*" | ||||
|  | ||||
| [requires] | ||||
| python_version = "3.9" | ||||
|  | ||||
							
								
								
									
										492
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										492
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -1,3 +1,3 @@ | ||||
| """authentik""" | ||||
| __version__ = "2021.5.4" | ||||
| __version__ = "2021.6.1-rc5" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| """Meta API""" | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from rest_framework.fields import CharField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| @ -22,7 +22,7 @@ class AppsViewSet(ViewSet): | ||||
|  | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: AppSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: AppSerializer(many=True)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
|         """List current messages and pass into Serializer""" | ||||
|         data = [] | ||||
|  | ||||
| @ -7,12 +7,12 @@ from django.db.models import Count, ExpressionWrapper, F | ||||
| from django.db.models.fields import DurationField | ||||
| from django.db.models.functions import ExtractHour | ||||
| from django.utils.timezone import now | ||||
| from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method | ||||
| from drf_spectacular.utils import extend_schema, extend_schema_field | ||||
| from rest_framework.fields import IntegerField, SerializerMethodField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import ViewSet | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.events.models import Event, EventAction | ||||
| @ -58,24 +58,24 @@ class LoginMetricsSerializer(PassiveSerializer): | ||||
|     logins_per_1h = SerializerMethodField() | ||||
|     logins_failed_per_1h = SerializerMethodField() | ||||
|  | ||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||
|     @extend_schema_field(CoordinateSerializer(many=True)) | ||||
|     def get_logins_per_1h(self, _): | ||||
|         """Get successful logins per hour for the last 24 hours""" | ||||
|         return get_events_per_1h(action=EventAction.LOGIN) | ||||
|  | ||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||
|     @extend_schema_field(CoordinateSerializer(many=True)) | ||||
|     def get_logins_failed_per_1h(self, _): | ||||
|         """Get failed logins per hour for the last 24 hours""" | ||||
|         return get_events_per_1h(action=EventAction.LOGIN_FAILED) | ||||
|  | ||||
|  | ||||
| class AdministrationMetricsViewSet(ViewSet): | ||||
| class AdministrationMetricsViewSet(APIView): | ||||
|     """Login Metrics per 1h""" | ||||
|  | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: LoginMetricsSerializer(many=False)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
|     @extend_schema(responses={200: LoginMetricsSerializer(many=False)}) | ||||
|     def get(self, request: Request) -> Response: | ||||
|         """Login Metrics per 1h""" | ||||
|         serializer = LoginMetricsSerializer(True) | ||||
|         return Response(serializer.data) | ||||
|  | ||||
							
								
								
									
										91
									
								
								authentik/admin/api/system.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								authentik/admin/api/system.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,91 @@ | ||||
| """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,7 +4,8 @@ from importlib import import_module | ||||
| from django.contrib import messages | ||||
| from django.http.response import Http404 | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| @ -21,7 +22,7 @@ class TaskSerializer(PassiveSerializer): | ||||
|  | ||||
|     task_name = CharField() | ||||
|     task_description = CharField() | ||||
|     task_finish_timestamp = DateTimeField(source="finish_timestamp") | ||||
|     task_finish_timestamp = DateTimeField(source="finish_time") | ||||
|  | ||||
|     status = ChoiceField( | ||||
|         source="result.status.name", | ||||
| @ -29,14 +30,32 @@ class TaskSerializer(PassiveSerializer): | ||||
|     ) | ||||
|     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): | ||||
|     """Read-only view set that returns all background tasks""" | ||||
|  | ||||
|     permission_classes = [IsAdminUser] | ||||
|     serializer_class = TaskSerializer | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         responses={200: TaskSerializer(many=False), 404: "Task not found"} | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: TaskSerializer(many=False), | ||||
|             404: OpenApiResponse(description="Task not found"), | ||||
|         } | ||||
|     ) | ||||
|     # pylint: disable=invalid-name | ||||
|     def retrieve(self, request: Request, pk=None) -> Response: | ||||
| @ -46,18 +65,19 @@ class TaskViewSet(ViewSet): | ||||
|             raise Http404 | ||||
|         return Response(TaskSerializer(task, many=False).data) | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: TaskSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: TaskSerializer(many=True)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
|         """List system tasks""" | ||||
|         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) | ||||
|         return Response(TaskSerializer(tasks, many=True).data) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|     @extend_schema( | ||||
|         request=OpenApiTypes.NONE, | ||||
|         responses={ | ||||
|             204: "Task retried successfully", | ||||
|             404: "Task not found", | ||||
|             500: "Failed to retry task", | ||||
|         } | ||||
|             204: OpenApiResponse(description="Task retried successfully"), | ||||
|             404: OpenApiResponse(description="Task not found"), | ||||
|             500: OpenApiResponse(description="Failed to retry task"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=True, methods=["post"]) | ||||
|     # pylint: disable=invalid-name | ||||
|  | ||||
| @ -2,14 +2,13 @@ | ||||
| from os import environ | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from packaging.version import parse | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.mixins import ListModelMixin | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | ||||
| from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | ||||
| @ -47,17 +46,14 @@ class VersionSerializer(PassiveSerializer): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class VersionViewSet(ListModelMixin, GenericViewSet): | ||||
| class VersionView(APIView): | ||||
|     """Get running and latest version.""" | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     pagination_class = None | ||||
|     filter_backends = [] | ||||
|  | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         return None | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: VersionSerializer(many=False)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
|     @extend_schema(responses={200: VersionSerializer(many=False)}) | ||||
|     def get(self, request: Request) -> Response: | ||||
|         """Get running and latest version.""" | ||||
|         return Response(VersionSerializer(True).data) | ||||
|  | ||||
| @ -1,25 +1,26 @@ | ||||
| """authentik administration overview""" | ||||
| from rest_framework.mixins import ListModelMixin | ||||
| from drf_spectacular.utils import extend_schema, inline_serializer | ||||
| from prometheus_client import Gauge | ||||
| from rest_framework.fields import IntegerField | ||||
| from rest_framework.permissions import IsAdminUser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import Serializer | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| 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.""" | ||||
|  | ||||
|     serializer_class = Serializer | ||||
|     permission_classes = [IsAdminUser] | ||||
|  | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         return None | ||||
|  | ||||
|     def list(self, request: Request) -> Response: | ||||
|         """Get currently connected worker count.""" | ||||
|         return Response( | ||||
|             {"pagination": {"count": len(CELERY_APP.control.ping(timeout=0.5))}} | ||||
|     @extend_schema( | ||||
|         responses=inline_serializer("Workers", fields={"count": IntegerField()}) | ||||
|     ) | ||||
|     def get(self, request: Request) -> Response: | ||||
|         """Get currently connected worker count.""" | ||||
|         count = len(CELERY_APP.control.ping(timeout=0.5)) | ||||
|         return Response({"count": count}) | ||||
|  | ||||
| @ -1,13 +1,15 @@ | ||||
| """authentik admin tasks""" | ||||
| import re | ||||
| from os import environ | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from django.core.validators import URLValidator | ||||
| from packaging.version import parse | ||||
| from prometheus_client import Info | ||||
| from requests import RequestException, get | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||
| from authentik.root.celery import CELERY_APP | ||||
| @ -17,6 +19,18 @@ VERSION_CACHE_KEY = "authentik_latest_version" | ||||
| VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | ||||
| # Chop of the first ^ because we want to search the entire string | ||||
| 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) | ||||
| @ -36,6 +50,7 @@ def update_latest_version(self: MonitoredTask): | ||||
|                 TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] | ||||
|             ) | ||||
|         ) | ||||
|         _set_prom_info() | ||||
|         # Check if upstream version is newer than what we're running, | ||||
|         # and if no event exists yet, create one. | ||||
|         local_version = parse(__version__) | ||||
| @ -53,3 +68,6 @@ def update_latest_version(self: MonitoredTask): | ||||
|     except (RequestException, IndexError) as exc: | ||||
|         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) | ||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||
|  | ||||
|  | ||||
| _set_prom_info() | ||||
|  | ||||
| @ -74,24 +74,29 @@ class TestAdminAPI(TestCase): | ||||
|  | ||||
|     def test_version(self): | ||||
|         """Test Version API""" | ||||
|         response = self.client.get(reverse("authentik_api:admin_version-list")) | ||||
|         response = self.client.get(reverse("authentik_api:admin_version")) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         body = loads(response.content) | ||||
|         self.assertEqual(body["version_current"], __version__) | ||||
|  | ||||
|     def test_workers(self): | ||||
|         """Test Workers API""" | ||||
|         response = self.client.get(reverse("authentik_api:admin_workers-list")) | ||||
|         response = self.client.get(reverse("authentik_api:admin_workers")) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         body = loads(response.content) | ||||
|         self.assertEqual(body["pagination"]["count"], 0) | ||||
|         self.assertEqual(body["count"], 0) | ||||
|  | ||||
|     def test_metrics(self): | ||||
|         """Test metrics API""" | ||||
|         response = self.client.get(reverse("authentik_api:admin_metrics-list")) | ||||
|         response = self.client.get(reverse("authentik_api:admin_metrics")) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_apps(self): | ||||
|         """Test apps API""" | ||||
|         response = self.client.get(reverse("authentik_api:apps-list")) | ||||
|         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) | ||||
|  | ||||
| @ -3,6 +3,7 @@ from base64 import b64decode | ||||
| from binascii import Error | ||||
| from typing import Any, Optional, Union | ||||
|  | ||||
| from drf_spectacular.authentication import OpenApiAuthenticationExtension | ||||
| from rest_framework.authentication import BaseAuthentication, get_authorization_header | ||||
| from rest_framework.exceptions import AuthenticationFailed | ||||
| from rest_framework.request import Request | ||||
| @ -17,7 +18,7 @@ LOGGER = get_logger() | ||||
| def token_from_header(raw_header: bytes) -> Optional[Token]: | ||||
|     """raw_header in the Format of `Bearer dGVzdDp0ZXN0`""" | ||||
|     auth_credentials = raw_header.decode() | ||||
|     if auth_credentials == "": | ||||
|     if auth_credentials == "" or " " not in auth_credentials: | ||||
|         return None | ||||
|     auth_type, auth_credentials = auth_credentials.split() | ||||
|     if auth_type.lower() not in ["basic", "bearer"]: | ||||
| @ -55,3 +56,18 @@ class TokenAuthentication(BaseAuthentication): | ||||
|             return None | ||||
|  | ||||
|         return (token.user, None)  # pragma: no cover | ||||
|  | ||||
|  | ||||
| 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", | ||||
|         } | ||||
|  | ||||
| @ -30,3 +30,47 @@ class Pagination(pagination.PageNumberPagination): | ||||
|                 "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"], | ||||
|         } | ||||
|  | ||||
| @ -1,97 +0,0 @@ | ||||
| """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,102 +1,77 @@ | ||||
| """Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224""" | ||||
| from drf_yasg import openapi | ||||
| from drf_yasg.inspectors.view import SwaggerAutoSchema | ||||
| from drf_yasg.utils import force_real_str, is_list_view | ||||
| from rest_framework import exceptions, status | ||||
| from rest_framework.settings import api_settings | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from drf_spectacular.plumbing import ( | ||||
|     ResolvedComponent, | ||||
|     build_array_type, | ||||
|     build_basic_type, | ||||
|     build_object_type, | ||||
| ) | ||||
| from drf_spectacular.settings import spectacular_settings | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
|  | ||||
|  | ||||
| class ErrorResponseAutoSchema(SwaggerAutoSchema): | ||||
|     """Inspector which includes an error schema""" | ||||
| def build_standard_type(obj, **kwargs): | ||||
|     """Build a basic type with optional add ons.""" | ||||
|     schema = build_basic_type(obj) | ||||
|     schema.update(kwargs) | ||||
|     return schema | ||||
|  | ||||
|     def get_generic_error_schema(self): | ||||
|         """Get a generic error schema""" | ||||
|         return openapi.Schema( | ||||
|             "Generic API Error", | ||||
|             type=openapi.TYPE_OBJECT, | ||||
|  | ||||
| GENERIC_ERROR = build_object_type( | ||||
|     description=_("Generic API Error"), | ||||
|     properties={ | ||||
|                 "detail": openapi.Schema( | ||||
|                     type=openapi.TYPE_STRING, description="Error details" | ||||
|                 ), | ||||
|                 "code": openapi.Schema( | ||||
|                     type=openapi.TYPE_STRING, description="Error code" | ||||
|                 ), | ||||
|         "detail": build_standard_type(OpenApiTypes.STR), | ||||
|         "code": build_standard_type(OpenApiTypes.STR), | ||||
|     }, | ||||
|     required=["detail"], | ||||
|         ) | ||||
|  | ||||
|     def get_validation_error_schema(self): | ||||
|         """Get a generic validation error schema""" | ||||
|         return openapi.Schema( | ||||
|             "Validation Error", | ||||
|             type=openapi.TYPE_OBJECT, | ||||
| ) | ||||
| VALIDATION_ERROR = build_object_type( | ||||
|     description=_("Validation Error"), | ||||
|     properties={ | ||||
|                 api_settings.NON_FIELD_ERRORS_KEY: openapi.Schema( | ||||
|                     description="List of validation errors not related to any field", | ||||
|                     type=openapi.TYPE_ARRAY, | ||||
|                     items=openapi.Schema(type=openapi.TYPE_STRING), | ||||
|                 ), | ||||
|         "non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)), | ||||
|         "code": build_standard_type(OpenApiTypes.STR), | ||||
|     }, | ||||
|             additional_properties=openapi.Schema( | ||||
|                 description=( | ||||
|                     "A list of error messages for each " | ||||
|                     "field that triggered a validation error" | ||||
|                 ), | ||||
|                 type=openapi.TYPE_ARRAY, | ||||
|                 items=openapi.Schema(type=openapi.TYPE_STRING), | ||||
|             ), | ||||
|     required=["detail"], | ||||
|     additionalProperties={}, | ||||
| ) | ||||
|  | ||||
|  | ||||
| def postprocess_schema_responses(result, generator, **kwargs):  # noqa: W0613 | ||||
|     """Workaround to set a default response for endpoints. | ||||
|     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 | ||||
|     ) | ||||
|  | ||||
|     def get_response_serializers(self): | ||||
|         responses = super().get_response_serializers() | ||||
|         definitions = self.components.with_scope( | ||||
|             openapi.SCHEMA_DEFINITIONS | ||||
|         )  # type: openapi.ReferenceResolver | ||||
|  | ||||
|         definitions.setdefault("GenericError", self.get_generic_error_schema) | ||||
|         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 | ||||
|     # This is a workaround for authentik/stages/prompt/stage.py | ||||
|     # since the serializer PromptChallengeResponse | ||||
|     # accepts dynamic keys | ||||
|     for component in result["components"]["schemas"]: | ||||
|         if component == "PromptChallengeResponseRequest": | ||||
|             comp = result["components"]["schemas"][component] | ||||
|             comp["additionalProperties"] = {} | ||||
|     return result | ||||
|  | ||||
| @ -3,7 +3,7 @@ | ||||
| {% load static %} | ||||
| 
 | ||||
| {% block title %} | ||||
| API Browser - {{ config.authentik.branding.title }} | ||||
| API Browser - {{ tenant.branding_title }} | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block head %} | ||||
| @ -11,6 +11,6 @@ class TestConfig(APITestCase): | ||||
|     def test_config(self): | ||||
|         """Test YAML generation""" | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:configs-list"), | ||||
|             reverse("authentik_api:config"), | ||||
|         ) | ||||
|         self.assertTrue(loads(response.content.decode())) | ||||
|  | ||||
							
								
								
									
										22
									
								
								authentik/api/tests/test_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								authentik/api/tests/test_schema.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| """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) | ||||
| @ -1,31 +0,0 @@ | ||||
| """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())) | ||||
|  | ||||
|     def test_browser(self): | ||||
|         """Test API Browser""" | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:swagger"), | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
| @ -1,50 +1,70 @@ | ||||
| """core Configs API""" | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from rest_framework.fields import BooleanField, CharField, ListField | ||||
| from os import environ, path | ||||
|  | ||||
| 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.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import ViewSet | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.events.geo import GEOIP_READER | ||||
| from authentik.lib.config import CONFIG | ||||
|  | ||||
|  | ||||
| class FooterLinkSerializer(PassiveSerializer): | ||||
|     """Links returned in Config API""" | ||||
| class Capabilities(models.TextChoices): | ||||
|     """Define capabilities which influence which APIs can/should be used""" | ||||
|  | ||||
|     href = CharField(read_only=True) | ||||
|     name = CharField(read_only=True) | ||||
|     CAN_SAVE_MEDIA = "can_save_media" | ||||
|     CAN_GEO_IP = "can_geo_ip" | ||||
|     CAN_BACKUP = "can_backup" | ||||
|  | ||||
|  | ||||
| class ConfigSerializer(PassiveSerializer): | ||||
|     """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_environment = CharField(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""" | ||||
|  | ||||
|     permission_classes = [AllowAny] | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: ConfigSerializer(many=False)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
|     def get_capabilities(self) -> list[Capabilities]: | ||||
|         """Get all capabilities this server instance supports""" | ||||
|         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""" | ||||
|         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_environment": CONFIG.y("error_reporting.environment"), | ||||
|                 "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"), | ||||
|                 "ui_footer_links": CONFIG.y("authentik.footer_links"), | ||||
|                 "capabilities": self.get_capabilities(), | ||||
|             } | ||||
|         ) | ||||
|         return Response(config.data) | ||||
|  | ||||
| @ -1,18 +1,18 @@ | ||||
| """api v2 urls""" | ||||
| from django.urls import path, re_path | ||||
| from drf_yasg import openapi | ||||
| from drf_yasg.views import get_schema_view | ||||
| from django.urls import path | ||||
| from drf_spectacular.views import SpectacularAPIView | ||||
| from rest_framework import routers | ||||
| from rest_framework.permissions import AllowAny | ||||
|  | ||||
| from authentik.admin.api.meta import AppsViewSet | ||||
| 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.version import VersionViewSet | ||||
| from authentik.admin.api.workers import WorkerViewSet | ||||
| from authentik.api.v2.config import ConfigsViewSet | ||||
| from authentik.api.views import SwaggerView | ||||
| from authentik.admin.api.version import VersionView | ||||
| from authentik.admin.api.workers import WorkerView | ||||
| from authentik.api.v2.config import ConfigView | ||||
| from authentik.api.views import APIBrowserView | ||||
| 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.propertymappings import PropertyMappingViewSet | ||||
| 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.stages import StageViewSet | ||||
| from authentik.flows.views import FlowExecutorView | ||||
| from authentik.outposts.api.outpost_service_connections import ( | ||||
| from authentik.outposts.api.outposts import OutpostViewSet | ||||
| from authentik.outposts.api.service_connections import ( | ||||
|     DockerServiceConnectionViewSet, | ||||
|     KubernetesServiceConnectionViewSet, | ||||
|     ServiceConnectionViewSet, | ||||
| ) | ||||
| from authentik.outposts.api.outposts import OutpostViewSet | ||||
| from authentik.policies.api.bindings import PolicyBindingViewSet | ||||
| from authentik.policies.api.policies import PolicyViewSet | ||||
| from authentik.policies.dummy.api import DummyPolicyViewSet | ||||
| @ -66,6 +66,11 @@ from authentik.sources.oauth.api.source_connection import ( | ||||
| ) | ||||
| from authentik.sources.plex.api import PlexSourceViewSet | ||||
| from authentik.sources.saml.api import SAMLSourceViewSet | ||||
| from authentik.stages.authenticator_duo.api import ( | ||||
|     AuthenticatorDuoStageViewSet, | ||||
|     DuoAdminDeviceViewSet, | ||||
|     DuoDeviceViewSet, | ||||
| ) | ||||
| from authentik.stages.authenticator_static.api import ( | ||||
|     AuthenticatorStaticStageViewSet, | ||||
|     StaticAdminDeviceViewSet, | ||||
| @ -97,24 +102,21 @@ from authentik.stages.user_delete.api import UserDeleteStageViewSet | ||||
| from authentik.stages.user_login.api import UserLoginStageViewSet | ||||
| from authentik.stages.user_logout.api import UserLogoutStageViewSet | ||||
| from authentik.stages.user_write.api import UserWriteStageViewSet | ||||
| from authentik.tenants.api import TenantViewSet | ||||
|  | ||||
| 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/apps", AppsViewSet, basename="apps") | ||||
|  | ||||
| router.register("core/authenticated_sessions", AuthenticatedSessionViewSet) | ||||
| router.register("core/applications", ApplicationViewSet) | ||||
| router.register("core/groups", GroupViewSet) | ||||
| router.register("core/users", UserViewSet) | ||||
| router.register("core/user_consent", UserConsentViewSet) | ||||
| router.register("core/tokens", TokenViewSet) | ||||
| router.register("core/tenants", TenantViewSet) | ||||
|  | ||||
| router.register("outposts/outposts", OutpostViewSet) | ||||
| router.register("outposts/instances", OutpostViewSet) | ||||
| router.register("outposts/service_connections/all", ServiceConnectionViewSet) | ||||
| router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) | ||||
| @ -166,9 +168,15 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) | ||||
| router.register("propertymappings/saml", SAMLPropertyMappingViewSet) | ||||
| router.register("propertymappings/scope", ScopeMappingViewSet) | ||||
|  | ||||
| router.register("authenticators/duo", DuoDeviceViewSet) | ||||
| router.register("authenticators/static", StaticDeviceViewSet) | ||||
| router.register("authenticators/totp", TOTPDeviceViewSet) | ||||
| router.register("authenticators/webauthn", WebAuthnDeviceViewSet) | ||||
| router.register( | ||||
|     "authenticators/admin/duo", | ||||
|     DuoAdminDeviceViewSet, | ||||
|     basename="admin-duodevice", | ||||
| ) | ||||
| router.register( | ||||
|     "authenticators/admin/static", | ||||
|     StaticAdminDeviceViewSet, | ||||
| @ -184,6 +192,7 @@ router.register( | ||||
| ) | ||||
|  | ||||
| router.register("stages/all", StageViewSet) | ||||
| router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet) | ||||
| router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet) | ||||
| router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet) | ||||
| router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet) | ||||
| @ -206,32 +215,26 @@ router.register("stages/user_write", UserWriteStageViewSet) | ||||
| router.register("stages/dummy", DummyStageViewSet) | ||||
| 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 = ( | ||||
|     [ | ||||
|         path("", SwaggerView.as_view(), name="swagger"), | ||||
|         path("", APIBrowserView.as_view(), name="schema-browser"), | ||||
|     ] | ||||
|     + 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( | ||||
|             "flows/executor/<slug:flow_slug>/", | ||||
|             FlowExecutorView.as_view(), | ||||
|             name="flow-executor", | ||||
|         ), | ||||
|         re_path( | ||||
|             r"^swagger(?P<format>\.json|\.yaml)$", | ||||
|             SchemaView.without_ui(cache_timeout=0), | ||||
|             name="schema-json", | ||||
|         ), | ||||
|         path("schema/", SpectacularAPIView.as_view(), name="schema"), | ||||
|     ] | ||||
| ) | ||||
|  | ||||
| @ -5,18 +5,15 @@ from django.urls import reverse | ||||
| from django.views.generic import TemplateView | ||||
|  | ||||
|  | ||||
| class SwaggerView(TemplateView): | ||||
|     """Show swagger view based on rapi-doc""" | ||||
| class APIBrowserView(TemplateView): | ||||
|     """Show browser view based on rapi-doc""" | ||||
|  | ||||
|     template_name = "api/swagger.html" | ||||
|     template_name = "api/browser.html" | ||||
|  | ||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||
|         path = self.request.build_absolute_uri( | ||||
|             reverse( | ||||
|                 "authentik_api:schema-json", | ||||
|                 kwargs={ | ||||
|                     "format": ".json", | ||||
|                 }, | ||||
|                 "authentik_api:schema", | ||||
|             ) | ||||
|         ) | ||||
|         return super().get_context_data(path=path, **kwargs) | ||||
|  | ||||
| @ -1,14 +1,23 @@ | ||||
| """Application API Views""" | ||||
| from typing import Optional | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from django.db.models import QuerySet | ||||
| from django.http.response import HttpResponseBadRequest | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from drf_yasg import openapi | ||||
| from drf_yasg.utils import no_body, swagger_auto_schema | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import ( | ||||
|     OpenApiParameter, | ||||
|     OpenApiResponse, | ||||
|     extend_schema, | ||||
|     inline_serializer, | ||||
| ) | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.fields import ( | ||||
|     BooleanField, | ||||
|     CharField, | ||||
|     FileField, | ||||
|     IntegerField, | ||||
|     ReadOnlyField, | ||||
| ) | ||||
| from rest_framework.parsers import MultiPartParser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| @ -20,9 +29,12 @@ from structlog.stdlib import get_logger | ||||
| from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.providers import ProviderSerializer | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.models import Application, User | ||||
| from authentik.events.models import EventAction | ||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | ||||
| 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() | ||||
| @ -36,12 +48,10 @@ def user_app_cache_key(user_pk: str) -> str: | ||||
| class ApplicationSerializer(ModelSerializer): | ||||
|     """Application Serializer""" | ||||
|  | ||||
|     launch_url = SerializerMethodField() | ||||
|     launch_url = ReadOnlyField(source="get_launch_url") | ||||
|     provider_obj = ProviderSerializer(source="get_provider", required=False) | ||||
|  | ||||
|     def get_launch_url(self, instance: Application) -> Optional[str]: | ||||
|         """Get generated launch URL""" | ||||
|         return instance.get_launch_url() | ||||
|     meta_icon = ReadOnlyField(source="get_meta_icon") | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
| @ -59,9 +69,12 @@ class ApplicationSerializer(ModelSerializer): | ||||
|             "meta_publisher", | ||||
|             "policy_engine_mode", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "meta_icon": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| class ApplicationViewSet(ModelViewSet): | ||||
| class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Application Viewset""" | ||||
|  | ||||
|     queryset = Application.objects.all() | ||||
| @ -93,31 +106,42 @@ class ApplicationViewSet(ModelViewSet): | ||||
|                 applications.append(application) | ||||
|         return applications | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|     @extend_schema( | ||||
|         request=inline_serializer( | ||||
|             "CheckAccessRequest", fields={"for_user": IntegerField(required=False)} | ||||
|         ), | ||||
|         responses={ | ||||
|             204: "Access granted", | ||||
|             403: "Access denied", | ||||
|         } | ||||
|             200: PolicyTestResultSerializer(), | ||||
|             404: OpenApiResponse(description="for_user user not found"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=True, methods=["GET"]) | ||||
|     @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) | ||||
|         engine = PolicyEngine(application, self.request.user, self.request) | ||||
|         # 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() | ||||
|         if engine.passing: | ||||
|             return Response(status=204) | ||||
|         return Response(status=403) | ||||
|         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) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|     @extend_schema( | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="superuser_full_list", | ||||
|                 in_=openapi.IN_QUERY, | ||||
|                 type=openapi.TYPE_BOOLEAN, | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 type=OpenApiTypes.BOOL, | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| @ -153,17 +177,20 @@ class ApplicationViewSet(ModelViewSet): | ||||
|         return self.get_paginated_response(serializer.data) | ||||
|  | ||||
|     @permission_required("authentik_core.change_application") | ||||
|     @swagger_auto_schema( | ||||
|         request_body=no_body, | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|                 name="file", | ||||
|                 in_=openapi.IN_FORM, | ||||
|                 type=openapi.TYPE_FILE, | ||||
|                 required=True, | ||||
|     @extend_schema( | ||||
|         request={ | ||||
|             "multipart/form-data": inline_serializer( | ||||
|                 "SetIcon", | ||||
|                 fields={ | ||||
|                     "file": FileField(required=False), | ||||
|                     "clear": BooleanField(default=False), | ||||
|                 }, | ||||
|             ) | ||||
|         ], | ||||
|         responses={200: "Success", 400: "Bad request"}, | ||||
|         }, | ||||
|         responses={ | ||||
|             200: OpenApiResponse(description="Success"), | ||||
|             400: OpenApiResponse(description="Bad request"), | ||||
|         }, | ||||
|     ) | ||||
|     @action( | ||||
|         detail=True, | ||||
| @ -177,16 +204,46 @@ class ApplicationViewSet(ModelViewSet): | ||||
|         """Set application icon""" | ||||
|         app: Application = self.get_object() | ||||
|         icon = request.FILES.get("file", None) | ||||
|         if not icon: | ||||
|             return HttpResponseBadRequest() | ||||
|         clear = request.data.get("clear", False) | ||||
|         if clear: | ||||
|             # .delete() saves the model by default | ||||
|             app.meta_icon.delete() | ||||
|             return Response({}) | ||||
|         if icon: | ||||
|             app.meta_icon = icon | ||||
|             app.save() | ||||
|             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( | ||||
|         "authentik_core.view_application", ["authentik_events.view_event"] | ||||
|     ) | ||||
|     @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: CoordinateSerializer(many=True)}) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||
|     # pylint: disable=unused-argument | ||||
|     def metrics(self, request: Request, slug: str): | ||||
|  | ||||
							
								
								
									
										117
									
								
								authentik/core/api/authenticated_sessions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								authentik/core/api/authenticated_sessions.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,117 @@ | ||||
| """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) | ||||
| @ -5,6 +5,7 @@ from rest_framework.serializers import ModelSerializer | ||||
| 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.models import Group | ||||
|  | ||||
| @ -20,7 +21,7 @@ class GroupSerializer(ModelSerializer): | ||||
|         fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] | ||||
|  | ||||
|  | ||||
| class GroupViewSet(ModelViewSet): | ||||
| class GroupViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Group Viewset""" | ||||
|  | ||||
|     queryset = Group.objects.all() | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| """PropertyMapping API Views""" | ||||
| from json import dumps | ||||
|  | ||||
| from drf_yasg import openapi | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework import mixins | ||||
| from rest_framework.decorators import action | ||||
| @ -14,6 +14,7 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
|  | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import ( | ||||
|     MetaNameSerializer, | ||||
|     PassiveSerializer, | ||||
| @ -65,6 +66,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri | ||||
| class PropertyMappingViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     UsedByMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
| @ -81,7 +83,7 @@ class PropertyMappingViewSet( | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         return PropertyMapping.objects.select_subclasses() | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def types(self, request: Request) -> Response: | ||||
|         """Get all creatable property-mapping types""" | ||||
| @ -100,14 +102,17 @@ class PropertyMappingViewSet( | ||||
|         return Response(TypeCreateSerializer(data, many=True).data) | ||||
|  | ||||
|     @permission_required("authentik_core.view_propertymapping") | ||||
|     @swagger_auto_schema( | ||||
|         request_body=PolicyTestSerializer(), | ||||
|         responses={200: PropertyMappingTestResultSerializer, 400: "Invalid parameters"}, | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|     @extend_schema( | ||||
|         request=PolicyTestSerializer(), | ||||
|         responses={ | ||||
|             200: PropertyMappingTestResultSerializer, | ||||
|             400: OpenApiResponse(description="Invalid parameters"), | ||||
|         }, | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="format_result", | ||||
|                 in_=openapi.IN_QUERY, | ||||
|                 type=openapi.TYPE_BOOLEAN, | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 type=OpenApiTypes.BOOL, | ||||
|             ) | ||||
|         ], | ||||
|     ) | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| """Provider API Views""" | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from rest_framework import mixins | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import ReadOnlyField | ||||
| @ -9,6 +9,7 @@ from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||
| 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.models import Provider | ||||
| from authentik.lib.utils.reflection import all_subclasses | ||||
| @ -22,7 +23,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||
|  | ||||
|     component = SerializerMethodField() | ||||
|  | ||||
|     def get_component(self, obj: Provider):  # pragma: no cover | ||||
|     def get_component(self, obj: Provider) -> str:  # pragma: no cover | ||||
|         """Get object component so that we know how to edit the object""" | ||||
|         # pyright: reportGeneralTypeIssues=false | ||||
|         if obj.__class__ == Provider: | ||||
| @ -48,6 +49,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||
| class ProviderViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     UsedByMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
| @ -66,7 +68,7 @@ class ProviderViewSet( | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         return Provider.objects.select_subclasses() | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def types(self, request: Request) -> Response: | ||||
|         """Get all creatable provider types""" | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| """Source API Views""" | ||||
| from typing import Iterable | ||||
|  | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from rest_framework import mixins | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.request import Request | ||||
| @ -10,6 +10,7 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
| 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.models import Source | ||||
| from authentik.core.types import UserSettingSerializer | ||||
| @ -24,7 +25,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||
|  | ||||
|     component = SerializerMethodField() | ||||
|  | ||||
|     def get_component(self, obj: Source): | ||||
|     def get_component(self, obj: Source) -> str: | ||||
|         """Get object component so that we know how to edit the object""" | ||||
|         # pyright: reportGeneralTypeIssues=false | ||||
|         if obj.__class__ == Source: | ||||
| @ -52,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||
| class SourceViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     UsedByMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
| @ -64,7 +66,7 @@ class SourceViewSet( | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         return Source.objects.select_subclasses() | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def types(self, request: Request) -> Response: | ||||
|         """Get all creatable source types""" | ||||
| @ -87,7 +89,7 @@ class SourceViewSet( | ||||
|             ) | ||||
|         return Response(TypeCreateSerializer(data, many=True).data) | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: UserSettingSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: UserSettingSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def user_settings(self, request: Request) -> Response: | ||||
|         """Get all sources the user can configure""" | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| """Tokens API Viewset""" | ||||
| from django.http.response import Http404 | ||||
| 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.fields import CharField | ||||
| from rest_framework.request import Request | ||||
| @ -9,6 +9,7 @@ from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| 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.utils import PassiveSerializer | ||||
| from authentik.core.models import Token, TokenIntents | ||||
| @ -43,7 +44,7 @@ class TokenViewSerializer(PassiveSerializer): | ||||
|     key = CharField(read_only=True) | ||||
|  | ||||
|  | ||||
| class TokenViewSet(ModelViewSet): | ||||
| class TokenViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Token Viewset""" | ||||
|  | ||||
|     lookup_field = "identifier" | ||||
| @ -67,10 +68,10 @@ class TokenViewSet(ModelViewSet): | ||||
|         serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API) | ||||
|  | ||||
|     @permission_required("authentik_core.view_token_key") | ||||
|     @swagger_auto_schema( | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: TokenViewSerializer(many=False), | ||||
|             404: "Token not found or expired", | ||||
|             404: OpenApiResponse(description="Token not found or expired"), | ||||
|         } | ||||
|     ) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||
|  | ||||
							
								
								
									
										102
									
								
								authentik/core/api/used_by.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								authentik/core/api/used_by.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,102 @@ | ||||
| """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) | ||||
| @ -7,7 +7,7 @@ from django.urls import reverse_lazy | ||||
| from django.utils.http import urlencode | ||||
| from django_filters.filters import BooleanFilter, CharFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field | ||||
| from guardian.utils import get_anonymous_user | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField, JSONField, SerializerMethodField | ||||
| @ -25,6 +25,7 @@ from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||
| from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.groups import GroupSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | ||||
| from authentik.core.middleware import ( | ||||
|     SESSION_IMPERSONATE_ORIGINAL_USER, | ||||
| @ -32,7 +33,7 @@ from authentik.core.middleware import ( | ||||
| ) | ||||
| from authentik.core.models import Token, TokenIntents, User | ||||
| from authentik.events.models import EventAction | ||||
| from authentik.flows.models import Flow, FlowDesignation | ||||
| from authentik.tenants.models import Tenant | ||||
|  | ||||
|  | ||||
| class UserSerializer(ModelSerializer): | ||||
| @ -77,13 +78,13 @@ class UserMetricsSerializer(PassiveSerializer): | ||||
|     logins_failed_per_1h = SerializerMethodField() | ||||
|     authorizations_per_1h = SerializerMethodField() | ||||
|  | ||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||
|     @extend_schema_field(CoordinateSerializer(many=True)) | ||||
|     def get_logins_per_1h(self, _): | ||||
|         """Get successful logins per hour for the last 24 hours""" | ||||
|         user = self.context["user"] | ||||
|         return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk) | ||||
|  | ||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||
|     @extend_schema_field(CoordinateSerializer(many=True)) | ||||
|     def get_logins_failed_per_1h(self, _): | ||||
|         """Get failed logins per hour for the last 24 hours""" | ||||
|         user = self.context["user"] | ||||
| @ -91,7 +92,7 @@ class UserMetricsSerializer(PassiveSerializer): | ||||
|             action=EventAction.LOGIN_FAILED, context__username=user.username | ||||
|         ) | ||||
|  | ||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) | ||||
|     @extend_schema_field(CoordinateSerializer(many=True)) | ||||
|     def get_authorizations_per_1h(self, _): | ||||
|         """Get failed logins per hour for the last 24 hours""" | ||||
|         user = self.context["user"] | ||||
| @ -131,7 +132,7 @@ class UsersFilter(FilterSet): | ||||
|         fields = ["username", "name", "is_active", "is_superuser", "attributes"] | ||||
|  | ||||
|  | ||||
| class UserViewSet(ModelViewSet): | ||||
| class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|     """User Viewset""" | ||||
|  | ||||
|     queryset = User.objects.none() | ||||
| @ -142,7 +143,7 @@ class UserViewSet(ModelViewSet): | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         return User.objects.all().exclude(pk=get_anonymous_user().pk) | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: SessionUserSerializer(many=False)}) | ||||
|     @extend_schema(responses={200: SessionUserSerializer(many=False)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     # pylint: disable=invalid-name | ||||
|     def me(self, request: Request) -> Response: | ||||
| @ -158,7 +159,7 @@ class UserViewSet(ModelViewSet): | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) | ||||
|     @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)}) | ||||
|     @extend_schema(responses={200: UserMetricsSerializer(many=False)}) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||
|     # pylint: disable=invalid-name, unused-argument | ||||
|     def metrics(self, request: Request, pk: int) -> Response: | ||||
| @ -169,15 +170,19 @@ class UserViewSet(ModelViewSet): | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|     @permission_required("authentik_core.reset_user_password") | ||||
|     @swagger_auto_schema( | ||||
|         responses={"200": LinkSerializer(many=False), "404": "No recovery flow found."}, | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             "200": LinkSerializer(many=False), | ||||
|             "404": OpenApiResponse(description="No recovery flow found."), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||
|     # pylint: disable=invalid-name, unused-argument | ||||
|     def recovery(self, request: Request, pk: int) -> Response: | ||||
|         """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 | ||||
|         flow = Flow.with_policy(request, designation=FlowDesignation.RECOVERY) | ||||
|         flow = tenant.flow_recovery | ||||
|         if not flow: | ||||
|             raise Http404 | ||||
|         user: User = self.get_object() | ||||
| @ -188,7 +193,8 @@ class UserViewSet(ModelViewSet): | ||||
|         ) | ||||
|         querystring = urlencode({"token": token.key}) | ||||
|         link = request.build_absolute_uri( | ||||
|             reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}" | ||||
|             reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||
|             + f"?{querystring}" | ||||
|         ) | ||||
|         return Response({"link": link}) | ||||
|  | ||||
|  | ||||
| @ -28,6 +28,9 @@ class PassiveSerializer(Serializer): | ||||
|     ) -> Model:  # pragma: no cover | ||||
|         return Model() | ||||
|  | ||||
|     class Meta: | ||||
|         model = Model | ||||
|  | ||||
|  | ||||
| class MetaNameSerializer(PassiveSerializer): | ||||
|     """Add verbose names to response""" | ||||
|  | ||||
| @ -2,6 +2,10 @@ | ||||
| from importlib import import_module | ||||
|  | ||||
| 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): | ||||
| @ -15,3 +19,12 @@ class AuthentikCoreConfig(AppConfig): | ||||
|     def ready(self): | ||||
|         import_module("authentik.core.signals") | ||||
|         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 | ||||
|  | ||||
| @ -42,10 +42,14 @@ class RequestIDMiddleware: | ||||
|         if not hasattr(request, "request_id"): | ||||
|             request_id = uuid4().hex | ||||
|             setattr(request, "request_id", request_id) | ||||
|             LOCAL.authentik = {"request_id": request_id} | ||||
|             LOCAL.authentik = { | ||||
|                 "request_id": request_id, | ||||
|                 "host": request.get_host(), | ||||
|             } | ||||
|         response = self.get_response(request) | ||||
|         response[RESPONSE_HEADER_ID] = request.request_id | ||||
|         del LOCAL.authentik["request_id"] | ||||
|         del LOCAL.authentik["host"] | ||||
|         return response | ||||
|  | ||||
|  | ||||
| @ -54,4 +58,5 @@ def structlog_add_request_id(logger: Logger, method_name: str, event_dict): | ||||
|     """If threadlocal has authentik defined, add request_id to log""" | ||||
|     if hasattr(LOCAL, "authentik"): | ||||
|         event_dict["request_id"] = LOCAL.authentik.get("request_id", "") | ||||
|         event_dict["host"] = LOCAL.authentik.get("host", "") | ||||
|     return event_dict | ||||
|  | ||||
							
								
								
									
										63
									
								
								authentik/core/migrations/0022_authenticatedsession.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								authentik/core/migrations/0022_authenticatedsession.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,63 @@ | ||||
| # 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), | ||||
|     ] | ||||
| @ -0,0 +1,23 @@ | ||||
| # 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()], | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										35
									
								
								authentik/core/migrations/0024_alter_token_identifier.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								authentik/core/migrations/0024_alter_token_identifier.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| # 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), | ||||
|         ), | ||||
|     ] | ||||
| @ -0,0 +1,20 @@ | ||||
| # 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,9 +5,11 @@ from typing import Any, Optional, Type | ||||
| from urllib.parse import urlencode | ||||
| from uuid import uuid4 | ||||
|  | ||||
| import django.db.models.options as options | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import AbstractUser | ||||
| from django.contrib.auth.models import UserManager as DjangoUserManager | ||||
| from django.core import validators | ||||
| from django.db import models | ||||
| from django.db.models import Q, QuerySet | ||||
| from django.http import HttpRequest | ||||
| @ -23,11 +25,11 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.exceptions import PropertyMappingExpressionException | ||||
| from authentik.core.signals import password_changed | ||||
| from authentik.core.types import UILoginButton | ||||
| from authentik.flows.challenge import Challenge | ||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.models import CreatedUpdatedModel, SerializerModel | ||||
| from authentik.lib.utils.http import get_client_ip | ||||
| from authentik.managed.models import ManagedModel | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
|  | ||||
| @ -40,6 +42,9 @@ GRAVATAR_URL = "https://secure.gravatar.com" | ||||
| DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | ||||
|  | ||||
|  | ||||
| options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",) | ||||
|  | ||||
|  | ||||
| def default_token_duration(): | ||||
|     """Default duration a Token is valid""" | ||||
|     return now() + timedelta(minutes=30) | ||||
| @ -214,12 +219,28 @@ class Application(PolicyBindingModel): | ||||
|         "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT | ||||
|     ) | ||||
|  | ||||
|     meta_launch_url = models.URLField(default="", blank=True) | ||||
|     meta_launch_url = models.TextField( | ||||
|         default="", blank=True, validators=[validators.URLValidator()] | ||||
|     ) | ||||
|     # For template applications, this can be set to /static/authentik/applications/* | ||||
|     meta_icon = models.FileField(upload_to="application-icons/", default="", blank=True) | ||||
|     meta_icon = models.FileField( | ||||
|         upload_to="application-icons/", default=None, null=True | ||||
|     ) | ||||
|     meta_description = 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]: | ||||
|         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" | ||||
|         if self.meta_launch_url: | ||||
| @ -324,9 +345,9 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|         return None | ||||
|  | ||||
|     @property | ||||
|     def ui_user_settings(self) -> Optional[Challenge]: | ||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||
|         """Entrypoint to integrate with User settings. Can either return None if no | ||||
|         user settings are available, or a challenge.""" | ||||
|         user settings are available, or UserSettingSerializer.""" | ||||
|         return None | ||||
|  | ||||
|     def __str__(self): | ||||
| @ -388,7 +409,7 @@ class Token(ManagedModel, ExpiringModel): | ||||
|     """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) | ||||
|     identifier = models.SlugField(max_length=255) | ||||
|     identifier = models.SlugField(max_length=255, unique=True) | ||||
|     key = models.TextField(default=default_token_key) | ||||
|     intent = models.TextField( | ||||
|         choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION | ||||
| @ -452,3 +473,33 @@ class PropertyMapping(SerializerModel, ManagedModel): | ||||
|  | ||||
|         verbose_name = _("Property Mapping") | ||||
|         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,20 +1,38 @@ | ||||
| """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.signals import Signal | ||||
| from django.db.models import Model | ||||
| from django.db.models.signals import post_save | ||||
| from django.dispatch import receiver | ||||
| from django.http.request import HttpRequest | ||||
| from prometheus_client import Gauge | ||||
|  | ||||
| # Arguments: user: User, password: str | ||||
| 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) | ||||
| # pylint: disable=unused-argument | ||||
| def post_save_application(sender, instance, created: bool, **_): | ||||
| def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||
|     """Clear user's application cache upon application creation""" | ||||
|     from authentik.core.api.applications import user_app_cache_key | ||||
|     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: | ||||
|         return | ||||
|     if not created:  # pragma: no cover | ||||
| @ -22,3 +40,23 @@ def post_save_application(sender, instance, created: bool, **_): | ||||
|     # Also delete user application cache | ||||
|     keys = cache.keys(user_app_cache_key("*")) | ||||
|     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() | ||||
|  | ||||
| @ -33,6 +33,7 @@ from authentik.flows.planner import ( | ||||
| 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 import BACKEND_DJANGO | ||||
| from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
|  | ||||
| @ -198,7 +199,7 @@ class SourceFlowManager: | ||||
|         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_AUTHENTICATION_BACKEND: BACKEND_DJANGO, | ||||
|                 PLAN_CONTEXT_SSO: True, | ||||
|                 PLAN_CONTEXT_SOURCE: self.source, | ||||
|                 PLAN_CONTEXT_REDIRECT: final_redirect, | ||||
|  | ||||
| @ -7,8 +7,7 @@ | ||||
|     <head> | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|         <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 }}"> | ||||
|         <title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title> | ||||
|         <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/page.css' %}?v={{ ak_version }}"> | ||||
|  | ||||
| @ -14,7 +14,7 @@ | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block title %} | ||||
| {% trans 'End session' %} - {{ config.authentik.branding.title }} | ||||
| {% trans 'End session' %} - {{ tenant.branding_title }} | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block card_title %} | ||||
| @ -4,7 +4,9 @@ | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head_before %} | ||||
| {% if flow.compatibility_mode %} | ||||
| <script>ShadyDOM = { force: !navigator.webdriver };</script> | ||||
| {% endif %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block head %} | ||||
|  | ||||
| @ -26,10 +26,7 @@ | ||||
|     <div class="ak-login-container"> | ||||
|         <header class="pf-c-login__header"> | ||||
|             <div class="pf-c-brand ak-brand"> | ||||
|                 <img src="{{ config.authentik.branding.logo }}" alt="authentik icon" /> | ||||
|                 {% if config.authentik.branding.title_show %} | ||||
|                 <p>{{ config.authentik.branding.title }}</p> | ||||
|                 {% endif %} | ||||
|                 <img src="{{ tenant.branding_logo }}" alt="authentik icon" /> | ||||
|             </div> | ||||
|         </header> | ||||
|         {% block main_container %} | ||||
| @ -49,12 +46,12 @@ | ||||
|         <footer class="pf-c-login__footer"> | ||||
|             <p></p> | ||||
|             <ul class="pf-c-list pf-m-inline"> | ||||
|                 {% for link in config.authentik.footer_links %} | ||||
|                 {% for link in footer_links %} | ||||
|                 <li> | ||||
|                     <a href="{{ link.href }}">{{ link.name }}</a> | ||||
|                 </li> | ||||
|                 {% endfor %} | ||||
|                 {% if config.authentik.branding.title != "authentik" %} | ||||
|                 {% if tenant.branding_title != "authentik" %} | ||||
|                 <li> | ||||
|                     <a href="https://goauthentik.io"> | ||||
|                         {% trans 'Powered by authentik' %} | ||||
|  | ||||
| @ -26,20 +26,26 @@ class TestApplicationsAPI(APITestCase): | ||||
|     def test_check_access(self): | ||||
|         """Test check_access operation""" | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|         response = self.client.post( | ||||
|             reverse( | ||||
|                 "authentik_api:application-check-access", | ||||
|                 kwargs={"slug": self.allowed.slug}, | ||||
|             ) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 204) | ||||
|         response = self.client.get( | ||||
|         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, 403) | ||||
|         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""" | ||||
|  | ||||
							
								
								
									
										31
									
								
								authentik/core/tests/test_authenticated_sessions_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								authentik/core/tests/test_authenticated_sessions_api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| """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) | ||||
							
								
								
									
										29
									
								
								authentik/core/tests/test_users_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								authentik/core/tests/test_users_api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | ||||
| """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) | ||||
| @ -36,3 +36,4 @@ class UserSettingSerializer(PassiveSerializer): | ||||
|     object_uid = CharField() | ||||
|     component = CharField() | ||||
|     title = CharField() | ||||
|     configure_url = CharField(required=False) | ||||
|  | ||||
| @ -6,6 +6,8 @@ from django.views.generic import RedirectView | ||||
| from django.views.generic.base import TemplateView | ||||
|  | ||||
| from authentik.core.views import impersonate | ||||
| from authentik.core.views.interface import FlowInterfaceView | ||||
| from authentik.core.views.session import EndSessionView | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path( | ||||
| @ -32,7 +34,18 @@ urlpatterns = [ | ||||
|     ), | ||||
|     path( | ||||
|         "if/flow/<slug:flow_slug>/", | ||||
|         ensure_csrf_cookie(TemplateView.as_view(template_name="if/flow.html")), | ||||
|         ensure_csrf_cookie(FlowInterfaceView.as_view()), | ||||
|         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"), | ||||
|     ), | ||||
| ] | ||||
|  | ||||
							
								
								
									
										17
									
								
								authentik/core/views/interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								authentik/core/views/interface.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| """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,22 +1,24 @@ | ||||
| """authentik OAuth2 Session Views""" | ||||
| """authentik Session Views""" | ||||
| from typing import Any | ||||
| 
 | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.views.generic.base import TemplateView | ||||
| 
 | ||||
| from authentik.core.models import Application | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| 
 | ||||
| 
 | ||||
| class EndSessionView(TemplateView): | ||||
| class EndSessionView(TemplateView, PolicyAccessView): | ||||
|     """Allow the client to end the Session""" | ||||
| 
 | ||||
|     template_name = "providers/oauth2/end_session.html" | ||||
|     template_name = "if/end_session.html" | ||||
| 
 | ||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||
|         context = super().get_context_data(**kwargs) | ||||
| 
 | ||||
|         context["application"] = get_object_or_404( | ||||
|     def resolve_provider_application(self): | ||||
|         self.application = get_object_or_404( | ||||
|             Application, slug=self.kwargs["application_slug"] | ||||
|         ) | ||||
| 
 | ||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||
|         context = super().get_context_data(**kwargs) | ||||
|         context["application"] = self.application | ||||
|         return context | ||||
| @ -1,12 +1,14 @@ | ||||
| """Crypto API Views""" | ||||
| import django_filters | ||||
| from cryptography.hazmat.backends import default_backend | ||||
| from cryptography.hazmat.primitives.serialization import load_pem_private_key | ||||
| 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 drf_yasg import openapi | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from django_filters import FilterSet | ||||
| 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.fields import ( | ||||
|     CharField, | ||||
| @ -20,6 +22,7 @@ from rest_framework.serializers import ModelSerializer, ValidationError | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.crypto.builder import CertificateBuilder | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| @ -33,6 +36,9 @@ class CertificateKeyPairSerializer(ModelSerializer): | ||||
|     cert_subject = SerializerMethodField() | ||||
|     private_key_available = SerializerMethodField() | ||||
|  | ||||
|     certificate_download_url = SerializerMethodField() | ||||
|     private_key_download_url = SerializerMethodField() | ||||
|  | ||||
|     def get_cert_subject(self, instance: CertificateKeyPair) -> str: | ||||
|         """Get certificate subject as full rfc4514""" | ||||
|         return instance.certificate.subject.rfc4514_string() | ||||
| @ -41,6 +47,26 @@ class CertificateKeyPairSerializer(ModelSerializer): | ||||
|         """Show if this keypair has a private key configured or not""" | ||||
|         return instance.key_data != "" and instance.key_data is not None | ||||
|  | ||||
|     def get_certificate_download_url(self, instance: CertificateKeyPair) -> str: | ||||
|         """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""" | ||||
|         try: | ||||
| @ -77,6 +103,8 @@ class CertificateKeyPairSerializer(ModelSerializer): | ||||
|             "cert_expiry", | ||||
|             "cert_subject", | ||||
|             "private_key_available", | ||||
|             "certificate_download_url", | ||||
|             "private_key_download_url", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "key_data": {"write_only": True}, | ||||
| @ -100,10 +128,10 @@ class CertificateGenerationSerializer(PassiveSerializer): | ||||
|     validity_days = IntegerField(initial=365) | ||||
|  | ||||
|  | ||||
| class CertificateKeyPairFilter(django_filters.FilterSet): | ||||
| class CertificateKeyPairFilter(FilterSet): | ||||
|     """Filter for certificates""" | ||||
|  | ||||
|     has_key = django_filters.BooleanFilter( | ||||
|     has_key = BooleanFilter( | ||||
|         label="Only return certificate-key pairs with keys", method="filter_has_key" | ||||
|     ) | ||||
|  | ||||
| @ -117,7 +145,7 @@ class CertificateKeyPairFilter(django_filters.FilterSet): | ||||
|         fields = ["name"] | ||||
|  | ||||
|  | ||||
| class CertificateKeyPairViewSet(ModelViewSet): | ||||
| class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | ||||
|     """CertificateKeyPair Viewset""" | ||||
|  | ||||
|     queryset = CertificateKeyPair.objects.all() | ||||
| @ -125,9 +153,12 @@ class CertificateKeyPairViewSet(ModelViewSet): | ||||
|     filterset_class = CertificateKeyPairFilter | ||||
|  | ||||
|     @permission_required(None, ["authentik_crypto.add_certificatekeypair"]) | ||||
|     @swagger_auto_schema( | ||||
|         request_body=CertificateGenerationSerializer(), | ||||
|         responses={200: CertificateKeyPairSerializer, 400: "Bad request"}, | ||||
|     @extend_schema( | ||||
|         request=CertificateGenerationSerializer(), | ||||
|         responses={ | ||||
|             200: CertificateKeyPairSerializer, | ||||
|             400: OpenApiResponse(description="Bad request"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=False, methods=["POST"]) | ||||
|     def generate(self, request: Request) -> Response: | ||||
| @ -147,12 +178,12 @@ class CertificateKeyPairViewSet(ModelViewSet): | ||||
|         serializer = self.get_serializer(instance) | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|     @extend_schema( | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="download", | ||||
|                 in_=openapi.IN_QUERY, | ||||
|                 type=openapi.TYPE_BOOLEAN, | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 type=OpenApiTypes.BOOL, | ||||
|             ) | ||||
|         ], | ||||
|         responses={200: CertificateDataSerializer(many=False)}, | ||||
| @ -180,12 +211,12 @@ class CertificateKeyPairViewSet(ModelViewSet): | ||||
|             CertificateDataSerializer({"data": certificate.certificate_data}).data | ||||
|         ) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|     @extend_schema( | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="download", | ||||
|                 in_=openapi.IN_QUERY, | ||||
|                 type=openapi.TYPE_BOOLEAN, | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 type=OpenApiTypes.BOOL, | ||||
|             ) | ||||
|         ], | ||||
|         responses={200: CertificateDataSerializer(many=False)}, | ||||
|  | ||||
| @ -4,10 +4,14 @@ import datetime | ||||
| 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.builder import CertificateBuilder | ||||
| 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): | ||||
| @ -91,3 +95,35 @@ class TestCrypto(TestCase): | ||||
|         ) | ||||
|         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,7 +2,8 @@ | ||||
| import django_filters | ||||
| from django.db.models.aggregates import Count | ||||
| from django.db.models.fields.json import KeyTextTransform | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField, DictField, IntegerField | ||||
| @ -38,12 +39,6 @@ class EventSerializer(ModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class EventTopPerUserParams(PassiveSerializer): | ||||
|     """Query params for top_per_user""" | ||||
|  | ||||
|     top_n = IntegerField(default=15) | ||||
|  | ||||
|  | ||||
| class EventTopPerUserSerializer(PassiveSerializer): | ||||
|     """Response object of Event's top_per_user""" | ||||
|  | ||||
| @ -111,12 +106,19 @@ class EventViewSet(ReadOnlyModelViewSet): | ||||
|     ] | ||||
|     filterset_class = EventsFilter | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         method="GET", | ||||
|     @extend_schema( | ||||
|         methods=["GET"], | ||||
|         responses={200: EventTopPerUserSerializer(many=True)}, | ||||
|         query_serializer=EventTopPerUserParams, | ||||
|         parameters=[ | ||||
|             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): | ||||
|         """Get the top_n events grouped by user count""" | ||||
|         filtered_action = request.query_params.get("action", EventAction.LOGIN) | ||||
| @ -134,7 +136,7 @@ class EventViewSet(ReadOnlyModelViewSet): | ||||
|             .order_by("-counted_events")[:top_n] | ||||
|         ) | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def actions(self, request: Request) -> Response: | ||||
|         """Get all actions""" | ||||
|  | ||||
| @ -7,6 +7,7 @@ from rest_framework.serializers import ModelSerializer | ||||
| 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.models import Notification | ||||
|  | ||||
| @ -35,6 +36,7 @@ class NotificationViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.UpdateModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     UsedByMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
|  | ||||
| @ -3,6 +3,7 @@ from rest_framework.serializers import ModelSerializer | ||||
| 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 | ||||
|  | ||||
|  | ||||
| @ -24,7 +25,7 @@ class NotificationRuleSerializer(ModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class NotificationRuleViewSet(ModelViewSet): | ||||
| class NotificationRuleViewSet(UsedByMixin, ModelViewSet): | ||||
|     """NotificationRule Viewset""" | ||||
|  | ||||
|     queryset = NotificationRule.objects.all() | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| """NotificationTransport API Views""" | ||||
| from drf_yasg.utils import no_body, swagger_auto_schema | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||
| from rest_framework.request import Request | ||||
| @ -8,6 +9,7 @@ from rest_framework.serializers import ModelSerializer, Serializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.events.models import ( | ||||
|     Notification, | ||||
|     NotificationSeverity, | ||||
| @ -22,7 +24,7 @@ class NotificationTransportSerializer(ModelSerializer): | ||||
|  | ||||
|     mode_verbose = SerializerMethodField() | ||||
|  | ||||
|     def get_mode_verbose(self, instance: NotificationTransport): | ||||
|     def get_mode_verbose(self, instance: NotificationTransport) -> str: | ||||
|         """Return selected mode with a UI Label""" | ||||
|         return TransportMode(instance.mode).label | ||||
|  | ||||
| @ -51,19 +53,19 @@ class NotificationTransportTestSerializer(Serializer): | ||||
|         raise NotImplementedError | ||||
|  | ||||
|  | ||||
| class NotificationTransportViewSet(ModelViewSet): | ||||
| class NotificationTransportViewSet(UsedByMixin, ModelViewSet): | ||||
|     """NotificationTransport Viewset""" | ||||
|  | ||||
|     queryset = NotificationTransport.objects.all() | ||||
|     serializer_class = NotificationTransportSerializer | ||||
|  | ||||
|     @permission_required("authentik_events.change_notificationtransport") | ||||
|     @swagger_auto_schema( | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: NotificationTransportTestSerializer(many=False), | ||||
|             503: "Failed to test transport", | ||||
|             500: OpenApiResponse(description="Failed to test transport"), | ||||
|         }, | ||||
|         request_body=no_body, | ||||
|         request=OpenApiTypes.NONE, | ||||
|     ) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"]) | ||||
|     # pylint: disable=invalid-name, unused-argument | ||||
| @ -83,4 +85,4 @@ class NotificationTransportViewSet(ModelViewSet): | ||||
|             response.is_valid() | ||||
|             return Response(response.data) | ||||
|         except NotificationTransportError as exc: | ||||
|             return Response(str(exc.__cause__ or None), status=503) | ||||
|             return Response(str(exc.__cause__ or None), status=500) | ||||
|  | ||||
| @ -1,7 +1,10 @@ | ||||
| """authentik events app""" | ||||
| from datetime import timedelta | ||||
| from importlib import import_module | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.db import ProgrammingError | ||||
| from django.utils.timezone import now | ||||
|  | ||||
|  | ||||
| class AuthentikEventsConfig(AppConfig): | ||||
| @ -13,3 +16,12 @@ class AuthentikEventsConfig(AppConfig): | ||||
|  | ||||
|     def ready(self): | ||||
|         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,7 +1,12 @@ | ||||
| """events GeoIP Reader""" | ||||
| from typing import Optional | ||||
| from datetime import datetime | ||||
| from os import stat | ||||
| from time import time | ||||
| from typing import Optional, TypedDict | ||||
|  | ||||
| from geoip2.database import Reader | ||||
| from geoip2.errors import GeoIP2Error | ||||
| from geoip2.models import City | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.lib.config import CONFIG | ||||
| @ -9,17 +14,78 @@ from authentik.lib.config import CONFIG | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| def get_geoip_reader() -> Optional[Reader]: | ||||
| class GeoIPDict(TypedDict): | ||||
|     """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""" | ||||
|         path = CONFIG.y("authentik.geoip") | ||||
|         if path == "" or not path: | ||||
|         return None | ||||
|             return | ||||
|         try: | ||||
|             reader = Reader(path) | ||||
|         LOGGER.info("Enabled GeoIP support") | ||||
|         return reader | ||||
|     except OSError: | ||||
|             LOGGER.info("Loaded GeoIP database") | ||||
|             self.__reader = reader | ||||
|             self.__last_mtime = stat(path).st_mtime | ||||
|         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 | ||||
|  | ||||
|     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() | ||||
|  | ||||
							
								
								
									
										45
									
								
								authentik/events/migrations/0015_alter_event_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								authentik/events/migrations/0015_alter_event_action.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,45 @@ | ||||
| # 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.utils.timezone import now | ||||
| from django.utils.translation import gettext as _ | ||||
| from geoip2.errors import GeoIP2Error | ||||
| from prometheus_client import Gauge | ||||
| from requests import RequestException, post | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -28,6 +28,11 @@ from authentik.policies.models import PolicyBindingModel | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
|  | ||||
| LOGGER = get_logger("authentik.events") | ||||
| GAUGE_EVENTS = Gauge( | ||||
|     "authentik_events", | ||||
|     "Events in authentik", | ||||
|     ["action", "user_username", "app", "client_ip"], | ||||
| ) | ||||
|  | ||||
|  | ||||
| def default_event_duration(): | ||||
| @ -72,6 +77,7 @@ class EventAction(models.TextChoices): | ||||
|     MODEL_CREATED = "model_created" | ||||
|     MODEL_UPDATED = "model_updated" | ||||
|     MODEL_DELETED = "model_deleted" | ||||
|     EMAIL_SENT = "email_sent" | ||||
|  | ||||
|     UPDATE_AVAILABLE = "update_available" | ||||
|  | ||||
| @ -143,7 +149,7 @@ class Event(ExpiringModel): | ||||
|                     request.session[SESSION_IMPERSONATE_USER] | ||||
|                 ) | ||||
|         # User 255.255.255.255 as fallback if IP cannot be determined | ||||
|         self.client_ip = get_client_ip(request) or "255.255.255.255" | ||||
|         self.client_ip = get_client_ip(request) | ||||
|         # Apply GeoIP Data, when enabled | ||||
|         self.with_geoip() | ||||
|         # If there's no app set, we get it from the requests too | ||||
| @ -152,22 +158,20 @@ class Event(ExpiringModel): | ||||
|         self.save() | ||||
|         return self | ||||
|  | ||||
|     def with_geoip(self): | ||||
|     def with_geoip(self):  # pragma: no cover | ||||
|         """Apply GeoIP Data, when enabled""" | ||||
|         if not GEOIP_READER: | ||||
|         city = GEOIP_READER.city_dict(self.client_ip) | ||||
|         if not city: | ||||
|             return | ||||
|         try: | ||||
|             response = GEOIP_READER.city(self.client_ip) | ||||
|             self.context["geo"] = { | ||||
|                 "continent": response.continent.code, | ||||
|                 "country": response.country.iso_code, | ||||
|                 "lat": response.location.latitude, | ||||
|                 "long": response.location.longitude, | ||||
|             } | ||||
|             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) | ||||
|         self.context["geo"] = city | ||||
|  | ||||
|     def _set_prom_metrics(self): | ||||
|         GAUGE_EVENTS.labels( | ||||
|             action=self.action, | ||||
|             user_username=self.user.get("username"), | ||||
|             app=self.app, | ||||
|             client_ip=self.client_ip, | ||||
|         ).set(self.created.timestamp()) | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         if self._state.adding: | ||||
| @ -178,7 +182,8 @@ class Event(ExpiringModel): | ||||
|                 client_ip=self.client_ip, | ||||
|                 user=self.user, | ||||
|             ) | ||||
|         return super().save(*args, **kwargs) | ||||
|         super().save(*args, **kwargs) | ||||
|         self._set_prom_metrics() | ||||
|  | ||||
|     @property | ||||
|     def summary(self) -> str: | ||||
|  | ||||
| @ -2,14 +2,22 @@ | ||||
| from dataclasses import dataclass, field | ||||
| from datetime import datetime | ||||
| from enum import Enum | ||||
| from timeit import default_timer | ||||
| from traceback import format_tb | ||||
| from typing import Any, Optional | ||||
|  | ||||
| from celery import Task | ||||
| from django.core.cache import cache | ||||
| from prometheus_client import Gauge | ||||
|  | ||||
| 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): | ||||
|     """Possible states of tasks""" | ||||
| @ -43,7 +51,9 @@ class TaskInfo: | ||||
|     """Info about a task run""" | ||||
|  | ||||
|     task_name: str | ||||
|     finish_timestamp: datetime | ||||
|     start_timestamp: float | ||||
|     finish_timestamp: float | ||||
|     finish_time: datetime | ||||
|  | ||||
|     result: TaskResult | ||||
|  | ||||
| @ -73,12 +83,28 @@ class TaskInfo: | ||||
|         """Delete task info from cache""" | ||||
|         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): | ||||
|         """Save task into cache""" | ||||
|         key = f"task_{self.task_name}" | ||||
|         if self.result.uid: | ||||
|             key += f"_{self.result.uid}" | ||||
|             self.task_name += f"_{self.result.uid}" | ||||
|         self.set_prom_metrics() | ||||
|         cache.set(key, self, timeout=timeout_hours * 60 * 60) | ||||
|  | ||||
|  | ||||
| @ -98,6 +124,7 @@ class MonitoredTask(Task): | ||||
|         self._uid = None | ||||
|         self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[]) | ||||
|         self.result_timeout_hours = 6 | ||||
|         self.start = default_timer() | ||||
|  | ||||
|     def set_uid(self, uid: str): | ||||
|         """Set UID, so in the case of an unexpected error its saved correctly""" | ||||
| @ -117,7 +144,9 @@ class MonitoredTask(Task): | ||||
|             TaskInfo( | ||||
|                 task_name=self.__name__, | ||||
|                 task_description=self.__doc__, | ||||
|                 finish_timestamp=datetime.now(), | ||||
|                 start_timestamp=self.start, | ||||
|                 finish_timestamp=default_timer(), | ||||
|                 finish_time=datetime.now(), | ||||
|                 result=self._result, | ||||
|                 task_call_module=self.__module__, | ||||
|                 task_call_func=self.__name__, | ||||
| @ -133,7 +162,9 @@ class MonitoredTask(Task): | ||||
|         TaskInfo( | ||||
|             task_name=self.__name__, | ||||
|             task_description=self.__doc__, | ||||
|             finish_timestamp=datetime.now(), | ||||
|             start_timestamp=self.start, | ||||
|             finish_timestamp=default_timer(), | ||||
|             finish_time=datetime.now(), | ||||
|             result=self._result, | ||||
|             task_call_module=self.__module__, | ||||
|             task_call_func=self.__name__, | ||||
| @ -151,3 +182,7 @@ class MonitoredTask(Task): | ||||
|  | ||||
|     def run(self, *args, **kwargs): | ||||
|         raise NotImplementedError | ||||
|  | ||||
|  | ||||
| for task in TaskInfo.all().values(): | ||||
|     task.set_prom_metrics() | ||||
|  | ||||
							
								
								
									
										26
									
								
								authentik/events/tests/test_geoip.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								authentik/events/tests/test_geoip.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | ||||
| """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,6 +2,7 @@ | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.flows.api.stages import StageSerializer | ||||
| from authentik.flows.models import FlowStageBinding | ||||
|  | ||||
| @ -27,7 +28,7 @@ class FlowStageBindingSerializer(ModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class FlowStageBindingViewSet(ModelViewSet): | ||||
| class FlowStageBindingViewSet(UsedByMixin, ModelViewSet): | ||||
|     """FlowStageBinding Viewset""" | ||||
|  | ||||
|     queryset = FlowStageBinding.objects.all() | ||||
|  | ||||
| @ -6,10 +6,11 @@ from django.db.models import Model | ||||
| from django.http.response import HttpResponseBadRequest, JsonResponse | ||||
| from django.urls import reverse | ||||
| from django.utils.translation import gettext as _ | ||||
| from drf_yasg import openapi | ||||
| from drf_yasg.utils import no_body, swagger_auto_schema | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import BooleanField, FileField, ReadOnlyField | ||||
| from rest_framework.parsers import MultiPartParser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| @ -23,6 +24,7 @@ from rest_framework.viewsets import ModelViewSet | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| 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.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.flows.models import Flow | ||||
| @ -41,10 +43,18 @@ class FlowSerializer(ModelSerializer): | ||||
|  | ||||
|     cache_count = SerializerMethodField() | ||||
|  | ||||
|     def get_cache_count(self, flow: Flow): | ||||
|     background = ReadOnlyField(source="background_url") | ||||
|  | ||||
|     export_url = SerializerMethodField() | ||||
|  | ||||
|     def get_cache_count(self, flow: Flow) -> int: | ||||
|         """Get count of cached flows""" | ||||
|         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: | ||||
|  | ||||
|         model = Flow | ||||
| @ -60,7 +70,12 @@ class FlowSerializer(ModelSerializer): | ||||
|             "policies", | ||||
|             "cache_count", | ||||
|             "policy_engine_mode", | ||||
|             "compatibility_mode", | ||||
|             "export_url", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "background": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| class FlowDiagramSerializer(Serializer): | ||||
| @ -87,7 +102,7 @@ class DiagramElement: | ||||
|         return f"{self.identifier}=>{self.type}: {self.rest}" | ||||
|  | ||||
|  | ||||
| class FlowViewSet(ModelViewSet): | ||||
| class FlowViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Flow Viewset""" | ||||
|  | ||||
|     queryset = Flow.objects.all() | ||||
| @ -97,16 +112,19 @@ class FlowViewSet(ModelViewSet): | ||||
|     filterset_fields = ["flow_uuid", "name", "slug", "designation"] | ||||
|  | ||||
|     @permission_required(None, ["authentik_flows.view_flow_cache"]) | ||||
|     @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) | ||||
|     @extend_schema(responses={200: CacheSerializer(many=False)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def cache_info(self, request: Request) -> Response: | ||||
|         """Info about cached flows""" | ||||
|         return Response(data={"count": len(cache.keys("flow_*"))}) | ||||
|  | ||||
|     @permission_required(None, ["authentik_flows.clear_flow_cache"]) | ||||
|     @swagger_auto_schema( | ||||
|         request_body=no_body, | ||||
|         responses={204: "Successfully cleared cache", 400: "Bad request"}, | ||||
|     @extend_schema( | ||||
|         request=OpenApiTypes.NONE, | ||||
|         responses={ | ||||
|             204: OpenApiResponse(description="Successfully cleared cache"), | ||||
|             400: OpenApiResponse(description="Bad request"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=False, methods=["POST"]) | ||||
|     def cache_clear(self, request: Request) -> Response: | ||||
| @ -133,17 +151,16 @@ class FlowViewSet(ModelViewSet): | ||||
|             "authentik_stages_prompt.change_prompt", | ||||
|         ], | ||||
|     ) | ||||
|     @swagger_auto_schema( | ||||
|         request_body=no_body, | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|                 name="file", | ||||
|                 in_=openapi.IN_FORM, | ||||
|                 type=openapi.TYPE_FILE, | ||||
|                 required=True, | ||||
|     @extend_schema( | ||||
|         request={ | ||||
|             "multipart/form-data": inline_serializer( | ||||
|                 "SetIcon", fields={"file": FileField()} | ||||
|             ) | ||||
|         ], | ||||
|         responses={204: "Successfully imported flow", 400: "Bad request"}, | ||||
|         }, | ||||
|         responses={ | ||||
|             204: OpenApiResponse(description="Successfully imported flow"), | ||||
|             400: OpenApiResponse(description="Bad request"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) | ||||
|     def import_flow(self, request: Request) -> Response: | ||||
| @ -157,8 +174,8 @@ class FlowViewSet(ModelViewSet): | ||||
|             return HttpResponseBadRequest() | ||||
|         successful = importer.apply() | ||||
|         if not successful: | ||||
|             return Response(status=204) | ||||
|             return HttpResponseBadRequest() | ||||
|         return Response(status=204) | ||||
|  | ||||
|     @permission_required( | ||||
|         "authentik_flows.export_flow", | ||||
| @ -171,11 +188,9 @@ class FlowViewSet(ModelViewSet): | ||||
|             "authentik_stages_prompt.view_prompt", | ||||
|         ], | ||||
|     ) | ||||
|     @swagger_auto_schema( | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             "200": openapi.Response( | ||||
|                 "File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE) | ||||
|             ), | ||||
|             "200": OpenApiResponse(response=OpenApiTypes.BINARY), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||
| @ -188,7 +203,7 @@ class FlowViewSet(ModelViewSet): | ||||
|         response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' | ||||
|         return response | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: FlowDiagramSerializer()}) | ||||
|     @extend_schema(responses={200: FlowDiagramSerializer()}) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) | ||||
|     # pylint: disable=unused-argument | ||||
|     def diagram(self, request: Request, slug: str) -> Response: | ||||
| @ -259,17 +274,20 @@ class FlowViewSet(ModelViewSet): | ||||
|         return Response({"diagram": diagram}) | ||||
|  | ||||
|     @permission_required("authentik_flows.change_flow") | ||||
|     @swagger_auto_schema( | ||||
|         request_body=no_body, | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|                 name="file", | ||||
|                 in_=openapi.IN_FORM, | ||||
|                 type=openapi.TYPE_FILE, | ||||
|                 required=True, | ||||
|     @extend_schema( | ||||
|         request={ | ||||
|             "multipart/form-data": inline_serializer( | ||||
|                 "SetIcon", | ||||
|                 fields={ | ||||
|                     "file": FileField(required=False), | ||||
|                     "clear": BooleanField(default=False), | ||||
|                 }, | ||||
|             ) | ||||
|         ], | ||||
|         responses={200: "Success", 400: "Bad request"}, | ||||
|         }, | ||||
|         responses={ | ||||
|             200: OpenApiResponse(description="Success"), | ||||
|             400: OpenApiResponse(description="Bad request"), | ||||
|         }, | ||||
|     ) | ||||
|     @action( | ||||
|         detail=True, | ||||
| @ -281,16 +299,49 @@ class FlowViewSet(ModelViewSet): | ||||
|     # pylint: disable=unused-argument | ||||
|     def set_background(self, request: Request, slug: str): | ||||
|         """Set Flow background""" | ||||
|         app: Flow = self.get_object() | ||||
|         icon = request.FILES.get("file", None) | ||||
|         if not icon: | ||||
|         flow: Flow = self.get_object() | ||||
|         background = request.FILES.get("file", None) | ||||
|         clear = request.data.get("clear", False) | ||||
|         if clear: | ||||
|             # .delete() saves the model by default | ||||
|             flow.background.delete() | ||||
|             return Response({}) | ||||
|         if background: | ||||
|             flow.background = background | ||||
|             flow.save() | ||||
|             return Response({}) | ||||
|         return HttpResponseBadRequest() | ||||
|         app.background = icon | ||||
|         app.save() | ||||
|  | ||||
|     @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_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({}) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         responses={200: LinkSerializer(many=False), 400: "Flow not applicable"}, | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: LinkSerializer(many=False), | ||||
|             400: OpenApiResponse(description="Flow not applicable"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||
|     # pylint: disable=unused-argument | ||||
|  | ||||
| @ -1,16 +1,17 @@ | ||||
| """Flow Stage API Views""" | ||||
| from typing import Iterable | ||||
|  | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from django.urls.base import reverse | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from rest_framework import mixins | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import BooleanField | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||
| from rest_framework.viewsets import GenericViewSet | ||||
| 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.types import UserSettingSerializer | ||||
| from authentik.flows.api.flows import FlowSerializer | ||||
| @ -20,12 +21,6 @@ from authentik.lib.utils.reflection import all_subclasses | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class StageUserSettingSerializer(UserSettingSerializer): | ||||
|     """User settings but can include a configure flow""" | ||||
|  | ||||
|     configure_flow = BooleanField(required=False) | ||||
|  | ||||
|  | ||||
| class StageSerializer(ModelSerializer, MetaNameSerializer): | ||||
|     """Stage Serializer""" | ||||
|  | ||||
| @ -55,6 +50,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer): | ||||
| class StageViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     UsedByMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
| @ -68,7 +64,7 @@ class StageViewSet( | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         return Stage.objects.select_subclasses() | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def types(self, request: Request) -> Response: | ||||
|         """Get all creatable stage types""" | ||||
| @ -86,7 +82,7 @@ class StageViewSet( | ||||
|         data = sorted(data, key=lambda x: x["name"]) | ||||
|         return Response(TypeCreateSerializer(data, many=True).data) | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: StageUserSettingSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: UserSettingSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def user_settings(self, request: Request) -> Response: | ||||
|         """Get all stages the user can configure""" | ||||
| @ -97,9 +93,10 @@ class StageViewSet( | ||||
|             if not user_settings: | ||||
|                 continue | ||||
|             user_settings.initial_data["object_uid"] = str(stage.pk) | ||||
|             if hasattr(stage, "configure_flow"): | ||||
|                 user_settings.initial_data["configure_flow"] = bool( | ||||
|                     stage.configure_flow | ||||
|             if hasattr(stage, "configure_flow") and stage.configure_flow: | ||||
|                 user_settings.initial_data["configure_url"] = reverse( | ||||
|                     "authentik_flows:configure", | ||||
|                     kwargs={"stage_uuid": stage.pk}, | ||||
|                 ) | ||||
|             if not user_settings.is_valid(): | ||||
|                 LOGGER.warning(user_settings.errors) | ||||
|  | ||||
| @ -2,6 +2,9 @@ | ||||
| from importlib import import_module | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.db.utils import ProgrammingError | ||||
|  | ||||
| from authentik.lib.utils.reflection import all_subclasses | ||||
|  | ||||
|  | ||||
| class AuthentikFlowsConfig(AppConfig): | ||||
| @ -14,3 +17,10 @@ class AuthentikFlowsConfig(AppConfig): | ||||
|  | ||||
|     def ready(self): | ||||
|         import_module("authentik.flows.signals") | ||||
|         try: | ||||
|             from authentik.flows.models import Stage | ||||
|  | ||||
|             for stage in all_subclasses(Stage): | ||||
|                 _ = stage().type | ||||
|         except ProgrammingError: | ||||
|             pass | ||||
|  | ||||
| @ -28,6 +28,14 @@ class ErrorDetailSerializer(PassiveSerializer): | ||||
|     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): | ||||
|     """Challenge that gets sent to the client based on which stage | ||||
|     is currently active""" | ||||
| @ -35,9 +43,8 @@ class Challenge(PassiveSerializer): | ||||
|     type = ChoiceField( | ||||
|         choices=[(x.value, x.name) for x in ChallengeTypes], | ||||
|     ) | ||||
|     component = CharField(required=False) | ||||
|     title = CharField(required=False) | ||||
|     background = CharField(required=False) | ||||
|     flow_info = ContextualFlowInfo(required=False) | ||||
|     component = CharField(default="") | ||||
|  | ||||
|     response_errors = DictField( | ||||
|         child=ErrorDetailSerializer(many=True), allow_empty=True, required=False | ||||
| @ -48,18 +55,20 @@ class RedirectChallenge(Challenge): | ||||
|     """Challenge type to redirect the client""" | ||||
|  | ||||
|     to = CharField() | ||||
|     component = CharField(default="xak-flow-redirect") | ||||
|  | ||||
|  | ||||
| class ShellChallenge(Challenge): | ||||
|     """Legacy challenge type to render HTML as-is""" | ||||
|     """challenge type to render HTML as-is""" | ||||
|  | ||||
|     body = CharField() | ||||
|     component = CharField(default="xak-flow-shell") | ||||
|  | ||||
|  | ||||
| class WithUserInfoChallenge(Challenge): | ||||
|     """Challenge base which shows some user info""" | ||||
|  | ||||
|     pending_user = CharField() | ||||
|     pending_user = CharField(allow_blank=True) | ||||
|     pending_user_avatar = CharField() | ||||
|  | ||||
|  | ||||
| @ -67,6 +76,7 @@ class AccessDeniedChallenge(Challenge): | ||||
|     """Challenge when a flow's active stage calls `stage_invalid()`.""" | ||||
|  | ||||
|     error_message = CharField(required=False) | ||||
|     component = CharField(default="ak-stage-access-denied") | ||||
|  | ||||
|  | ||||
| class PermissionSerializer(PassiveSerializer): | ||||
| @ -80,6 +90,7 @@ class ChallengeResponse(PassiveSerializer): | ||||
|     """Base class for all challenge responses""" | ||||
|  | ||||
|     stage: Optional["StageView"] | ||||
|     component = CharField(default="xak-flow-response-default") | ||||
|  | ||||
|     def __init__(self, instance=None, data=None, **kwargs): | ||||
|         self.stage = kwargs.pop("stage", None) | ||||
|  | ||||
| @ -6,6 +6,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||
|  | ||||
| from authentik.flows.models import FlowDesignation | ||||
| from authentik.stages.identification.models import UserFields | ||||
| from authentik.stages.password import BACKEND_DJANGO, BACKEND_LDAP | ||||
|  | ||||
|  | ||||
| def create_default_authentication_flow( | ||||
| @ -31,7 +32,7 @@ def create_default_authentication_flow( | ||||
|  | ||||
|     password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create( | ||||
|         name="default-authentication-password", | ||||
|         defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]}, | ||||
|         defaults={"backends": [BACKEND_DJANGO, BACKEND_LDAP]}, | ||||
|     ) | ||||
|  | ||||
|     login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( | ||||
|  | ||||
| @ -15,9 +15,6 @@ PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently run | ||||
| # by injecting "pending_user" | ||||
| akadmin = ak_user_by(username="akadmin") | ||||
| context["pending_user"] = akadmin | ||||
| # We're also setting the backend for the user, so we can | ||||
| # directly login without having to identify again | ||||
| context["user_backend"] = "django.contrib.auth.backends.ModelBackend" | ||||
| return True""" | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										23
									
								
								authentik/flows/migrations/0019_alter_flow_background.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/flows/migrations/0019_alter_flow_background.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| # 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/", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										21
									
								
								authentik/flows/migrations/0020_flow_compatibility_mode.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								authentik/flows/migrations/0020_flow_compatibility_mode.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | ||||
| # 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): | ||||
|         if hasattr(self, "__in_memory_type"): | ||||
|             return f"In-memory Stage {getattr(self, '__in_memory_type')}" | ||||
|         return self.name | ||||
|         return f"Stage {self.name}" | ||||
|  | ||||
|  | ||||
| def in_memory_stage(view: Type["StageView"]) -> Stage: | ||||
| @ -110,11 +110,31 @@ class Flow(SerializerModel, PolicyBindingModel): | ||||
|  | ||||
|     background = models.FileField( | ||||
|         upload_to="flow-backgrounds/", | ||||
|         default="../static/dist/assets/images/flow_background.jpg", | ||||
|         blank=True, | ||||
|         default=None, | ||||
|         null=True, | ||||
|         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) | ||||
|  | ||||
|     @property | ||||
| @ -142,11 +162,6 @@ class Flow(SerializerModel, PolicyBindingModel): | ||||
|         LOGGER.debug("with_policy: no flow found", filters=flow_filter) | ||||
|         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: | ||||
|         return f"Flow {self.name} ({self.slug})" | ||||
|  | ||||
| @ -197,7 +212,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel): | ||||
|         return FlowStageBindingSerializer | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"{self.target} #{self.order}" | ||||
|         return f"Flow-stage binding #{self.order} to {self.target}" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|  | ||||
| @ -4,6 +4,7 @@ from typing import Any, Optional | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from django.http import HttpRequest | ||||
| from prometheus_client import Histogram | ||||
| from sentry_sdk.hub import Hub | ||||
| from sentry_sdk.tracing import Span | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
| @ -14,6 +15,7 @@ from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableExce | ||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||
| from authentik.flows.models import Flow, FlowStageBinding, Stage | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.root.monitoring import UpdatingGauge | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||
| @ -21,6 +23,16 @@ PLAN_CONTEXT_SSO = "is_sso" | ||||
| PLAN_CONTEXT_REDIRECT = "redirect" | ||||
| PLAN_CONTEXT_APPLICATION = "application" | ||||
| 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: | ||||
| @ -146,6 +158,7 @@ class FlowPlanner: | ||||
|             ) | ||||
|             plan = self._build_plan(user, request, default_context) | ||||
|             cache.set(cache_key(self.flow, user), plan) | ||||
|             GAUGE_FLOWS_CACHED.update() | ||||
|             if not plan.stages and not self.allow_empty_flows: | ||||
|                 raise EmptyFlowException() | ||||
|             return plan | ||||
| @ -158,7 +171,9 @@ class FlowPlanner: | ||||
|     ) -> FlowPlan: | ||||
|         """Build flow plan by checking each stage in their respective | ||||
|         order and checking the applied policies""" | ||||
|         with Hub.current.start_span(op="flow.planner.build_plan") as span: | ||||
|         with Hub.current.start_span( | ||||
|             op="flow.planner.build_plan" | ||||
|         ) as span, HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time(): | ||||
|             span: Span | ||||
|             span.set_data("flow", self.flow) | ||||
|             span.set_data("user", user) | ||||
| @ -202,6 +217,7 @@ class FlowPlanner: | ||||
|                     marker = ReevaluateMarker(binding=binding, user=user) | ||||
|                 if stage: | ||||
|                     plan.append(stage, marker) | ||||
|             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) | ||||
|         self._logger.debug( | ||||
|             "f(plan): finished building", | ||||
|         ) | ||||
|  | ||||
| @ -3,6 +3,7 @@ from django.contrib.auth.models import AnonymousUser | ||||
| from django.http import HttpRequest | ||||
| from django.http.request import QueryDict | ||||
| from django.http.response import HttpResponse | ||||
| from django.urls import reverse | ||||
| from django.views.generic.base import View | ||||
| from rest_framework.request import Request | ||||
| from structlog.stdlib import get_logger | ||||
| @ -11,6 +12,7 @@ from authentik.core.models import DEFAULT_AVATAR, User | ||||
| from authentik.flows.challenge import ( | ||||
|     Challenge, | ||||
|     ChallengeResponse, | ||||
|     ContextualFlowInfo, | ||||
|     HttpChallengeResponse, | ||||
|     WithUserInfoChallenge, | ||||
| ) | ||||
| @ -48,14 +50,17 @@ class StageView(View): | ||||
|         self.executor = executor | ||||
|         super().__init__(**kwargs) | ||||
|  | ||||
|     def get_pending_user(self) -> User: | ||||
|     def get_pending_user(self, for_display=False) -> User: | ||||
|         """Either show the matched User object or show what the user entered, | ||||
|         based on what the earlier stage (mostly IdentificationStage) set. | ||||
|         _USER_IDENTIFIER overrides the first User, as PENDING_USER is used for | ||||
|         other things besides the form display. | ||||
|  | ||||
|         If no user is pending, returns request.user""" | ||||
|         if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context: | ||||
|         if ( | ||||
|             PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context | ||||
|             and for_display | ||||
|         ): | ||||
|             return User( | ||||
|                 username=self.executor.plan.context.get( | ||||
|                     PLAN_CONTEXT_PENDING_USER_IDENTIFIER | ||||
| @ -93,15 +98,21 @@ class ChallengeStageView(StageView): | ||||
|  | ||||
|     def _get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         challenge = self.get_challenge(*args, **kwargs) | ||||
|         if "title" not in challenge.initial_data: | ||||
|             challenge.initial_data["title"] = self.executor.flow.title | ||||
|         if "background" not in challenge.initial_data: | ||||
|             challenge.initial_data["background"] = self.executor.flow.background.url | ||||
|         if "flow_info" not in challenge.initial_data: | ||||
|             flow_info = ContextualFlowInfo( | ||||
|                 data={ | ||||
|                     "title": self.executor.flow.title, | ||||
|                     "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 there's a pending user, update the `username` field | ||||
|             # this field is only used by password managers. | ||||
|             # If there's no user set, an error is raised later. | ||||
|             if user := self.get_pending_user(): | ||||
|             if user := self.get_pending_user(for_display=True): | ||||
|                 challenge.initial_data["pending_user"] = user.username | ||||
|             challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR | ||||
|             if not isinstance(user, AnonymousUser): | ||||
|  | ||||
| @ -93,7 +93,11 @@ class TestFlowExecutor(TestCase): | ||||
|             { | ||||
|                 "component": "ak-stage-access-denied", | ||||
|                 "error_message": FlowNonApplicableException.__doc__, | ||||
|                 "flow_info": { | ||||
|                     "background": flow.background_url, | ||||
|                     "cancel_url": reverse("authentik_flows:cancel"), | ||||
|                     "title": "", | ||||
|                 }, | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
|             }, | ||||
|         ) | ||||
| @ -289,7 +293,11 @@ class TestFlowExecutor(TestCase): | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             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): | ||||
| @ -366,7 +374,11 @@ class TestFlowExecutor(TestCase): | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             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): | ||||
| @ -414,10 +426,13 @@ class TestFlowExecutor(TestCase): | ||||
|             self.assertJSONEqual( | ||||
|                 force_str(response.content), | ||||
|                 { | ||||
|                     "background": flow.background.url, | ||||
|                     "type": ChallengeTypes.NATIVE.value, | ||||
|                     "component": "ak-stage-dummy", | ||||
|                     "title": binding.stage.name, | ||||
|                     "flow_info": { | ||||
|                         "background": flow.background_url, | ||||
|                         "cancel_url": reverse("authentik_flows:cancel"), | ||||
|                         "title": "", | ||||
|                     }, | ||||
|                 }, | ||||
|             ) | ||||
|  | ||||
| @ -445,10 +460,13 @@ class TestFlowExecutor(TestCase): | ||||
|         self.assertJSONEqual( | ||||
|             force_str(response.content), | ||||
|             { | ||||
|                 "background": flow.background.url, | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
|                 "component": "ak-stage-dummy", | ||||
|                 "title": binding4.stage.name, | ||||
|                 "flow_info": { | ||||
|                     "background": flow.background_url, | ||||
|                     "cancel_url": reverse("authentik_flows:cancel"), | ||||
|                     "title": "", | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @ -458,7 +476,11 @@ class TestFlowExecutor(TestCase): | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertJSONEqual( | ||||
|             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): | ||||
| @ -489,4 +511,4 @@ class TestFlowExecutor(TestCase): | ||||
|         executor.flow = flow | ||||
|  | ||||
|         stage_view = StageView(executor) | ||||
|         self.assertEqual(ident, stage_view.get_pending_user().username) | ||||
|         self.assertEqual(ident, stage_view.get_pending_user(for_display=True).username) | ||||
|  | ||||
| @ -1,6 +1,5 @@ | ||||
| """flow urls""" | ||||
| from django.urls import path | ||||
| from django.views.generic import RedirectView | ||||
|  | ||||
| from authentik.flows.models import FlowDesignation | ||||
| from authentik.flows.views import CancelView, ConfigureFlowInitView, ToDefaultFlow | ||||
| @ -16,30 +15,10 @@ urlpatterns = [ | ||||
|         ToDefaultFlow.as_view(designation=FlowDesignation.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( | ||||
|         "-/configure/<uuid:stage_uuid>/", | ||||
|         ConfigureFlowInitView.as_view(), | ||||
|         name="configure", | ||||
|     ), | ||||
|     path( | ||||
|         "<slug:flow_slug>/", | ||||
|         RedirectView.as_view(pattern_name="authentik_core:if-flow"), | ||||
|         name="flow-executor-shell", | ||||
|     ), | ||||
| ] | ||||
|  | ||||
| @ -2,16 +2,23 @@ | ||||
| from traceback import format_tb | ||||
| from typing import Any, Optional | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.mixins import LoginRequiredMixin | ||||
| from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | ||||
| from django.http.request import QueryDict | ||||
| from django.shortcuts import get_object_or_404, redirect | ||||
| from django.template.response import TemplateResponse | ||||
| from django.urls.base import reverse | ||||
| from django.utils.decorators import method_decorator | ||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||
| from django.views.generic import View | ||||
| from drf_yasg import openapi | ||||
| from drf_yasg.utils import no_body, swagger_auto_schema | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import ( | ||||
|     OpenApiParameter, | ||||
|     OpenApiResponse, | ||||
|     PolymorphicProxySerializer, | ||||
|     extend_schema, | ||||
| ) | ||||
| from rest_framework.permissions import AllowAny | ||||
| from rest_framework.views import APIView | ||||
| from sentry_sdk import capture_exception | ||||
| @ -27,6 +34,7 @@ from authentik.flows.challenge import ( | ||||
|     HttpChallengeResponse, | ||||
|     RedirectChallenge, | ||||
|     ShellChallenge, | ||||
|     WithUserInfoChallenge, | ||||
| ) | ||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||
| from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | ||||
| @ -36,8 +44,9 @@ from authentik.flows.planner import ( | ||||
|     FlowPlan, | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.lib.utils.reflection import all_subclasses, class_to_path | ||||
| from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||
| from authentik.tenants.models import Tenant | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| # Argument used to redirect user after login | ||||
| @ -47,6 +56,43 @@ SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre" | ||||
| 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") | ||||
| class FlowExecutorView(APIView): | ||||
|     """Stage 1 Flow executor, passing requests to Stage Views""" | ||||
| @ -125,19 +171,25 @@ class FlowExecutorView(APIView): | ||||
|         self.current_stage_view.request = request | ||||
|         return super().dispatch(request) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: Challenge(), | ||||
|             404: "No Token found",  # This error can be raised by the email stage | ||||
|             200: PolymorphicProxySerializer( | ||||
|                 component_name="FlowChallengeRequest", | ||||
|                 serializers=challenge_types(), | ||||
|                 resource_type_field_name="component", | ||||
|             ), | ||||
|             404: OpenApiResponse( | ||||
|                 description="No Token found" | ||||
|             ),  # This error can be raised by the email stage | ||||
|         }, | ||||
|         request_body=no_body, | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|                 "query", | ||||
|                 openapi.IN_QUERY, | ||||
|         request=OpenApiTypes.NONE, | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="query", | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 required=True, | ||||
|                 description="Querystring as received", | ||||
|                 type=openapi.TYPE_STRING, | ||||
|                 type=OpenApiTypes.STR, | ||||
|             ) | ||||
|         ], | ||||
|         operation_id="flows_executor_get", | ||||
| @ -153,20 +205,32 @@ class FlowExecutorView(APIView): | ||||
|             stage_response = self.current_stage_view.get(request, *args, **kwargs) | ||||
|             return to_stage_response(request, stage_response) | ||||
|         except Exception as exc:  # pylint: disable=broad-except | ||||
|             if settings.DEBUG or settings.TEST: | ||||
|                 raise exc | ||||
|             capture_exception(exc) | ||||
|             self._logger.warning(exc) | ||||
|             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||
|  | ||||
|     @swagger_auto_schema( | ||||
|         responses={200: Challenge()}, | ||||
|         request_body=ChallengeResponse(), | ||||
|         manual_parameters=[ | ||||
|             openapi.Parameter( | ||||
|                 "query", | ||||
|                 openapi.IN_QUERY, | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|             200: PolymorphicProxySerializer( | ||||
|                 component_name="FlowChallengeRequest", | ||||
|                 serializers=challenge_types(), | ||||
|                 resource_type_field_name="component", | ||||
|             ), | ||||
|         }, | ||||
|         request=PolymorphicProxySerializer( | ||||
|             component_name="FlowChallengeResponse", | ||||
|             serializers=challenge_response_types(), | ||||
|             resource_type_field_name="component", | ||||
|         ), | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="query", | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 required=True, | ||||
|                 description="Querystring as received", | ||||
|                 type=openapi.TYPE_STRING, | ||||
|                 type=OpenApiTypes.STR, | ||||
|             ) | ||||
|         ], | ||||
|         operation_id="flows_executor_solve", | ||||
| @ -182,6 +246,8 @@ class FlowExecutorView(APIView): | ||||
|             stage_response = self.current_stage_view.post(request, *args, **kwargs) | ||||
|             return to_stage_response(request, stage_response) | ||||
|         except Exception as exc:  # pylint: disable=broad-except | ||||
|             if settings.DEBUG or settings.TEST: | ||||
|                 raise exc | ||||
|             capture_exception(exc) | ||||
|             self._logger.warning(exc) | ||||
|             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||
| @ -218,7 +284,7 @@ class FlowExecutorView(APIView): | ||||
|         if self.plan.stages: | ||||
|             self._logger.debug( | ||||
|                 "f(exec): Continuing with next stage", | ||||
|                 reamining=len(self.plan.stages), | ||||
|                 remaining=len(self.plan.stages), | ||||
|             ) | ||||
|             kwargs = self.kwargs | ||||
|             kwargs.update({"flow_slug": self.flow.slug}) | ||||
| @ -244,9 +310,13 @@ class FlowExecutorView(APIView): | ||||
|             AccessDeniedChallenge( | ||||
|                 { | ||||
|                     "error_message": error_message, | ||||
|                     "title": self.flow.title, | ||||
|                     "type": ChallengeTypes.NATIVE.value, | ||||
|                     "component": "ak-stage-access-denied", | ||||
|                     "flow_info": { | ||||
|                         "title": self.flow.title, | ||||
|                         "background": self.flow.background_url, | ||||
|                         "cancel_url": reverse("authentik_flows:cancel"), | ||||
|                     }, | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
| @ -307,7 +377,17 @@ class ToDefaultFlow(View): | ||||
|     designation: Optional[FlowDesignation] = None | ||||
|  | ||||
|     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) | ||||
|         # If we still don't have a flow, 404 | ||||
|         if not flow: | ||||
|             raise Http404 | ||||
|         # If user already has a pending plan, clear it so we don't have to later. | ||||
| @ -338,7 +418,10 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons | ||||
|         ) | ||||
|         return HttpChallengeResponse( | ||||
|             RedirectChallenge( | ||||
|                 {"type": ChallengeTypes.REDIRECT, "to": str(redirect_url)} | ||||
|                 { | ||||
|                     "type": ChallengeTypes.REDIRECT, | ||||
|                     "to": str(redirect_url), | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
|     if isinstance(source, TemplateResponse): | ||||
| @ -350,7 +433,7 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
|     # Check for actual HttpResponse (without isinstance as we dont want to check inheritance) | ||||
|     # Check for actual HttpResponse (without isinstance as we don't want to check inheritance) | ||||
|     if source.__class__ == HttpResponse: | ||||
|         return HttpChallengeResponse( | ||||
|             ShellChallenge( | ||||
|  | ||||
| @ -10,9 +10,6 @@ from urllib.parse import urlparse | ||||
|  | ||||
| import yaml | ||||
| from django.conf import ImproperlyConfigured | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| from authentik import __version__ | ||||
|  | ||||
| SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob( | ||||
|     "/etc/authentik/config.d/*.yml", recursive=True | ||||
| @ -21,11 +18,6 @@ ENV_PREFIX = "AUTHENTIK" | ||||
| ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") | ||||
|  | ||||
|  | ||||
| def context_processor(request: HttpRequest) -> dict[str, Any]: | ||||
|     """Context Processor that injects config object into every template""" | ||||
|     return {"config": CONFIG.raw, "ak_version": __version__} | ||||
|  | ||||
|  | ||||
| class ConfigLoader: | ||||
|     """Search through SEARCH_PATHS and load configuration. Environment variables starting with | ||||
|     `ENV_PREFIX` are also applied. | ||||
|  | ||||
| @ -47,10 +47,7 @@ outposts: | ||||
|  | ||||
| authentik: | ||||
|   avatars: gravatar  # gravatar or none | ||||
|   geoip: "" | ||||
|   branding: | ||||
|     title: authentik | ||||
|     logo: /static/dist/assets/icons/icon_left_brand.svg | ||||
|   geoip: "./GeoLite2-City.mmdb" | ||||
|   # Optionally add links to the footer on the login page | ||||
|   footer_links: | ||||
|     - name: Documentation | ||||
|  | ||||
| @ -26,8 +26,8 @@ class BaseEvaluator: | ||||
|     _filename: str | ||||
|  | ||||
|     def __init__(self): | ||||
|         # update authentik/policies/expression/templates/policy/expression/form.html | ||||
|         # update website/docs/policies/expression.md | ||||
|         # update website/docs/expressions/_objects.md | ||||
|         # update website/docs/expressions/_functions.md | ||||
|         self._globals = { | ||||
|             "regex_match": BaseEvaluator.expr_filter_regex_match, | ||||
|             "regex_replace": BaseEvaluator.expr_filter_regex_replace, | ||||
|  | ||||
| @ -94,6 +94,13 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | ||||
|         if isinstance(exc_value, ignored_classes): | ||||
|             return None | ||||
|     if "logger" in event: | ||||
|         if event["logger"] in ["dbbackup", "botocore"]: | ||||
|         if event["logger"] in [ | ||||
|             "dbbackup", | ||||
|             "botocore", | ||||
|             "kombu", | ||||
|             "asyncio", | ||||
|             "multiprocessing", | ||||
|             "django_redis", | ||||
|         ]: | ||||
|             return None | ||||
|     return event | ||||
|  | ||||
| @ -5,9 +5,10 @@ from django.http import HttpRequest | ||||
|  | ||||
| OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | ||||
| USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | ||||
| DEFAULT_IP = "255.255.255.255" | ||||
|  | ||||
|  | ||||
| def _get_client_ip_from_meta(meta: dict[str, Any]) -> Optional[str]: | ||||
| def _get_client_ip_from_meta(meta: dict[str, Any]) -> str: | ||||
|     """Attempt to get the client's IP by checking common HTTP Headers. | ||||
|     Returns none if no IP Could be found""" | ||||
|     headers = ( | ||||
| @ -19,7 +20,7 @@ def _get_client_ip_from_meta(meta: dict[str, Any]) -> Optional[str]: | ||||
|         if _header in meta: | ||||
|             ips: list[str] = meta.get(_header).split(",") | ||||
|             return ips[0].strip() | ||||
|     return None | ||||
|     return DEFAULT_IP | ||||
|  | ||||
|  | ||||
| def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: | ||||
| @ -37,7 +38,7 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: | ||||
|     return request.META[OUTPOST_REMOTE_IP_HEADER] | ||||
|  | ||||
|  | ||||
| def get_client_ip(request: Optional[HttpRequest]) -> Optional[str]: | ||||
| def get_client_ip(request: Optional[HttpRequest]) -> str: | ||||
|     """Attempt to get the client's IP by checking common HTTP Headers. | ||||
|     Returns none if no IP Could be found""" | ||||
|     if request: | ||||
| @ -45,4 +46,4 @@ def get_client_ip(request: Optional[HttpRequest]) -> Optional[str]: | ||||
|         if override: | ||||
|             return override | ||||
|         return _get_client_ip_from_meta(request.META) | ||||
|     return None | ||||
|     return DEFAULT_IP | ||||
|  | ||||
| @ -6,7 +6,7 @@ class AuthentikManagedConfig(AppConfig): | ||||
|     """authentik Managed app""" | ||||
|  | ||||
|     name = "authentik.managed" | ||||
|     label = "authentik_Managed" | ||||
|     label = "authentik_managed" | ||||
|     verbose_name = "authentik Managed" | ||||
|  | ||||
|     def ready(self) -> None: | ||||
|  | ||||
| @ -1,24 +1,60 @@ | ||||
| """Outpost API Views""" | ||||
| from dacite.core import from_dict | ||||
| from dacite.exceptions import DaciteError | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import BooleanField, CharField, DateTimeField | ||||
| from rest_framework.relations import PrimaryKeyRelatedField | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import JSONField, ModelSerializer, ValidationError | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.providers import ProviderSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import PassiveSerializer, is_dict | ||||
| from authentik.outposts.models import Outpost, OutpostConfig, default_outpost_config | ||||
| from authentik.core.models import Provider | ||||
| from authentik.outposts.api.service_connections import ServiceConnectionSerializer | ||||
| from authentik.outposts.models import ( | ||||
|     Outpost, | ||||
|     OutpostConfig, | ||||
|     OutpostType, | ||||
|     default_outpost_config, | ||||
| ) | ||||
| from authentik.providers.ldap.models import LDAPProvider | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
|  | ||||
|  | ||||
| class OutpostSerializer(ModelSerializer): | ||||
|     """Outpost Serializer""" | ||||
|  | ||||
|     config = JSONField(validators=[is_dict], source="_config") | ||||
|     providers = PrimaryKeyRelatedField( | ||||
|         allow_empty=False, | ||||
|         many=True, | ||||
|         queryset=Provider.objects.select_subclasses().all(), | ||||
|     ) | ||||
|     providers_obj = ProviderSerializer(source="providers", many=True, read_only=True) | ||||
|     service_connection_obj = ServiceConnectionSerializer( | ||||
|         source="service_connection", read_only=True | ||||
|     ) | ||||
|  | ||||
|     def validate_providers(self, providers: list[Provider]) -> list[Provider]: | ||||
|         """Check that all providers match the type of the outpost""" | ||||
|         type_map = { | ||||
|             OutpostType.LDAP: LDAPProvider, | ||||
|             OutpostType.PROXY: ProxyProvider, | ||||
|             None: Provider, | ||||
|         } | ||||
|         for provider in providers: | ||||
|             if not isinstance(provider, type_map[self.initial_data.get("type")]): | ||||
|                 raise ValidationError( | ||||
|                     ( | ||||
|                         f"Outpost type {self.initial_data['type']} can't be used with " | ||||
|                         f"{type(provider)} providers." | ||||
|                     ) | ||||
|                 ) | ||||
|         return providers | ||||
|  | ||||
|     def validate_config(self, config) -> dict: | ||||
|         """Check that the config has all required fields""" | ||||
| @ -38,9 +74,11 @@ class OutpostSerializer(ModelSerializer): | ||||
|             "providers", | ||||
|             "providers_obj", | ||||
|             "service_connection", | ||||
|             "service_connection_obj", | ||||
|             "token_identifier", | ||||
|             "config", | ||||
|         ] | ||||
|         extra_kwargs = {"type": {"required": True}} | ||||
|  | ||||
|  | ||||
| class OutpostDefaultConfigSerializer(PassiveSerializer): | ||||
| @ -58,7 +96,7 @@ class OutpostHealthSerializer(PassiveSerializer): | ||||
|     version_outdated = BooleanField(read_only=True) | ||||
|  | ||||
|  | ||||
| class OutpostViewSet(ModelViewSet): | ||||
| class OutpostViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Outpost Viewset""" | ||||
|  | ||||
|     queryset = Outpost.objects.all() | ||||
| @ -70,10 +108,10 @@ class OutpostViewSet(ModelViewSet): | ||||
|         "name", | ||||
|         "providers__name", | ||||
|     ] | ||||
|     ordering = ["name"] | ||||
|     ordering = ["name", "service_connection__name"] | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: OutpostHealthSerializer(many=True)}) | ||||
|     @action(methods=["GET"], detail=True) | ||||
|     @extend_schema(responses={200: OutpostHealthSerializer(many=True)}) | ||||
|     @action(methods=["GET"], detail=True, pagination_class=None) | ||||
|     # pylint: disable=invalid-name, unused-argument | ||||
|     def health(self, request: Request, pk: int) -> Response: | ||||
|         """Get outposts current health""" | ||||
| @ -90,7 +128,7 @@ class OutpostViewSet(ModelViewSet): | ||||
|             ) | ||||
|         return Response(OutpostHealthSerializer(states, many=True).data) | ||||
|  | ||||
|     @swagger_auto_schema(responses={200: OutpostDefaultConfigSerializer(many=False)}) | ||||
|     @extend_schema(responses={200: OutpostDefaultConfigSerializer(many=False)}) | ||||
|     @action(detail=False, methods=["GET"]) | ||||
|     def default_settings(self, request: Request) -> Response: | ||||
|         """Global default outpost config""" | ||||
|  | ||||
| @ -2,18 +2,19 @@ | ||||
| from dataclasses import asdict | ||||
| 
 | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from drf_yasg.utils import swagger_auto_schema | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from kubernetes.client.configuration import Configuration | ||||
| from kubernetes.config.config_exception import ConfigException | ||||
| from kubernetes.config.kube_config import load_kube_config_from_dict | ||||
| from rest_framework import mixins, serializers | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import BooleanField, CharField, SerializerMethodField | ||||
| from rest_framework.fields import BooleanField, CharField, ReadOnlyField | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||
| 
 | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import ( | ||||
|     MetaNameSerializer, | ||||
|     PassiveSerializer, | ||||
| @ -30,11 +31,7 @@ from authentik.outposts.models import ( | ||||
| class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): | ||||
|     """ServiceConnection Serializer""" | ||||
| 
 | ||||
|     component = SerializerMethodField() | ||||
| 
 | ||||
|     def get_component(self, obj: OutpostServiceConnection) -> str: | ||||
|         """Get object component so that we know how to edit the object""" | ||||
|         return obj.component | ||||
|     component = ReadOnlyField() | ||||
| 
 | ||||
|     class Meta: | ||||
| 
 | ||||
| @ -59,6 +56,7 @@ class ServiceConnectionStateSerializer(PassiveSerializer): | ||||
| class ServiceConnectionViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     UsedByMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
| @ -69,7 +67,7 @@ class ServiceConnectionViewSet( | ||||
|     search_fields = ["name"] | ||||
|     filterset_fields = ["name"] | ||||
| 
 | ||||
|     @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def types(self, request: Request) -> Response: | ||||
|         """Get all creatable service connection types""" | ||||
| @ -87,7 +85,7 @@ class ServiceConnectionViewSet( | ||||
|             ) | ||||
|         return Response(TypeCreateSerializer(data, many=True).data) | ||||
| 
 | ||||
|     @swagger_auto_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) | ||||
|     @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) | ||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||
|     # pylint: disable=unused-argument, invalid-name | ||||
|     def state(self, request: Request, pk: str) -> Response: | ||||
| @ -109,7 +107,7 @@ class DockerServiceConnectionSerializer(ServiceConnectionSerializer): | ||||
|         ] | ||||
| 
 | ||||
| 
 | ||||
| class DockerServiceConnectionViewSet(ModelViewSet): | ||||
| class DockerServiceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||
|     """DockerServiceConnection Viewset""" | ||||
| 
 | ||||
|     queryset = DockerServiceConnection.objects.all() | ||||
| @ -122,7 +120,7 @@ class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): | ||||
|     def validate_kubeconfig(self, kubeconfig): | ||||
|         """Validate kubeconfig by attempting to load it""" | ||||
|         if kubeconfig == {}: | ||||
|             if not self.validated_data["local"]: | ||||
|             if not self.initial_data["local"]: | ||||
|                 raise serializers.ValidationError( | ||||
|                     _( | ||||
|                         "You can only use an empty kubeconfig when connecting to a local cluster." | ||||
| @ -143,7 +141,7 @@ class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): | ||||
|         fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig"] | ||||
| 
 | ||||
| 
 | ||||
| class KubernetesServiceConnectionViewSet(ModelViewSet): | ||||
| class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||
|     """KubernetesServiceConnection Viewset""" | ||||
| 
 | ||||
|     queryset = KubernetesServiceConnection.objects.all() | ||||
| @ -8,11 +8,21 @@ from channels.exceptions import DenyConnection | ||||
| from dacite import from_dict | ||||
| from dacite.data import Data | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from prometheus_client import Gauge | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.channels import AuthJsonConsumer | ||||
| from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | ||||
|  | ||||
| GAUGE_OUTPOSTS_CONNECTED = Gauge( | ||||
|     "authentik_outposts_connected", "Currently connected outposts", ["outpost", "uid"] | ||||
| ) | ||||
| GAUGE_OUTPOSTS_LAST_UPDATE = Gauge( | ||||
|     "authentik_outposts_last_update", | ||||
|     "Last update from any outpost", | ||||
|     ["outpost", "uid", "version"], | ||||
| ) | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @ -40,10 +50,12 @@ class WebsocketMessage: | ||||
| class OutpostConsumer(AuthJsonConsumer): | ||||
|     """Handler for Outposts that connect over websockets for health checks and live updates""" | ||||
|  | ||||
|     outpost: Outpost | ||||
|     outpost: Optional[Outpost] = None | ||||
|  | ||||
|     last_uid: Optional[str] = None | ||||
|  | ||||
|     first_msg = False | ||||
|  | ||||
|     def connect(self): | ||||
|         super().connect() | ||||
|         uuid = self.scope["url_route"]["kwargs"]["pk"] | ||||
| @ -68,6 +80,10 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
|             if self.channel_name in state.channel_ids: | ||||
|                 state.channel_ids.remove(self.channel_name) | ||||
|                 state.save() | ||||
|             GAUGE_OUTPOSTS_CONNECTED.labels( | ||||
|                 outpost=self.outpost.name, | ||||
|                 uid=self.last_uid, | ||||
|             ).dec() | ||||
|         LOGGER.debug( | ||||
|             "removed outpost instance from cache", | ||||
|             outpost=self.outpost, | ||||
| @ -78,15 +94,32 @@ class OutpostConsumer(AuthJsonConsumer): | ||||
|         msg = from_dict(WebsocketMessage, content) | ||||
|         uid = msg.args.get("uuid", self.channel_name) | ||||
|         self.last_uid = uid | ||||
|  | ||||
|         if not self.outpost: | ||||
|             raise DenyConnection() | ||||
|  | ||||
|         state = OutpostState.for_instance_uid(self.outpost, uid) | ||||
|         if self.channel_name not in state.channel_ids: | ||||
|             state.channel_ids.append(self.channel_name) | ||||
|         state.last_seen = datetime.now() | ||||
|  | ||||
|         if not self.first_msg: | ||||
|             GAUGE_OUTPOSTS_CONNECTED.labels( | ||||
|                 outpost=self.outpost.name, | ||||
|                 uid=self.last_uid, | ||||
|             ).inc() | ||||
|             self.first_msg = True | ||||
|  | ||||
|         if msg.instruction == WebsocketMessageInstruction.HELLO: | ||||
|             state.version = msg.args.get("version", None) | ||||
|             state.build_hash = msg.args.get("buildHash", "") | ||||
|         elif msg.instruction == WebsocketMessageInstruction.ACK: | ||||
|             return | ||||
|         GAUGE_OUTPOSTS_LAST_UPDATE.labels( | ||||
|             outpost=self.outpost.name, | ||||
|             uid=self.last_uid or "", | ||||
|             version=state.version or "", | ||||
|         ).set_to_current_time() | ||||
|         state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) | ||||
|  | ||||
|         response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) | ||||
|  | ||||
| @ -71,6 +71,7 @@ class DockerController(BaseController): | ||||
|                 }, | ||||
|                 "environment": self._get_env(), | ||||
|                 "labels": self._get_labels(), | ||||
|                 "restart_policy": {"Name": "unless-stopped"}, | ||||
|             } | ||||
|             if settings.TEST: | ||||
|                 del container_args["ports"] | ||||
| @ -80,27 +81,39 @@ class DockerController(BaseController): | ||||
|                 True, | ||||
|             ) | ||||
|  | ||||
|     # pylint: disable=too-many-return-statements | ||||
|     def up(self): | ||||
|         try: | ||||
|             container, has_been_created = self._get_container() | ||||
|             if has_been_created: | ||||
|                 return None | ||||
|             # Check if the container is out of date, delete it and retry | ||||
|             if len(container.image.tags) > 0: | ||||
|                 tag: str = container.image.tags[0] | ||||
|                 _, _, version = tag.partition(":") | ||||
|                 if version != __version__: | ||||
|                 if tag != self.get_container_image(): | ||||
|                     self.logger.info( | ||||
|                         "Container has mismatched version, re-creating...", | ||||
|                         has=version, | ||||
|                         should=__version__, | ||||
|                         "Container has mismatched image, re-creating...", | ||||
|                         has=tag, | ||||
|                         should=self.get_container_image(), | ||||
|                     ) | ||||
|                     container.kill() | ||||
|                     container.remove(force=True) | ||||
|                     self.down() | ||||
|                     return self.up() | ||||
|             # Check that container values match our values | ||||
|             if self._comp_env(container): | ||||
|                 self.logger.info("Container has outdated config, re-creating...") | ||||
|                 container.kill() | ||||
|                 container.remove(force=True) | ||||
|                 self.down() | ||||
|                 return self.up() | ||||
|             if ( | ||||
|                 container.attrs.get("HostConfig", {}) | ||||
|                 .get("RestartPolicy", {}) | ||||
|                 .get("Name", "") | ||||
|                 .lower() | ||||
|                 != "unless-stopped" | ||||
|             ): | ||||
|                 self.logger.info( | ||||
|                     "Container has mis-matched restart policy, re-creating..." | ||||
|                 ) | ||||
|                 self.down() | ||||
|                 return self.up() | ||||
|             # Check that container is healthy | ||||
|             if ( | ||||
| @ -127,16 +140,16 @@ class DockerController(BaseController): | ||||
|                 return None | ||||
|             return None | ||||
|         except DockerException as exc: | ||||
|             raise ControllerException from exc | ||||
|             raise ControllerException(str(exc)) from exc | ||||
|  | ||||
|     def down(self): | ||||
|         try: | ||||
|             container, _ = self._get_container() | ||||
|             if container.status == "running": | ||||
|                 container.kill() | ||||
|             container.remove() | ||||
|             container.remove(force=True) | ||||
|         except DockerException as exc: | ||||
|             raise ControllerException from exc | ||||
|             raise ControllerException(str(exc)) from exc | ||||
|  | ||||
|     def get_static_deployment(self) -> str: | ||||
|         """Generate docker-compose yaml for proxy, version 3.5""" | ||||
|  | ||||
| @ -376,16 +376,24 @@ class Outpost(models.Model): | ||||
|     @property | ||||
|     def token(self) -> Token: | ||||
|         """Get/create token for auto-generated user""" | ||||
|         token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API) | ||||
|         if token.exists(): | ||||
|             return token.first() | ||||
|         managed = f"goauthentik.io/outpost/{self.token_identifier}" | ||||
|         tokens = Token.filter_not_expired( | ||||
|             identifier=self.token_identifier, | ||||
|             intent=TokenIntents.INTENT_API, | ||||
|         ) | ||||
|         if tokens.exists(): | ||||
|             token = tokens.first() | ||||
|             if not token.managed: | ||||
|                 token.managed = managed | ||||
|                 token.save() | ||||
|             return token | ||||
|         return Token.objects.create( | ||||
|             user=self.user, | ||||
|             identifier=self.token_identifier, | ||||
|             intent=TokenIntents.INTENT_API, | ||||
|             description=f"Autogenerated by authentik for Outpost {self.name}", | ||||
|             expiring=False, | ||||
|             managed=f"goauthentik.io/outpost/{self.token_identifier}", | ||||
|             managed=managed, | ||||
|         ) | ||||
|  | ||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||
|  | ||||
| @ -65,6 +65,8 @@ def outpost_service_connection_state(connection_pk: Any): | ||||
|         .select_subclasses() | ||||
|         .first() | ||||
|     ) | ||||
|     if not connection: | ||||
|         return | ||||
|     state = connection.fetch_state() | ||||
|     cache.set(connection.state_key, state, timeout=None) | ||||
|  | ||||
| @ -147,8 +149,9 @@ def outpost_post_save(model_class: str, model_pk: Any): | ||||
|         return | ||||
|  | ||||
|     if isinstance(instance, Outpost): | ||||
|         LOGGER.debug("Ensuring token for outpost", instance=instance) | ||||
|         LOGGER.debug("Ensuring token and permissions for outpost", instance=instance) | ||||
|         _ = instance.token | ||||
|         _ = instance.user | ||||
|         LOGGER.debug("Trigger reconcile for outpost") | ||||
|         outpost_controller.delay(instance.pk) | ||||
|  | ||||
| @ -199,6 +202,7 @@ def _outpost_single_update(outpost: Outpost, layer=None): | ||||
|     # Ensure token again, because this function is called when anything related to an | ||||
|     # OutpostModel is saved, so we can be sure permissions are right | ||||
|     _ = outpost.token | ||||
|     _ = outpost.user | ||||
|     if not layer:  # pragma: no cover | ||||
|         layer = get_channel_layer() | ||||
|     for state in OutpostState.for_outpost(outpost): | ||||
|  | ||||
| @ -5,7 +5,8 @@ from rest_framework.test import APITestCase | ||||
| from authentik.core.models import PropertyMapping, User | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.outposts.api.outposts import OutpostSerializer | ||||
| from authentik.outposts.models import default_outpost_config | ||||
| from authentik.outposts.models import OutpostType, default_outpost_config | ||||
| from authentik.providers.ldap.models import LDAPProvider | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
|  | ||||
|  | ||||
| @ -20,6 +21,36 @@ class TestOutpostServiceConnectionsAPI(APITestCase): | ||||
|         self.user = User.objects.get(username="akadmin") | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|     def test_outpost_validaton(self): | ||||
|         """Test Outpost validation""" | ||||
|         valid = OutpostSerializer( | ||||
|             data={ | ||||
|                 "name": "foo", | ||||
|                 "type": OutpostType.PROXY, | ||||
|                 "config": default_outpost_config(), | ||||
|                 "providers": [ | ||||
|                     ProxyProvider.objects.create( | ||||
|                         name="test", authorization_flow=Flow.objects.first() | ||||
|                     ).pk | ||||
|                 ], | ||||
|             } | ||||
|         ) | ||||
|         self.assertTrue(valid.is_valid()) | ||||
|         invalid = OutpostSerializer( | ||||
|             data={ | ||||
|                 "name": "foo", | ||||
|                 "type": OutpostType.PROXY, | ||||
|                 "config": default_outpost_config(), | ||||
|                 "providers": [ | ||||
|                     LDAPProvider.objects.create( | ||||
|                         name="test", authorization_flow=Flow.objects.first() | ||||
|                     ).pk | ||||
|                 ], | ||||
|             } | ||||
|         ) | ||||
|         self.assertFalse(invalid.is_valid()) | ||||
|         self.assertIn("providers", invalid.errors) | ||||
|  | ||||
|     def test_types(self): | ||||
|         """Test OutpostServiceConnections's types endpoint""" | ||||
|         response = self.client.get( | ||||
| @ -42,6 +73,7 @@ class TestOutpostServiceConnectionsAPI(APITestCase): | ||||
|                 "name": "foo", | ||||
|                 "providers": [provider.pk], | ||||
|                 "config": default_outpost_config("foo"), | ||||
|                 "type": OutpostType.PROXY, | ||||
|             } | ||||
|         ) | ||||
|         self.assertTrue(valid.is_valid()) | ||||
|  | ||||
| @ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.api.groups import GroupSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.users import UserSerializer | ||||
| from authentik.policies.api.policies import PolicySerializer | ||||
| from authentik.policies.models import PolicyBinding, PolicyBindingModel | ||||
| @ -75,6 +76,7 @@ class PolicyBindingSerializer(ModelSerializer): | ||||
|             "group_obj", | ||||
|             "user_obj", | ||||
|             "target", | ||||
|             "negate", | ||||
|             "enabled", | ||||
|             "order", | ||||
|             "timeout", | ||||
| @ -98,7 +100,7 @@ class PolicyBindingSerializer(ModelSerializer): | ||||
|         return data | ||||
|  | ||||
|  | ||||
| class PolicyBindingViewSet(ModelViewSet): | ||||
| class PolicyBindingViewSet(UsedByMixin, ModelViewSet): | ||||
|     """PolicyBinding Viewset""" | ||||
|  | ||||
|     queryset = ( | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	