Compare commits
	
		
			378 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2d5c45543b | |||
| 9b57f0b81d | |||
| 9d476a42d1 | |||
| 2c816e6162 | |||
| 934cfa483c | |||
| 50308510b4 | |||
| dbcb4d46ba | |||
| bb89b9b572 | |||
| 6600da7d98 | |||
| 1a0f72d0a8 | |||
| a265dd54cc | |||
| a603f42cc0 | |||
| d9a788aac8 | |||
| 7c6185b581 | |||
| 41a1305555 | |||
| 75f252b530 | |||
| c526e5fb9a | |||
| 7aa903d715 | |||
| b826eb264e | |||
| a9519a4a68 | |||
| a4960064c9 | |||
| 94bddb9886 | |||
| f38702f361 | |||
| c49fac39b1 | |||
| b3390f0ab4 | |||
| 7666c246c3 | |||
| bf4cbb25fe | |||
| a925418f60 | |||
| ffd61d0e60 | |||
| 13cc33c39c | |||
| 71d112bdcf | |||
| c58fe18b97 | |||
| d2c06c40ea | |||
| 590c7f4c9d | |||
| 9a48c2fd9a | |||
| be5a6c0310 | |||
| 92106ca4bf | |||
| 56f1204c9b | |||
| f6f93640c5 | |||
| b8c76eaf1c | |||
| 9dbbd4eff6 | |||
| 2908be5272 | |||
| 349a5b2d00 | |||
| 63e3667e82 | |||
| 92f2a82c03 | |||
| dcf074650e | |||
| 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] | [bumpversion] | ||||||
| current_version = 2021.5.2 | current_version = 2021.6.1-rc4 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||||
| @ -31,8 +31,6 @@ values = | |||||||
|  |  | ||||||
| [bumpversion:file:web/src/constants.ts] | [bumpversion:file:web/src/constants.ts] | ||||||
|  |  | ||||||
| [bumpversion:file:web/nginx.conf] |  | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md] | [bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md] | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md] | [bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md] | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
										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: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - name: Set up QEMU |       - 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 |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v1 |         uses: docker/setup-buildx-action@v1 | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
| @ -28,17 +28,14 @@ jobs: | |||||||
|           registry: ghcr.io |           registry: ghcr.io | ||||||
|           username: ${{ github.repository_owner }} |           username: ${{ github.repository_owner }} | ||||||
|           password: ${{ secrets.GITHUB_TOKEN }} |           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 |       - name: Building Docker Image | ||||||
|         uses: docker/build-push-action@v2 |         uses: docker/build-push-action@v2 | ||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik:2021.5.2, |             beryju/authentik:2021.6.1-rc4, | ||||||
|             beryju/authentik:latest, |             beryju/authentik:latest, | ||||||
|             ghcr.io/goauthentik/server:2021.5.2, |             ghcr.io/goauthentik/server:2021.6.1-rc4, | ||||||
|             ghcr.io/goauthentik/server:latest |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
| @ -49,14 +46,8 @@ jobs: | |||||||
|       - uses: actions/setup-go@v2 |       - uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.15" |           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 |       - 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 |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v1 |         uses: docker/setup-buildx-action@v1 | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
| @ -75,11 +66,10 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-proxy:2021.5.2, |             beryju/authentik-proxy:2021.6.1-rc4, | ||||||
|             beryju/authentik-proxy:latest, |             beryju/authentik-proxy:latest, | ||||||
|             ghcr.io/goauthentik/proxy:2021.5.2, |             ghcr.io/goauthentik/proxy:2021.6.1-rc4, | ||||||
|             ghcr.io/goauthentik/proxy:latest |             ghcr.io/goauthentik/proxy:latest | ||||||
|           context: outpost/ |  | ||||||
|           file: outpost/proxy.Dockerfile |           file: outpost/proxy.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|   build-ldap: |   build-ldap: | ||||||
| @ -89,14 +79,8 @@ jobs: | |||||||
|       - uses: actions/setup-go@v2 |       - uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.15" |           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 |       - 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 |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v1 |         uses: docker/setup-buildx-action@v1 | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
| @ -115,11 +99,10 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-ldap:2021.5.2, |             beryju/authentik-ldap:2021.6.1-rc4, | ||||||
|             beryju/authentik-ldap:latest, |             beryju/authentik-ldap:latest, | ||||||
|             ghcr.io/goauthentik/ldap:2021.5.2, |             ghcr.io/goauthentik/ldap:2021.6.1-rc4, | ||||||
|             ghcr.io/goauthentik/ldap:latest |             ghcr.io/goauthentik/ldap:latest | ||||||
|           context: outpost/ |  | ||||||
|           file: outpost/ldap.Dockerfile |           file: outpost/ldap.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|   test-release: |   test-release: | ||||||
| @ -139,7 +122,7 @@ jobs: | |||||||
|           docker-compose pull -q |           docker-compose pull -q | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root --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: |   sentry-release: | ||||||
|     if: ${{ github.event_name == 'release' }} |     if: ${{ github.event_name == 'release' }} | ||||||
|     needs: |     needs: | ||||||
| @ -155,5 +138,5 @@ jobs: | |||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2021.5.2 |           version: authentik@2021.6.1-rc4 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,9 +11,6 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - 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 |       - name: Pre-release test | ||||||
|         run: | |         run: | | ||||||
|           sudo apt-get install -y pwgen |           sudo apt-get install -y pwgen | ||||||
| @ -23,11 +20,11 @@ jobs: | |||||||
|           docker-compose pull -q |           docker-compose pull -q | ||||||
|           docker build \ |           docker build \ | ||||||
|             --no-cache \ |             --no-cache \ | ||||||
|             -t beryju/authentik:latest \ |             -t ghcr.io/goauthentik/server:latest \ | ||||||
|             -f Dockerfile . |             -f Dockerfile . | ||||||
|           docker-compose up --no-start |           docker-compose up --no-start | ||||||
|           docker-compose start postgresql redis |           docker-compose start postgresql redis | ||||||
|           docker-compose run -u root --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 |       - name: Extract version number | ||||||
|         id: get_version |         id: get_version | ||||||
|         uses: actions/github-script@v4.0.2 |         uses: actions/github-script@v4.0.2 | ||||||
|  | |||||||
							
								
								
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -8,18 +8,30 @@ WORKDIR /app/ | |||||||
|  |  | ||||||
| RUN pip install pipenv && \ | RUN pip install pipenv && \ | ||||||
|     pipenv lock -r > requirements.txt && \ |     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 | FROM node as npm-builder | ||||||
|  |  | ||||||
| COPY ./web /static/ | COPY ./web /static/ | ||||||
|  | COPY --from=api-builder /local/web/api /static/api | ||||||
|  |  | ||||||
| ENV NODE_ENV=production | 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 | # Stage 4: Build go proxy | ||||||
| FROM golang:1.16.4 AS builder | FROM golang:1.16.5 AS builder | ||||||
|  |  | ||||||
| WORKDIR /work | 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/dist/ /work/web/dist/ | ||||||
| COPY --from=npm-builder /static/authentik/ /work/web/authentik/ | COPY --from=npm-builder /static/authentik/ /work/web/authentik/ | ||||||
|  |  | ||||||
| # RUN ls /work/web/static/authentik/ && exit 1 |  | ||||||
| COPY ./cmd /work/cmd | COPY ./cmd /work/cmd | ||||||
| COPY ./web/static.go /work/web/static.go | COPY ./web/static.go /work/web/static.go | ||||||
| COPY ./internal /work/internal | COPY ./internal /work/internal | ||||||
| @ -37,7 +48,7 @@ COPY ./go.sum /work/go.sum | |||||||
|  |  | ||||||
| RUN go build -o /work/authentik ./cmd/server/main.go | RUN go build -o /work/authentik ./cmd/server/main.go | ||||||
|  |  | ||||||
| # Stage 4: Run | # Stage 5: Run | ||||||
| FROM python:3.9-slim-buster | FROM python:3.9-slim-buster | ||||||
|  |  | ||||||
| WORKDIR / | WORKDIR / | ||||||
| @ -65,6 +76,7 @@ RUN apt-get update && \ | |||||||
| COPY ./authentik/ /authentik | COPY ./authentik/ /authentik | ||||||
| COPY ./pyproject.toml / | COPY ./pyproject.toml / | ||||||
| COPY ./xml /xml | COPY ./xml /xml | ||||||
|  | COPY ./tests /tests | ||||||
| COPY ./manage.py / | COPY ./manage.py / | ||||||
| COPY ./lifecycle/ /lifecycle | COPY ./lifecycle/ /lifecycle | ||||||
| COPY --from=builder /work/authentik /authentik-proxy | COPY --from=builder /work/authentik /authentik-proxy | ||||||
|  | |||||||
							
								
								
									
										31
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,5 +1,7 @@ | |||||||
| .SHELLFLAGS += -x -e | .SHELLFLAGS += -x -e | ||||||
| PWD = $(shell pwd) | PWD = $(shell pwd) | ||||||
|  | UID = $(shell id -u) | ||||||
|  | GID = $(shell id -g) | ||||||
|  |  | ||||||
| all: lint-fix lint test gen | all: lint-fix lint test gen | ||||||
|  |  | ||||||
| @ -25,16 +27,39 @@ lint: | |||||||
| 	bandit -r authentik tests lifecycle -x node_modules | 	bandit -r authentik tests lifecycle -x node_modules | ||||||
| 	pylint authentik tests lifecycle | 	pylint authentik tests lifecycle | ||||||
|  |  | ||||||
| gen: | gen-build: | ||||||
| 	./manage.py generate_swagger -o swagger.yaml -f yaml | 	./manage.py spectacular --file schema.yml | ||||||
|  |  | ||||||
|  | gen-clean: | ||||||
|  | 	rm -rf web/api/src/ | ||||||
|  | 	rm -rf outpost/api/ | ||||||
|  |  | ||||||
|  | gen-web: | ||||||
| 	docker run \ | 	docker run \ | ||||||
| 		--rm -v ${PWD}:/local \ | 		--rm -v ${PWD}:/local \ | ||||||
|  | 		--user ${UID}:${GID} \ | ||||||
| 		openapitools/openapi-generator-cli generate \ | 		openapitools/openapi-generator-cli generate \ | ||||||
| 		-i /local/swagger.yaml \ | 		-i /local/schema.yml \ | ||||||
| 		-g typescript-fetch \ | 		-g typescript-fetch \ | ||||||
| 		-o /local/web/api \ | 		-o /local/web/api \ | ||||||
| 		--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | 		--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0 | ||||||
| 	cd web/api && npx tsc | 	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: | run: | ||||||
| 	go run -v cmd/server/main.go | 	go run -v cmd/server/main.go | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								Pipfile
									
									
									
									
									
								
							| @ -22,7 +22,7 @@ django-storages = "*" | |||||||
| djangorestframework = "*" | djangorestframework = "*" | ||||||
| djangorestframework-guardian = "*" | djangorestframework-guardian = "*" | ||||||
| docker = "*" | docker = "*" | ||||||
| drf_yasg = "*" | drf-spectacular = "*" | ||||||
| facebook-sdk = "*" | facebook-sdk = "*" | ||||||
| geoip2 = "*" | geoip2 = "*" | ||||||
| gunicorn = "*" | gunicorn = "*" | ||||||
| @ -44,6 +44,8 @@ urllib3 = {extras = ["secure"],version = "*"} | |||||||
| uvicorn = {extras = ["standard"],version = "*"} | uvicorn = {extras = ["standard"],version = "*"} | ||||||
| webauthn = "*" | webauthn = "*" | ||||||
| xmlsec = "*" | xmlsec = "*" | ||||||
|  | duo-client = "*" | ||||||
|  | ua-parser = "*" | ||||||
|  |  | ||||||
| [requires] | [requires] | ||||||
| python_version = "3.9" | python_version = "3.9" | ||||||
|  | |||||||
							
								
								
									
										403
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										403
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "8a32708c1c04f8da03c817df973de28c37c97ee773f571ce0b3f3f834e1b7094" |             "sha256": "4fa1ad681762c867a95410074f31ac5d00119e187e0f38982cd59fdf301cccf5" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": { |         "requires": { | ||||||
| @ -116,18 +116,17 @@ | |||||||
|         }, |         }, | ||||||
|         "boto3": { |         "boto3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:13cfe0e3ae1bdc7baf4272b1814a7e760fbb508b19d6ac3f472a6bbd64baad61", |                 "sha256:4cfab400cd9ca9b27b7dffb43f5675525ea5d36c81223d64d15542fdb16cdf7e", | ||||||
|                 "sha256:ce08b88a2d7a0ad8edb385f84ea4914296fee6813c66ebf0def956d5278de793" |                 "sha256:b0808a58c54c595b6cc6271cbc14a09bb89f0951ca9e8b105d1e94bef3ed24a0" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.17.73" |             "version": "==1.17.91" | ||||||
|         }, |         }, | ||||||
|         "botocore": { |         "botocore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:4b4aa58c61d4b125bc6ec1597924b2749e19de8f2c9a374ac087aa2561e71828", |                 "sha256:462e75419e6537efb2709b7eb5b8c7ade007d30209416f0476bd7c51a2ddc78d" | ||||||
|                 "sha256:69dc0b6fdc0855f5a4f8b1d29c96b9cec44e71054fea0f968e5904d6ccfd4fd9" |  | ||||||
|             ], |             ], | ||||||
|             "version": "==1.20.73" |             "version": "==1.20.91" | ||||||
|         }, |         }, | ||||||
|         "cachetools": { |         "cachetools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -138,36 +137,57 @@ | |||||||
|         }, |         }, | ||||||
|         "cbor2": { |         "cbor2": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:a33aa2e5534fd74401ac95686886e655e3b2ce6383b3f958199b6e70a87c94bf" |                 "sha256:059363ae716c60f6ba29aa61b3d9c57896189c351c4119095f0542aec169e4dc", | ||||||
|  |                 "sha256:0b80a4a4fca830af3d3cf36b725c31f0a98106e9c2b02004ab73b0ec7f139446", | ||||||
|  |                 "sha256:0d22b47fb24b384200277fcfb0582c3a3551c413ad51f3bd3ee334caaf79a483", | ||||||
|  |                 "sha256:3c586a6e328ba5020802346f5e0304f81b982dcafeb51ee4109c9be9cccbc4a0", | ||||||
|  |                 "sha256:4dd142764607b1a8b5e3e3b474d2b84099e9cbb323596a15ee8db0d78901d95f", | ||||||
|  |                 "sha256:6f8a7911c2307ee8f8d4940bdcfb8bd21608f14203a83b651fcd7868bce377a5", | ||||||
|  |                 "sha256:7ecc4e9c548282a5d296d4535244efa69c7f67cda959f28e14929cf1d6af8a97", | ||||||
|  |                 "sha256:8bc9f5054650d05e6d3e90f6490dcd6ef6c01ad9c1568958a48dde2702824cb1", | ||||||
|  |                 "sha256:98410520482796a547af2d5ffe11a8a2dc3b9f2124834fa7c12db8264935ed61", | ||||||
|  |                 "sha256:a7926f7244b08c413f1a4fa71a81aa256771c75bdf1a4fd77308547a2d63dd48", | ||||||
|  |                 "sha256:ae31d3b5966807fdff6c9e6f894b0aa10474295d9ff8467a8b978a569c8fec47", | ||||||
|  |                 "sha256:ce6219986385778b1ab7f9b542f160bb4d3558f52975e914a27b774e47016fb7", | ||||||
|  |                 "sha256:d562b2773e14ee1d65ea5b85351a83a64d4f3fd011bc2b4c70a6e813e78203ce" | ||||||
|             ], |             ], | ||||||
|             "version": "==5.2.0" |             "version": "==5.4.0" | ||||||
|         }, |         }, | ||||||
|         "celery": { |         "celery": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:5e8d364e058554e83bbb116e8377d90c79be254785f357cb2cec026e79febe13", |                 "sha256:1329de1edeaf734ef859e630cb42df2c116d53e59d2f46433b13aed196e85620", | ||||||
|                 "sha256:f4efebe6f8629b0da2b8e529424de376494f5b7a743c321c8a2ddc2b1414921c" |                 "sha256:65f061c04578cf189cd7352c192e1a79fdeb370b916bff792bcc769560e81184" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==5.0.5" |             "version": "==5.1.0" | ||||||
|         }, |         }, | ||||||
|         "certifi": { |         "certifi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", |                 "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", | ||||||
|                 "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" |                 "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" | ||||||
|             ], |             ], | ||||||
|             "version": "==2020.12.5" |             "version": "==2021.5.30" | ||||||
|         }, |         }, | ||||||
|         "cffi": { |         "cffi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", |                 "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", | ||||||
|  |                 "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", | ||||||
|  |                 "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", | ||||||
|  |                 "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", | ||||||
|                 "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", |                 "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", | ||||||
|  |                 "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", | ||||||
|                 "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", |                 "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", | ||||||
|                 "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", |                 "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", | ||||||
|  |                 "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", | ||||||
|                 "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", |                 "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", | ||||||
|  |                 "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", | ||||||
|  |                 "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", | ||||||
|                 "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", |                 "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", | ||||||
|                 "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", |                 "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", | ||||||
|  |                 "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", | ||||||
|                 "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", |                 "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", | ||||||
|                 "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", |                 "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", | ||||||
|  |                 "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", | ||||||
|                 "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", |                 "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", | ||||||
|                 "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", |                 "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", | ||||||
|                 "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", |                 "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", | ||||||
| @ -175,6 +195,7 @@ | |||||||
|                 "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", |                 "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", | ||||||
|                 "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", |                 "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", | ||||||
|                 "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", |                 "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", | ||||||
|  |                 "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", | ||||||
|                 "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", |                 "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", | ||||||
|                 "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", |                 "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", | ||||||
|                 "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", |                 "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", | ||||||
| @ -192,8 +213,10 @@ | |||||||
|                 "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", |                 "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", | ||||||
|                 "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", |                 "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", | ||||||
|                 "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", |                 "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", | ||||||
|  |                 "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", | ||||||
|                 "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", |                 "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", | ||||||
|                 "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", |                 "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", | ||||||
|  |                 "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", | ||||||
|                 "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", |                 "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", | ||||||
|                 "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" |                 "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" | ||||||
|             ], |             ], | ||||||
| @ -244,10 +267,10 @@ | |||||||
|         }, |         }, | ||||||
|         "click-repl": { |         "click-repl": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:9c4c3d022789cae912aad8a3f5e1d7c2cdd016ee1225b5212ad3e8691563cda5", |                 "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b", | ||||||
|                 "sha256:b9f29d52abc4d6059f8e276132a111ab8d94980afe6a5432b9d996544afa95d5" |                 "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.1.6" |             "version": "==0.2.0" | ||||||
|         }, |         }, | ||||||
|         "constantly": { |         "constantly": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -256,20 +279,6 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==15.1.0" |             "version": "==15.1.0" | ||||||
|         }, |         }, | ||||||
|         "coreapi": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", |  | ||||||
|                 "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3" |  | ||||||
|             ], |  | ||||||
|             "version": "==2.3.3" |  | ||||||
|         }, |  | ||||||
|         "coreschema": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f", |  | ||||||
|                 "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607" |  | ||||||
|             ], |  | ||||||
|             "version": "==0.0.4" |  | ||||||
|         }, |  | ||||||
|         "cryptography": { |         "cryptography": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", |                 "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", | ||||||
| @ -312,11 +321,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django": { |         "django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:13ac78dbfd189532cad8f383a27e58e18b3d33f80009ceb476d7fcbfc5dcebd8", |                 "sha256:66c9d8db8cc6fe938a28b7887c1596e42d522e27618562517cc8929eb7e7f296", | ||||||
|                 "sha256:7e0a1393d18c16b503663752a8b6790880c5084412618990ce8a81cc908b4962" |                 "sha256:ea735cbbbb3b2fba6d4da4784a0043d84c67c92f1fdf15ad6db69900e792c10f" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==3.2.3" |             "version": "==3.2.4" | ||||||
|         }, |         }, | ||||||
|         "django-dbbackup": { |         "django-dbbackup": { | ||||||
|             "git": "https://github.com/django-dbbackup/django-dbbackup.git", |             "git": "https://github.com/django-dbbackup/django-dbbackup.git", | ||||||
| @ -332,11 +341,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django-guardian": { |         "django-guardian": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0e70706c6cda88ddaf8849bddb525b8df49de05ba0798d4b3506049f0d95cbc8", |                 "sha256:440ca61358427e575323648b25f8384739e54c38b3d655c81d75e0cd0d61b697", | ||||||
|                 "sha256:ed2de26e4defb800919c5749fb1bbe370d72829fbd72895b6cf4f7f1a7607e1b" |                 "sha256:c58a68ae76922d33e6bdc0e69af1892097838de56e93e78a8361090bcd9f89a0" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==2.3.0" |             "version": "==2.4.0" | ||||||
|         }, |         }, | ||||||
|         "django-model-utils": { |         "django-model-utils": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -348,11 +357,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django-otp": { |         "django-otp": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:75a815747a0542cc5442e3a6396dfd272c49a0866bee2149ac57ecc36ddd3961", |                 "sha256:01b5888f0bde5125e139433aacb947e52d5c406fa56c9db43c3e8d75b5c323c4", | ||||||
|                 "sha256:cc657a0e7266cda6ab42f861bdc3840ed24f7e441bc7f249916174dd1a6375a0" |                 "sha256:0d56dd2a7fbb6ee6e54557e036ca64add0bd3596f471794bad673b7637d5e935" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.0.5" |             "version": "==1.0.6" | ||||||
|         }, |         }, | ||||||
|         "django-prometheus": { |         "django-prometheus": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -364,11 +373,11 @@ | |||||||
|         }, |         }, | ||||||
|         "django-redis": { |         "django-redis": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:1133b26b75baa3664164c3f44b9d5d133d1b8de45d94d79f38d1adc5b1d502e5", |                 "sha256:048f665bbe27f8ff2edebae6aa9c534ab137f1e8fa7234147ef470df3f3aa9b8", | ||||||
|                 "sha256:306589c7021e6468b2656edc89f62b8ba67e8d5a1c8877e2688042263daa7a63" |                 "sha256:97739ca9de3f964c51412d1d7d8aecdfd86737bb197fce6e1ff12620c63c97ee" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==4.12.1" |             "version": "==5.0.0" | ||||||
|         }, |         }, | ||||||
|         "django-storages": { |         "django-storages": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -402,13 +411,21 @@ | |||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==5.0.0" |             "version": "==5.0.0" | ||||||
|         }, |         }, | ||||||
|         "drf-yasg": { |         "drf-spectacular": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:8b72e5b1875931a8d11af407be3a9a5ba8776541492947a0df5bafda6b7f8267", |                 "sha256:4d35e890b8139e1c056588c5529a2f2066615635482563f0840b96d3b879d7d2", | ||||||
|                 "sha256:d50f197c7f02545d0b736df88c6d5cf874f8fea2507ad85ad7de6ae5bf2d9e5a" |                 "sha256:f552476dfde647963c21615249672e7f4f9ece3788036b5ee5c6cc5ad50748ab" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.20.0" |             "version": "==0.17.0" | ||||||
|  |         }, | ||||||
|  |         "duo-client": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:038c40c86615b2c176252ec32888898807861371536ac29a39a707c71dd0e693", | ||||||
|  |                 "sha256:652548002767d27a5eaaa700b312205dfc1c252033bb0ab7f98d1d3961bceba7" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==4.3.2" | ||||||
|         }, |         }, | ||||||
|         "facebook-sdk": { |         "facebook-sdk": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -434,10 +451,10 @@ | |||||||
|         }, |         }, | ||||||
|         "google-auth": { |         "google-auth": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f", |                 "sha256:9b235dbc876e49454cbedc52ae0abd540ef705ebccdf4fbe93553bb13f26b1a4", | ||||||
|                 "sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206" |                 "sha256:eb017521276a75492282c6ca4b718f26de112ed3bcbeaeeb02c1b82de425f909" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.30.0" |             "version": "==1.30.2" | ||||||
|         }, |         }, | ||||||
|         "gunicorn": { |         "gunicorn": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -502,23 +519,23 @@ | |||||||
|         }, |         }, | ||||||
|         "httptools": { |         "httptools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:07659649fe6b3948b6490825f89abe5eb1cec79ebfaaa0b4bf30f3f33f3c2ba8", |                 "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb", | ||||||
|                 "sha256:08b79e09114e6ab5c3dbf560bba2cb2257ea38cdaeaf99b7cb80d8f92622fcd9", |                 "sha256:200fc1cdf733a9ff554c0bb97a4047785cfaad9875307d6087001db3eb2b417f", | ||||||
|                 "sha256:1e35aa179b67086cc600a984924a88589b90793c9c1b260152ca4908786e09df", |                 "sha256:3ab1f390d8867f74b3b5ee2a7ecc9b8d7f53750bd45714bf1cb72a953d7dfa77", | ||||||
|                 "sha256:31629e1f1b89959f8c0927bad12184dc07977dcf71e24f4772934aa490aa199b", |                 "sha256:78d03dd39b09c99ec917d50189e6743adbfd18c15d5944392d2eabda688bf149", | ||||||
|                 "sha256:851026bd63ec0af7e7592890d97d15c92b62d9e17094353f19a52c8e2b33710a", |                 "sha256:79dbc21f3612a78b28384e989b21872e2e3cf3968532601544696e4ed0007ce5", | ||||||
|                 "sha256:8fcca4b7efe353b13a24017211334c57d055a6e132c7adffed13a10d28efca57", |                 "sha256:80ffa04fe8c8dfacf6e4cef8277347d35b0442c581f5814f3b0cf41b65c43c6e", | ||||||
|                 "sha256:9abd788465aa46a0f288bd3a99e53edd184177d6379e2098fd6097bb359ad9d6", |                 "sha256:813871f961edea6cb2fe312f2d9b27d12a51ba92545380126f80d0de1917ea15", | ||||||
|                 "sha256:aebdf0bd7bf7c90ae6b3be458692bf6e9e5b610b501f9f74c7979015a51db4c4", |                 "sha256:94505026be56652d7a530ab03d89474dc6021019d6b8682281977163b3471ea0", | ||||||
|                 "sha256:bda99a5723e7eab355ce57435c70853fc137a65aebf2f1cd4d15d96e2956da7b", |                 "sha256:a23166e5ae2775709cf4f7ad4c2048755ebfb272767d244e1a96d55ac775cca7", | ||||||
|                 "sha256:c1c63d860749841024951b0a78e4dec6f543d23751ef061d6ab60064c7b8b524", |                 "sha256:a289c27ccae399a70eacf32df9a44059ca2ba4ac444604b00a19a6c1f0809943", | ||||||
|                 "sha256:c4111a0a8a00eff1e495d43ea5230aaf64968a48ddba8ea2d5f982efae827404", |                 "sha256:a7594f9a010cdf1e16a58b3bf26c9da39bbf663e3b8d46d39176999d71816658", | ||||||
|                 "sha256:dce59ee45dd6ee6c434346a5ac527c44014326f560866b4b2f414a692ee1aca8", |                 "sha256:b08d00d889a118f68f37f3c43e359aab24ee29eb2e3fe96d64c6a2ba8b9d6557", | ||||||
|                 "sha256:f759717ca1b2ef498c67ba4169c2b33eecf943a89f5329abcff8b89d153eb500", |                 "sha256:cc9be041e428c10f8b6ab358c6b393648f9457094e1dcc11b4906026d43cd380", | ||||||
|                 "sha256:fb7199b8fb0c50a22e77260bb59017e0c075fa80cb03bb2c8692de76e7bb7fe7", |                 "sha256:d5682eeb10cca0606c4a8286a3391d4c3c5a36f0c448e71b8bd05be4e1694bfb", | ||||||
|                 "sha256:fbf7ecd31c39728f251b1c095fd27c84e4d21f60a1d079a0333472ff3ae59d34" |                 "sha256:fd3b8905e21431ad306eeaf56644a68fdd621bf8f3097eff54d0f6bdf7262065" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.1.2" |             "version": "==0.2.0" | ||||||
|         }, |         }, | ||||||
|         "hyperlink": { |         "hyperlink": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -548,20 +565,6 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==0.5.1" |             "version": "==0.5.1" | ||||||
|         }, |         }, | ||||||
|         "itypes": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", |  | ||||||
|                 "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1" |  | ||||||
|             ], |  | ||||||
|             "version": "==1.2.0" |  | ||||||
|         }, |  | ||||||
|         "jinja2": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:2f2de5285cf37f33d33ecd4a9080b75c87cd0c1994d5a9c6df17131ea1f049c6", |  | ||||||
|                 "sha256:ea8d7dd814ce9df6de6a761ec7f1cac98afe305b8cdc4aaae4e114b8d8ce24c5" |  | ||||||
|             ], |  | ||||||
|             "version": "==3.0.0" |  | ||||||
|         }, |  | ||||||
|         "jmespath": { |         "jmespath": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", |                 "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", | ||||||
| @ -578,10 +581,10 @@ | |||||||
|         }, |         }, | ||||||
|         "kombu": { |         "kombu": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006", |                 "sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d", | ||||||
|                 "sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c" |                 "sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a" | ||||||
|             ], |             ], | ||||||
|             "version": "==5.0.2" |             "version": "==5.1.0" | ||||||
|         }, |         }, | ||||||
|         "kubernetes": { |         "kubernetes": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -651,45 +654,6 @@ | |||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==4.6.3" |             "version": "==4.6.3" | ||||||
|         }, |         }, | ||||||
|         "markupsafe": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:007dc055dbce5b1104876acee177dbfd18757e19d562cd440182e1f492e96b95", |  | ||||||
|                 "sha256:031bf79a27d1c42f69c276d6221172417b47cb4b31cdc73d362a9bf5a1889b9f", |  | ||||||
|                 "sha256:161d575fa49395860b75da5135162481768b11208490d5a2143ae6785123e77d", |  | ||||||
|                 "sha256:24bbc3507fb6dfff663af7900a631f2aca90d5a445f272db5fc84999fa5718bc", |  | ||||||
|                 "sha256:2efaeb1baff547063bad2b2893a8f5e9c459c4624e1a96644bbba08910ae34e0", |  | ||||||
|                 "sha256:32200f562daaab472921a11cbb63780f1654552ae49518196fc361ed8e12e901", |  | ||||||
|                 "sha256:3261fae28155e5c8634dd7710635fe540a05b58f160cef7713c7700cb9980e66", |  | ||||||
|                 "sha256:3b54a9c68995ef4164567e2cd1a5e16db5dac30b2a50c39c82db8d4afaf14f63", |  | ||||||
|                 "sha256:3c352ff634e289061711608f5e474ec38dbaa21e3e168820d53d5f4015e5b91b", |  | ||||||
|                 "sha256:3fb47f97f1d338b943126e90b79cad50d4fcfa0b80637b5a9f468941dbbd9ce5", |  | ||||||
|                 "sha256:441ce2a8c17683d97e06447fcbccbdb057cbf587c78eb75ae43ea7858042fe2c", |  | ||||||
|                 "sha256:45535241baa0fc0ba2a43961a1ac7562ca3257f46c4c3e9c0de38b722be41bd1", |  | ||||||
|                 "sha256:4aca81a687975b35e3e80bcf9aa93fe10cd57fac37bf18b2314c186095f57e05", |  | ||||||
|                 "sha256:4cc563836f13c57f1473bc02d1e01fc37bab70ad4ee6be297d58c1d66bc819bf", |  | ||||||
|                 "sha256:4fae0677f712ee090721d8b17f412f1cbceefbf0dc180fe91bab3232f38b4527", |  | ||||||
|                 "sha256:58bc9fce3e1557d463ef5cee05391a05745fd95ed660f23c1742c711712c0abb", |  | ||||||
|                 "sha256:664832fb88b8162268928df233f4b12a144a0c78b01d38b81bdcf0fc96668ecb", |  | ||||||
|                 "sha256:70820a1c96311e02449591cbdf5cd1c6a34d5194d5b55094ab725364375c9eb2", |  | ||||||
|                 "sha256:79b2ae94fa991be023832e6bcc00f41dbc8e5fe9d997a02db965831402551730", |  | ||||||
|                 "sha256:83cf0228b2f694dcdba1374d5312f2277269d798e65f40344964f642935feac1", |  | ||||||
|                 "sha256:87de598edfa2230ff274c4de7fcf24c73ffd96208c8e1912d5d0fee459767d75", |  | ||||||
|                 "sha256:8f806bfd0f218477d7c46a11d3e52dc7f5fdfaa981b18202b7dc84bbc287463b", |  | ||||||
|                 "sha256:90053234a6479738fd40d155268af631c7fca33365f964f2208867da1349294b", |  | ||||||
|                 "sha256:a00dce2d96587651ef4fa192c17e039e8cfab63087c67e7d263a5533c7dad715", |  | ||||||
|                 "sha256:a08cd07d3c3c17cd33d9e66ea9dee8f8fc1c48e2d11bd88fd2dc515a602c709b", |  | ||||||
|                 "sha256:a19d39b02a24d3082856a5b06490b714a9d4179321225bbf22809ff1e1887cc8", |  | ||||||
|                 "sha256:d00a669e4a5bec3ee6dbeeeedd82a405ced19f8aeefb109a012ea88a45afff96", |  | ||||||
|                 "sha256:dab0c685f21f4a6c95bfc2afd1e7eae0033b403dd3d8c1b6d13a652ada75b348", |  | ||||||
|                 "sha256:df561f65049ed3556e5b52541669310e88713fdae2934845ec3606f283337958", |  | ||||||
|                 "sha256:e4570d16f88c7f3032ed909dc9e905a17da14a1c4cfd92608e3fda4cb1208bbd", |  | ||||||
|                 "sha256:e77e4b983e2441aff0c0d07ee711110c106b625f440292dfe02a2f60c8218bd6", |  | ||||||
|                 "sha256:e79212d09fc0e224d20b43ad44bb0a0a3416d1e04cf6b45fed265114a5d43d20", |  | ||||||
|                 "sha256:f58b5ba13a5689ca8317b98439fccfbcc673acaaf8241c1869ceea40f5d585bf", |  | ||||||
|                 "sha256:fef86115fdad7ae774720d7103aa776144cf9b66673b4afa9bcaa7af990ed07b" |  | ||||||
|             ], |  | ||||||
|             "version": "==2.0.0" |  | ||||||
|         }, |  | ||||||
|         "maxminddb": { |         "maxminddb": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" |                 "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" | ||||||
| @ -773,10 +737,10 @@ | |||||||
|         }, |         }, | ||||||
|         "oauthlib": { |         "oauthlib": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", |                 "sha256:42bf6354c2ed8c6acb54d971fce6f88193d97297e18602a3a886603f9d7730cc", | ||||||
|                 "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" |                 "sha256:8f0215fcc533dd8dd1bee6f4c412d4f0cd7297307d43ac61666389e3bc3198a3" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.1.0" |             "version": "==3.1.1" | ||||||
|         }, |         }, | ||||||
|         "packaging": { |         "packaging": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -788,10 +752,10 @@ | |||||||
|         }, |         }, | ||||||
|         "prometheus-client": { |         "prometheus-client": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:030e4f9df5f53db2292eec37c6255957eb76168c6f974e4176c711cf91ed34aa", |                 "sha256:3a8baade6cb80bcfe43297e33e7623f3118d660d41387593758e2fb1ea173a86", | ||||||
|                 "sha256:b6c5a9643e3545bcbfd9451766cbaa5d9c67e7303c7bc32c750b6fa70ecb107d" |                 "sha256:b014bc76815eb1399da8ce5fc84b7717a3e63652b0c0f8804092c9363acab1b2" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.10.1" |             "version": "==0.11.0" | ||||||
|         }, |         }, | ||||||
|         "prompt-toolkit": { |         "prompt-toolkit": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1019,50 +983,6 @@ | |||||||
|             "markers": "python_version >= '3.6'", |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==4.7.2" |             "version": "==4.7.2" | ||||||
|         }, |         }, | ||||||
|         "ruamel.yaml": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:44bc6b54fddd45e4bc0619059196679f9e8b79c027f4131bb072e6a22f4d5e28", |  | ||||||
|                 "sha256:ac79fb25f5476e8e9ed1c53b8a2286d2c3f5dde49eb37dbcee5c7eb6a8415a22" |  | ||||||
|             ], |  | ||||||
|             "version": "==0.17.4" |  | ||||||
|         }, |  | ||||||
|         "ruamel.yaml.clib": { |  | ||||||
|             "hashes": [ |  | ||||||
|                 "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b", |  | ||||||
|                 "sha256:1236df55e0f73cd138c0eca074ee086136c3f16a97c2ac719032c050f7e0622f", |  | ||||||
|                 "sha256:1f8c0a4577c0e6c99d208de5c4d3fd8aceed9574bb154d7a2b21c16bb924154c", |  | ||||||
|                 "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91", |  | ||||||
|                 "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc", |  | ||||||
|                 "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7", |  | ||||||
|                 "sha256:2fd336a5c6415c82e2deb40d08c222087febe0aebe520f4d21910629018ab0f3", |  | ||||||
|                 "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7", |  | ||||||
|                 "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6", |  | ||||||
|                 "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6", |  | ||||||
|                 "sha256:46d6d20815064e8bb023ea8628cfb7402c0f0e83de2c2227a88097e239a7dffd", |  | ||||||
|                 "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0", |  | ||||||
|                 "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62", |  | ||||||
|                 "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99", |  | ||||||
|                 "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5", |  | ||||||
|                 "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026", |  | ||||||
|                 "sha256:6c0a5dc52fc74eb87c67374a4e554d4761fd42a4d01390b7e868b30d21f4b8bb", |  | ||||||
|                 "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2", |  | ||||||
|                 "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1", |  | ||||||
|                 "sha256:75f0ee6839532e52a3a53f80ce64925ed4aed697dd3fa890c4c918f3304bd4f4", |  | ||||||
|                 "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b", |  | ||||||
|                 "sha256:8be05be57dc5c7b4a0b24edcaa2f7275866d9c907725226cdde46da09367d923", |  | ||||||
|                 "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e", |  | ||||||
|                 "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c", |  | ||||||
|                 "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988", |  | ||||||
|                 "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f", |  | ||||||
|                 "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5", |  | ||||||
|                 "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a", |  | ||||||
|                 "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1", |  | ||||||
|                 "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2", |  | ||||||
|                 "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f" |  | ||||||
|             ], |  | ||||||
|             "markers": "platform_python_implementation == 'CPython' and python_version < '3.10'", |  | ||||||
|             "version": "==0.2.2" |  | ||||||
|         }, |  | ||||||
|         "s3transfer": { |         "s3transfer": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc", |                 "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc", | ||||||
| @ -1163,6 +1083,14 @@ | |||||||
|             ], |             ], | ||||||
|             "version": "==3.10.0.0" |             "version": "==3.10.0.0" | ||||||
|         }, |         }, | ||||||
|  |         "ua-parser": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:46ab2e383c01dbd2ab284991b87d624a26a08f72da4d7d413f5bfab8b9036f8a", | ||||||
|  |                 "sha256:47b1782ed130d890018d983fac37c2a80799d9e0b9c532e734c67cf70f185033" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==0.10.0" | ||||||
|  |         }, | ||||||
|         "uritemplate": { |         "uritemplate": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", |                 "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", | ||||||
| @ -1175,22 +1103,22 @@ | |||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", |                 "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", | ||||||
|                 "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" |                 "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.4" |             "version": "==1.26.5" | ||||||
|         }, |         }, | ||||||
|         "uvicorn": { |         "uvicorn": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
|                 "standard" |                 "standard" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:3292251b3c7978e8e4a7868f4baf7f7f7bb7e40c759ecc125c37e99cdea34202", |                 "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae", | ||||||
|                 "sha256:7587f7b08bd1efd2b9bad809a3d333e972f1d11af8a5e52a9371ee3a5de71524" |                 "sha256:45ad7dfaaa7d55cab4cd1e85e03f27e9d60bc067ddc59db52a2b0aeca8870292" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==0.13.4" |             "version": "==0.14.0" | ||||||
|         }, |         }, | ||||||
|         "uvloop": { |         "uvloop": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1238,54 +1166,65 @@ | |||||||
|         }, |         }, | ||||||
|         "websocket-client": { |         "websocket-client": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32", |                 "sha256:b68e4959d704768fa20e35c9d508c8dc2bbc041fd8d267c0d7345cffe2824568", | ||||||
|                 "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c" |                 "sha256:e5c333bfa9fa739538b652b6f8c8fc2559f1d364243c8a689d7c0e1d41c2e611" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.59.0" |             "version": "==1.1.0" | ||||||
|         }, |         }, | ||||||
|         "websockets": { |         "websockets": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", |                 "sha256:0dd4eb8e0bbf365d6f652711ce21b8fd2b596f873d32aabb0fbb53ec604418cc", | ||||||
|                 "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", |                 "sha256:1d0971cc7251aeff955aa742ec541ee8aaea4bb2ebf0245748fbec62f744a37e", | ||||||
|                 "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", |                 "sha256:1d6b4fddb12ab9adf87b843cd4316c4bd602db8d5efd2fb83147f0458fe85135", | ||||||
|                 "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", |                 "sha256:230a3506df6b5f446fed2398e58dcaafdff12d67fe1397dff196411a9e820d02", | ||||||
|                 "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", |                 "sha256:276d2339ebf0df4f45df453923ebd2270b87900eda5dfd4a6b0cfa15f82111c3", | ||||||
|                 "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", |                 "sha256:2cf04601633a4ec176b9cc3d3e73789c037641001dbfaf7c411f89cd3e04fcaf", | ||||||
|                 "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", |                 "sha256:3ddff38894c7857c476feb3538dd847514379d6dc844961dc99f04b0384b1b1b", | ||||||
|                 "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", |                 "sha256:48c222feb3ced18f3dc61168ca18952a22fb88e5eb8902d2bf1b50faefdc34a2", | ||||||
|                 "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", |                 "sha256:51d04df04ed9d08077d10ccbe21e6805791b78eac49d16d30a1f1fe2e44ba0af", | ||||||
|                 "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", |                 "sha256:597c28f3aa7a09e8c070a86b03107094ee5cdafcc0d55f2f2eac92faac8dc67d", | ||||||
|                 "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", |                 "sha256:5c8f0d82ea2468282e08b0cf5307f3ad022290ed50c45d5cb7767957ca782880", | ||||||
|                 "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", |                 "sha256:7189e51955f9268b2bdd6cc537e0faa06f8fffda7fb386e5922c6391de51b077", | ||||||
|                 "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", |                 "sha256:7df3596838b2a0c07c6f6d67752c53859a54993d4f062689fdf547cb56d0f84f", | ||||||
|                 "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", |                 "sha256:826ccf85d4514609219725ba4a7abd569228c2c9f1968e8be05be366f68291ec", | ||||||
|                 "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", |                 "sha256:836d14eb53b500fd92bd5db2fc5894f7c72b634f9c2a28f546f75967503d8e25", | ||||||
|                 "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", |                 "sha256:85db8090ba94e22d964498a47fdd933b8875a1add6ebc514c7ac8703eb97bbf0", | ||||||
|                 "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", |                 "sha256:85e701a6c316b7067f1e8675c638036a796fe5116783a4c932e7eb8e305a3ffe", | ||||||
|                 "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", |                 "sha256:900589e19200be76dd7cbaa95e9771605b5ce3f62512d039fb3bc5da9014912a", | ||||||
|                 "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", |                 "sha256:9147868bb0cc01e6846606cd65cbf9c58598f187b96d14dd1ca17338b08793bb", | ||||||
|                 "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", |                 "sha256:9e7fdc775fe7403dbd8bc883ba59576a6232eac96dacb56512daacf7af5d618d", | ||||||
|                 "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", |                 "sha256:ab5ee15d3462198c794c49ccd31773d8a2b8c17d622aa184f669d2b98c2f0857", | ||||||
|                 "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" |                 "sha256:ad893d889bc700a5835e0a95a3e4f2c39e91577ab232a3dc03c262a0f8fc4b5c", | ||||||
|  |                 "sha256:b2e71c4670ebe1067fa8632f0d081e47254ee2d3d409de54168b43b0ba9147e0", | ||||||
|  |                 "sha256:b43b13e5622c5a53ab12f3272e6f42f1ce37cd5b6684b2676cb365403295cd40", | ||||||
|  |                 "sha256:b4ad84b156cf50529b8ac5cc1638c2cf8680490e3fccb6121316c8c02620a2e4", | ||||||
|  |                 "sha256:be5fd35e99970518547edc906efab29afd392319f020c3c58b0e1a158e16ed20", | ||||||
|  |                 "sha256:caa68c95bc1776d3521f81eeb4d5b9438be92514ec2a79fececda814099c8314", | ||||||
|  |                 "sha256:d144b350045c53c8ff09aa1cfa955012dd32f00c7e0862c199edcabb1a8b32da", | ||||||
|  |                 "sha256:d2c2d9b24d3c65b5a02cac12cbb4e4194e590314519ed49db2f67ef561c3cf58", | ||||||
|  |                 "sha256:e9e5fd6dbdf95d99bc03732ded1fc8ef22ebbc05999ac7e0c7bf57fe6e4e5ae2", | ||||||
|  |                 "sha256:ebf459a1c069f9866d8569439c06193c586e72c9330db1390af7c6a0a32c4afd", | ||||||
|  |                 "sha256:f31722f1c033c198aa4a39a01905951c00bd1c74f922e8afc1b1c62adbcdd56a", | ||||||
|  |                 "sha256:f68c352a68e5fdf1e97288d5cec9296664c590c25932a8476224124aaf90dbcd" | ||||||
|             ], |             ], | ||||||
|             "version": "==8.1" |             "version": "==9.1" | ||||||
|         }, |         }, | ||||||
|         "xmlsec": { |         "xmlsec": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:17d2e66d4e3e601d210eed936b53c3eb44cddaef62f60b5c6ad5c18e948d926c", |                 "sha256:23f209260b37bdc2fd96af837494c47dd1e67964f077442b63acd83c0f62e212", | ||||||
|                 "sha256:2bc1b871b49d6580779805a4a1c2d835e834a2fa614fe40cf71931d11a8279cf", |                 "sha256:4fb38ab0bf3e47cbae136119674a869e09d61c939b510350f369c8ac46087373", | ||||||
|                 "sha256:52eded125c0d1ab72125105ef061370c6b06ab9bd37e29a61bc2f8a61205bae4", |                 "sha256:705ab5b848afdf3a5c78b1322276054c885f44dc51601e14cb883a9c86cbe20f", | ||||||
|                 "sha256:72af9a5a747a5fe6e425d2be10daa43d18307dbe03498df3820fc3cd93daa148", |                 "sha256:843d10bba4c480609da74ee11fff1ee0fc1c12821c656979f12a7a4ecb043e03", | ||||||
|                 "sha256:806855d505da24aeb77758a6f373b1473e5ed63bdbe346af90cc6d2b053e4716", |                 "sha256:86d54b93f8278e2f0c504d0744e39a483c1c7ce9993f2ca70184cc7770faa982", | ||||||
|                 "sha256:8746dd992aaec06ed8ff1615f4a8e2a32258e8af38f9a9f8acf3ee1fb34a5da6", |                 "sha256:8922fba55a060ee81de4a7f5efc593c5bf121047763aecf0eead02e061c9d2db", | ||||||
|                 "sha256:9d52b2b15d42292725e4f9d8a5b040e39cba0a9cd58059ac951e7310d6340bb9", |                 "sha256:c7b49d4fce83186b89f7ce6cec765245d36a70d0acc2f3ed0ba95c735b3667da", | ||||||
|                 "sha256:b380f3ebc042f71afab057632481d06e06f1ba4f90047d91ca92612a7d3d487b", |                 "sha256:cd2eaaff7f31784a07dd99ce81fa767313df3ba1834faa4143ee2c07000cac7a", | ||||||
|                 "sha256:be0f475edd8e9c98f57449c97839f6a81946e79e4cccb81e4b5196a2cc40e044", |                 "sha256:dea5bef9b5830c36ccb7a68a0d94d49eaea4d03fbbd04179652bf661b7e6e30f", | ||||||
|                 "sha256:bf3c62d154f2222caf56d897ddfd53fd0aef560d5a2202447d90e015301a0a10", |                 "sha256:eadff662d89c80db409c69d82eb3e695e16d4a5e8ab56b5b22670a54e9c6ff20", | ||||||
|                 "sha256:fe6a5f05aba3ff47e105a308482b68f8b0fd80656eb1456a9c1e4de47d2c580f" |                 "sha256:ee233d0bc27fb8f447ca2622b0de2ac2df45b8795f02ef263825912011fe4fe9" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.3.10" |             "version": "==1.3.11" | ||||||
|         }, |         }, | ||||||
|         "yarl": { |         "yarl": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1434,10 +1373,10 @@ | |||||||
|         }, |         }, | ||||||
|         "certifi": { |         "certifi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", |                 "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", | ||||||
|                 "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" |                 "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" | ||||||
|             ], |             ], | ||||||
|             "version": "==2020.12.5" |             "version": "==2021.5.30" | ||||||
|         }, |         }, | ||||||
|         "chardet": { |         "chardet": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1633,11 +1572,11 @@ | |||||||
|         }, |         }, | ||||||
|         "pylint": { |         "pylint": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:586d8fa9b1891f4b725f587ef267abe2a1bad89d6b184520c7f07a253dd6e217", |                 "sha256:0a049c5d47b629d9070c3932d13bff482b12119b6a241a93bc460b0be16953c8", | ||||||
|                 "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b" |                 "sha256:792b38ff30903884e4a9eab814ee3523731abd3c463f3ba48d7b627e87013484" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==2.8.2" |             "version": "==2.8.3" | ||||||
|         }, |         }, | ||||||
|         "pylint-django": { |         "pylint-django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1671,11 +1610,11 @@ | |||||||
|         }, |         }, | ||||||
|         "pytest-django": { |         "pytest-django": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:d1c6758a592fb0ef8abaa2fe12dd28858c1dcfc3d466102ffe52aa8934733dca", |                 "sha256:65783e78382456528bd9d79a35843adde9e6a47347b20464eb2c885cb0f1f606", | ||||||
|                 "sha256:f96c4556f4e7b15d987dd1dcc1d1526df81d40c1548d31ce840d597ed2be8c46" |                 "sha256:b5171e3798bf7e3fc5ea7072fe87324db67a4dd9f1192b037fed4cc3c1b7f455" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==4.3.0" |             "version": "==4.4.0" | ||||||
|         }, |         }, | ||||||
|         "pyyaml": { |         "pyyaml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1767,11 +1706,11 @@ | |||||||
|         }, |         }, | ||||||
|         "requests-mock": { |         "requests-mock": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:33296f228d8c5df11a7988b741325422480baddfdf5dd9318fd0eb40c3ed8595", |                 "sha256:0a2d38a117c08bb78939ec163522976ad59a6b7fdd82b709e23bb98004a44970", | ||||||
|                 "sha256:5c8ef0254c14a84744be146e9799dc13ebc4f6186058112d9aeed96b131b58e2" |                 "sha256:8d72abe54546c1fc9696fa1516672f1031d72a55a1d66c85184f972a24ba0eba" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.9.2" |             "version": "==1.9.3" | ||||||
|         }, |         }, | ||||||
|         "selenium": { |         "selenium": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1814,11 +1753,11 @@ | |||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", |                 "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", | ||||||
|                 "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" |                 "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.4" |             "version": "==1.26.5" | ||||||
|         }, |         }, | ||||||
|         "wrapt": { |         "wrapt": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|  | |||||||
| @ -1,3 +1,3 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "2021.5.2" | __version__ = "2021.6.1-rc4" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """Meta API""" | """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.fields import CharField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -22,7 +22,7 @@ class AppsViewSet(ViewSet): | |||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: AppSerializer(many=True)}) |     @extend_schema(responses={200: AppSerializer(many=True)}) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """List current messages and pass into Serializer""" |         """List current messages and pass into Serializer""" | ||||||
|         data = [] |         data = [] | ||||||
|  | |||||||
| @ -7,12 +7,12 @@ from django.db.models import Count, ExpressionWrapper, F | |||||||
| from django.db.models.fields import DurationField | from django.db.models.fields import DurationField | ||||||
| from django.db.models.functions import ExtractHour | from django.db.models.functions import ExtractHour | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from drf_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.fields import IntegerField, SerializerMethodField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ViewSet | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| @ -58,24 +58,24 @@ class LoginMetricsSerializer(PassiveSerializer): | |||||||
|     logins_per_1h = SerializerMethodField() |     logins_per_1h = SerializerMethodField() | ||||||
|     logins_failed_per_1h = SerializerMethodField() |     logins_failed_per_1h = SerializerMethodField() | ||||||
|  |  | ||||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_logins_per_1h(self, _): |     def get_logins_per_1h(self, _): | ||||||
|         """Get successful logins per hour for the last 24 hours""" |         """Get successful logins per hour for the last 24 hours""" | ||||||
|         return get_events_per_1h(action=EventAction.LOGIN) |         return get_events_per_1h(action=EventAction.LOGIN) | ||||||
|  |  | ||||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_logins_failed_per_1h(self, _): |     def get_logins_failed_per_1h(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per hour for the last 24 hours""" | ||||||
|         return get_events_per_1h(action=EventAction.LOGIN_FAILED) |         return get_events_per_1h(action=EventAction.LOGIN_FAILED) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AdministrationMetricsViewSet(ViewSet): | class AdministrationMetricsViewSet(APIView): | ||||||
|     """Login Metrics per 1h""" |     """Login Metrics per 1h""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: LoginMetricsSerializer(many=False)}) |     @extend_schema(responses={200: LoginMetricsSerializer(many=False)}) | ||||||
|     def list(self, request: Request) -> Response: |     def get(self, request: Request) -> Response: | ||||||
|         """Login Metrics per 1h""" |         """Login Metrics per 1h""" | ||||||
|         serializer = LoginMetricsSerializer(True) |         serializer = LoginMetricsSerializer(True) | ||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  | |||||||
							
								
								
									
										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.contrib import messages | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_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.decorators import action | ||||||
| from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField | from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| @ -21,7 +22,7 @@ class TaskSerializer(PassiveSerializer): | |||||||
|  |  | ||||||
|     task_name = CharField() |     task_name = CharField() | ||||||
|     task_description = CharField() |     task_description = CharField() | ||||||
|     task_finish_timestamp = DateTimeField(source="finish_timestamp") |     task_finish_timestamp = DateTimeField(source="finish_time") | ||||||
|  |  | ||||||
|     status = ChoiceField( |     status = ChoiceField( | ||||||
|         source="result.status.name", |         source="result.status.name", | ||||||
| @ -29,14 +30,32 @@ class TaskSerializer(PassiveSerializer): | |||||||
|     ) |     ) | ||||||
|     messages = ListField(source="result.messages") |     messages = ListField(source="result.messages") | ||||||
|  |  | ||||||
|  |     def to_representation(self, instance): | ||||||
|  |         """When a new version of authentik adds fields to TaskInfo, | ||||||
|  |         the API will fail with an AttributeError, as the classes | ||||||
|  |         are pickled in cache. In that case, just delete the info""" | ||||||
|  |         try: | ||||||
|  |             return super().to_representation(instance) | ||||||
|  |         except AttributeError: | ||||||
|  |             if isinstance(self.instance, list): | ||||||
|  |                 for inst in self.instance: | ||||||
|  |                     inst.delete() | ||||||
|  |             else: | ||||||
|  |                 self.instance.delete() | ||||||
|  |             return {} | ||||||
|  |  | ||||||
|  |  | ||||||
| class TaskViewSet(ViewSet): | class TaskViewSet(ViewSet): | ||||||
|     """Read-only view set that returns all background tasks""" |     """Read-only view set that returns all background tasks""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |     serializer_class = TaskSerializer | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={200: TaskSerializer(many=False), 404: "Task not found"} |         responses={ | ||||||
|  |             200: TaskSerializer(many=False), | ||||||
|  |             404: OpenApiResponse(description="Task not found"), | ||||||
|  |         } | ||||||
|     ) |     ) | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|     def retrieve(self, request: Request, pk=None) -> Response: |     def retrieve(self, request: Request, pk=None) -> Response: | ||||||
| @ -46,18 +65,19 @@ class TaskViewSet(ViewSet): | |||||||
|             raise Http404 |             raise Http404 | ||||||
|         return Response(TaskSerializer(task, many=False).data) |         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: |     def list(self, request: Request) -> Response: | ||||||
|         """List system tasks""" |         """List system tasks""" | ||||||
|         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) |         tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name) | ||||||
|         return Response(TaskSerializer(tasks, many=True).data) |         return Response(TaskSerializer(tasks, many=True).data) | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|  |         request=OpenApiTypes.NONE, | ||||||
|         responses={ |         responses={ | ||||||
|             204: "Task retried successfully", |             204: OpenApiResponse(description="Task retried successfully"), | ||||||
|             404: "Task not found", |             404: OpenApiResponse(description="Task not found"), | ||||||
|             500: "Failed to retry task", |             500: OpenApiResponse(description="Failed to retry task"), | ||||||
|         } |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["post"]) |     @action(detail=True, methods=["post"]) | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|  | |||||||
| @ -2,14 +2,13 @@ | |||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| from django.core.cache import cache | 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 packaging.version import parse | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import SerializerMethodField | ||||||
| from rest_framework.mixins import ListModelMixin |  | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import ENV_GIT_HASH_KEY, __version__ | ||||||
| from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | ||||||
| @ -47,17 +46,14 @@ class VersionSerializer(PassiveSerializer): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class VersionViewSet(ListModelMixin, GenericViewSet): | class VersionView(APIView): | ||||||
|     """Get running and latest version.""" |     """Get running and latest version.""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAuthenticated] |     permission_classes = [IsAuthenticated] | ||||||
|     pagination_class = None |     pagination_class = None | ||||||
|     filter_backends = [] |     filter_backends = [] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     @extend_schema(responses={200: VersionSerializer(many=False)}) | ||||||
|         return None |     def get(self, request: Request) -> Response: | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: VersionSerializer(many=False)}) |  | ||||||
|     def list(self, request: Request) -> Response: |  | ||||||
|         """Get running and latest version.""" |         """Get running and latest version.""" | ||||||
|         return Response(VersionSerializer(True).data) |         return Response(VersionSerializer(True).data) | ||||||
|  | |||||||
| @ -1,25 +1,26 @@ | |||||||
| """authentik administration overview""" | """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.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.views import APIView | ||||||
| from rest_framework.viewsets import GenericViewSet |  | ||||||
|  |  | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
|  | GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers") | ||||||
|  |  | ||||||
| class WorkerViewSet(ListModelMixin, GenericViewSet): |  | ||||||
|  | class WorkerView(APIView): | ||||||
|     """Get currently connected worker count.""" |     """Get currently connected worker count.""" | ||||||
|  |  | ||||||
|     serializer_class = Serializer |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     @extend_schema( | ||||||
|         return None |         responses=inline_serializer("Workers", fields={"count": IntegerField()}) | ||||||
|  |  | ||||||
|     def list(self, request: Request) -> Response: |  | ||||||
|         """Get currently connected worker count.""" |  | ||||||
|         return Response( |  | ||||||
|             {"pagination": {"count": len(CELERY_APP.control.ping(timeout=0.5))}} |  | ||||||
|     ) |     ) | ||||||
|  |     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""" | """authentik admin tasks""" | ||||||
| import re | import re | ||||||
|  | from os import environ | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.core.validators import URLValidator | from django.core.validators import URLValidator | ||||||
| from packaging.version import parse | from packaging.version import parse | ||||||
|  | from prometheus_client import Info | ||||||
| from requests import RequestException, get | from requests import RequestException, get | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import ENV_GIT_HASH_KEY, __version__ | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| @ -17,6 +19,18 @@ VERSION_CACHE_KEY = "authentik_latest_version" | |||||||
| VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | ||||||
| # Chop of the first ^ because we want to search the entire string | # Chop of the first ^ because we want to search the entire string | ||||||
| URL_FINDER = URLValidator.regex.pattern[1:] | URL_FINDER = URLValidator.regex.pattern[1:] | ||||||
|  | PROM_INFO = Info("authentik_version", "Currently running authentik version") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _set_prom_info(): | ||||||
|  |     """Set prometheus info for version""" | ||||||
|  |     PROM_INFO.info( | ||||||
|  |         { | ||||||
|  |             "version": __version__, | ||||||
|  |             "latest": cache.get(VERSION_CACHE_KEY, ""), | ||||||
|  |             "build_hash": environ.get(ENV_GIT_HASH_KEY, ""), | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||||
| @ -36,6 +50,7 @@ def update_latest_version(self: MonitoredTask): | |||||||
|                 TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] |                 TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|  |         _set_prom_info() | ||||||
|         # Check if upstream version is newer than what we're running, |         # Check if upstream version is newer than what we're running, | ||||||
|         # and if no event exists yet, create one. |         # and if no event exists yet, create one. | ||||||
|         local_version = parse(__version__) |         local_version = parse(__version__) | ||||||
| @ -53,3 +68,6 @@ def update_latest_version(self: MonitoredTask): | |||||||
|     except (RequestException, IndexError) as exc: |     except (RequestException, IndexError) as exc: | ||||||
|         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) |         cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) | ||||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _set_prom_info() | ||||||
|  | |||||||
| @ -74,24 +74,29 @@ class TestAdminAPI(TestCase): | |||||||
|  |  | ||||||
|     def test_version(self): |     def test_version(self): | ||||||
|         """Test Version API""" |         """Test Version API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_version-list")) |         response = self.client.get(reverse("authentik_api:admin_version")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         body = loads(response.content) |         body = loads(response.content) | ||||||
|         self.assertEqual(body["version_current"], __version__) |         self.assertEqual(body["version_current"], __version__) | ||||||
|  |  | ||||||
|     def test_workers(self): |     def test_workers(self): | ||||||
|         """Test Workers API""" |         """Test Workers API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_workers-list")) |         response = self.client.get(reverse("authentik_api:admin_workers")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         body = loads(response.content) |         body = loads(response.content) | ||||||
|         self.assertEqual(body["pagination"]["count"], 0) |         self.assertEqual(body["count"], 0) | ||||||
|  |  | ||||||
|     def test_metrics(self): |     def test_metrics(self): | ||||||
|         """Test metrics API""" |         """Test metrics API""" | ||||||
|         response = self.client.get(reverse("authentik_api:admin_metrics-list")) |         response = self.client.get(reverse("authentik_api:admin_metrics")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|     def test_apps(self): |     def test_apps(self): | ||||||
|         """Test apps API""" |         """Test apps API""" | ||||||
|         response = self.client.get(reverse("authentik_api:apps-list")) |         response = self.client.get(reverse("authentik_api:apps-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_system(self): | ||||||
|  |         """Test system API""" | ||||||
|  |         response = self.client.get(reverse("authentik_api:admin_system")) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from base64 import b64decode | |||||||
| from binascii import Error | from binascii import Error | ||||||
| from typing import Any, Optional, Union | from typing import Any, Optional, Union | ||||||
| 
 | 
 | ||||||
|  | from drf_spectacular.authentication import OpenApiAuthenticationExtension | ||||||
| from rest_framework.authentication import BaseAuthentication, get_authorization_header | from rest_framework.authentication import BaseAuthentication, get_authorization_header | ||||||
| from rest_framework.exceptions import AuthenticationFailed | from rest_framework.exceptions import AuthenticationFailed | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -17,7 +18,7 @@ LOGGER = get_logger() | |||||||
| def token_from_header(raw_header: bytes) -> Optional[Token]: | def token_from_header(raw_header: bytes) -> Optional[Token]: | ||||||
|     """raw_header in the Format of `Bearer dGVzdDp0ZXN0`""" |     """raw_header in the Format of `Bearer dGVzdDp0ZXN0`""" | ||||||
|     auth_credentials = raw_header.decode() |     auth_credentials = raw_header.decode() | ||||||
|     if auth_credentials == "": |     if auth_credentials == "" or " " not in auth_credentials: | ||||||
|         return None |         return None | ||||||
|     auth_type, auth_credentials = auth_credentials.split() |     auth_type, auth_credentials = auth_credentials.split() | ||||||
|     if auth_type.lower() not in ["basic", "bearer"]: |     if auth_type.lower() not in ["basic", "bearer"]: | ||||||
| @ -42,7 +43,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]: | |||||||
|     return tokens.first() |     return tokens.first() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class AuthentikTokenAuthentication(BaseAuthentication): | class TokenAuthentication(BaseAuthentication): | ||||||
|     """Token-based authentication using HTTP Bearer authentication""" |     """Token-based authentication using HTTP Bearer authentication""" | ||||||
| 
 | 
 | ||||||
|     def authenticate(self, request: Request) -> Union[tuple[User, Any], None]: |     def authenticate(self, request: Request) -> Union[tuple[User, Any], None]: | ||||||
| @ -55,3 +56,18 @@ class AuthentikTokenAuthentication(BaseAuthentication): | |||||||
|             return None |             return None | ||||||
| 
 | 
 | ||||||
|         return (token.user, None)  # pragma: no cover |         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", | ||||||
|  |         } | ||||||
							
								
								
									
										35
									
								
								authentik/api/authorization.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								authentik/api/authorization.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | """API Authorization""" | ||||||
|  | from django.db.models import Model | ||||||
|  | from django.db.models.query import QuerySet | ||||||
|  | from rest_framework.filters import BaseFilterBackend | ||||||
|  | from rest_framework.permissions import BasePermission | ||||||
|  | from rest_framework.request import Request | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OwnerFilter(BaseFilterBackend): | ||||||
|  |     """Filter objects by their owner""" | ||||||
|  |  | ||||||
|  |     owner_key = "user" | ||||||
|  |  | ||||||
|  |     def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: | ||||||
|  |         return queryset.filter(**{self.owner_key: request.user}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OwnerPermissions(BasePermission): | ||||||
|  |     """Authorize requests by an object's owner matching the requesting user""" | ||||||
|  |  | ||||||
|  |     owner_key = "user" | ||||||
|  |  | ||||||
|  |     def has_permission(self, request: Request, view) -> bool: | ||||||
|  |         """If the user is authenticated, we allow all requests here. For listing, the | ||||||
|  |         object-level permissions are done by the filter backend""" | ||||||
|  |         return request.user.is_authenticated | ||||||
|  |  | ||||||
|  |     def has_object_permission(self, request: Request, view, obj: Model) -> bool: | ||||||
|  |         """Check if the object's owner matches the currently logged in user""" | ||||||
|  |         if not hasattr(obj, self.owner_key): | ||||||
|  |             return False | ||||||
|  |         owner = getattr(obj, self.owner_key) | ||||||
|  |         if owner != request.user: | ||||||
|  |             return False | ||||||
|  |         return True | ||||||
| @ -30,3 +30,47 @@ class Pagination(pagination.PageNumberPagination): | |||||||
|                 "results": data, |                 "results": data, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def get_paginated_response_schema(self, schema): | ||||||
|  |         return { | ||||||
|  |             "type": "object", | ||||||
|  |             "properties": { | ||||||
|  |                 "pagination": { | ||||||
|  |                     "type": "object", | ||||||
|  |                     "properties": { | ||||||
|  |                         "next": { | ||||||
|  |                             "type": "number", | ||||||
|  |                         }, | ||||||
|  |                         "previous": { | ||||||
|  |                             "type": "number", | ||||||
|  |                         }, | ||||||
|  |                         "count": { | ||||||
|  |                             "type": "number", | ||||||
|  |                         }, | ||||||
|  |                         "current": { | ||||||
|  |                             "type": "number", | ||||||
|  |                         }, | ||||||
|  |                         "total_pages": { | ||||||
|  |                             "type": "number", | ||||||
|  |                         }, | ||||||
|  |                         "start_index": { | ||||||
|  |                             "type": "number", | ||||||
|  |                         }, | ||||||
|  |                         "end_index": { | ||||||
|  |                             "type": "number", | ||||||
|  |                         }, | ||||||
|  |                     }, | ||||||
|  |                     "required": [ | ||||||
|  |                         "next", | ||||||
|  |                         "previous", | ||||||
|  |                         "count", | ||||||
|  |                         "current", | ||||||
|  |                         "total_pages", | ||||||
|  |                         "start_index", | ||||||
|  |                         "end_index", | ||||||
|  |                     ], | ||||||
|  |                 }, | ||||||
|  |                 "results": schema, | ||||||
|  |             }, | ||||||
|  |             "required": ["pagination", "results"], | ||||||
|  |         } | ||||||
|  | |||||||
| @ -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""" | """Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224""" | ||||||
| from drf_yasg import openapi | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_yasg.inspectors.view import SwaggerAutoSchema | from drf_spectacular.plumbing import ( | ||||||
| from drf_yasg.utils import force_real_str, is_list_view |     ResolvedComponent, | ||||||
| from rest_framework import exceptions, status |     build_array_type, | ||||||
| from rest_framework.settings import api_settings |     build_basic_type, | ||||||
|  |     build_object_type, | ||||||
|  | ) | ||||||
|  | from drf_spectacular.settings import spectacular_settings | ||||||
|  | from drf_spectacular.types import OpenApiTypes | ||||||
|  |  | ||||||
|  |  | ||||||
| class ErrorResponseAutoSchema(SwaggerAutoSchema): | def build_standard_type(obj, **kwargs): | ||||||
|     """Inspector which includes an error schema""" |     """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""" | GENERIC_ERROR = build_object_type( | ||||||
|         return openapi.Schema( |     description=_("Generic API Error"), | ||||||
|             "Generic API Error", |  | ||||||
|             type=openapi.TYPE_OBJECT, |  | ||||||
|     properties={ |     properties={ | ||||||
|                 "detail": openapi.Schema( |         "detail": build_standard_type(OpenApiTypes.STR), | ||||||
|                     type=openapi.TYPE_STRING, description="Error details" |         "code": build_standard_type(OpenApiTypes.STR), | ||||||
|                 ), |  | ||||||
|                 "code": openapi.Schema( |  | ||||||
|                     type=openapi.TYPE_STRING, description="Error code" |  | ||||||
|                 ), |  | ||||||
|     }, |     }, | ||||||
|     required=["detail"], |     required=["detail"], | ||||||
|         ) | ) | ||||||
|  | VALIDATION_ERROR = build_object_type( | ||||||
|     def get_validation_error_schema(self): |     description=_("Validation Error"), | ||||||
|         """Get a generic validation error schema""" |  | ||||||
|         return openapi.Schema( |  | ||||||
|             "Validation Error", |  | ||||||
|             type=openapi.TYPE_OBJECT, |  | ||||||
|     properties={ |     properties={ | ||||||
|                 api_settings.NON_FIELD_ERRORS_KEY: openapi.Schema( |         "non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)), | ||||||
|                     description="List of validation errors not related to any field", |         "code": build_standard_type(OpenApiTypes.STR), | ||||||
|                     type=openapi.TYPE_ARRAY, |  | ||||||
|                     items=openapi.Schema(type=openapi.TYPE_STRING), |  | ||||||
|                 ), |  | ||||||
|     }, |     }, | ||||||
|             additional_properties=openapi.Schema( |     required=["detail"], | ||||||
|                 description=( |     additionalProperties={}, | ||||||
|                     "A list of error messages for each " | ) | ||||||
|                     "field that triggered a validation error" |  | ||||||
|                 ), |  | ||||||
|                 type=openapi.TYPE_ARRAY, | def postprocess_schema_responses(result, generator, **kwargs):  # noqa: W0613 | ||||||
|                 items=openapi.Schema(type=openapi.TYPE_STRING), |     """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): |     # This is a workaround for authentik/stages/prompt/stage.py | ||||||
|         responses = super().get_response_serializers() |     # since the serializer PromptChallengeResponse | ||||||
|         definitions = self.components.with_scope( |     # accepts dynamic keys | ||||||
|             openapi.SCHEMA_DEFINITIONS |     for component in result["components"]["schemas"]: | ||||||
|         )  # type: openapi.ReferenceResolver |         if component == "PromptChallengeResponseRequest": | ||||||
|  |             comp = result["components"]["schemas"][component] | ||||||
|         definitions.setdefault("GenericError", self.get_generic_error_schema) |             comp["additionalProperties"] = {} | ||||||
|         definitions.setdefault("ValidationError", self.get_validation_error_schema) |     return result | ||||||
|         definitions.setdefault("APIException", self.get_generic_error_schema) |  | ||||||
|  |  | ||||||
|         if self.get_request_serializer() or self.get_query_serializer(): |  | ||||||
|             responses.setdefault( |  | ||||||
|                 exceptions.ValidationError.status_code, |  | ||||||
|                 openapi.Response( |  | ||||||
|                     description=force_real_str( |  | ||||||
|                         exceptions.ValidationError.default_detail |  | ||||||
|                     ), |  | ||||||
|                     schema=openapi.SchemaRef(definitions, "ValidationError"), |  | ||||||
|                 ), |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         security = self.get_security() |  | ||||||
|         if security is None or len(security) > 0: |  | ||||||
|             # Note: 401 error codes are coerced  into 403 see |  | ||||||
|             # rest_framework/views.py:433:handle_exception |  | ||||||
|             # This is b/c the API uses token auth which doesn't have WWW-Authenticate header |  | ||||||
|             responses.setdefault( |  | ||||||
|                 status.HTTP_403_FORBIDDEN, |  | ||||||
|                 openapi.Response( |  | ||||||
|                     description="Authentication credentials were invalid, absent or insufficient.", |  | ||||||
|                     schema=openapi.SchemaRef(definitions, "GenericError"), |  | ||||||
|                 ), |  | ||||||
|             ) |  | ||||||
|         if not is_list_view(self.path, self.method, self.view): |  | ||||||
|             responses.setdefault( |  | ||||||
|                 exceptions.PermissionDenied.status_code, |  | ||||||
|                 openapi.Response( |  | ||||||
|                     description="Permission denied.", |  | ||||||
|                     schema=openapi.SchemaRef(definitions, "APIException"), |  | ||||||
|                 ), |  | ||||||
|             ) |  | ||||||
|             responses.setdefault( |  | ||||||
|                 exceptions.NotFound.status_code, |  | ||||||
|                 openapi.Response( |  | ||||||
|                     description=( |  | ||||||
|                         "Object does not exist or caller " |  | ||||||
|                         "has insufficient permissions to access it." |  | ||||||
|                     ), |  | ||||||
|                     schema=openapi.SchemaRef(definitions, "APIException"), |  | ||||||
|                 ), |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         return responses |  | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| 
 | 
 | ||||||
| {% block title %} | {% block title %} | ||||||
| API Browser - {{ config.authentik.branding.title }} | API Browser - {{ tenant.branding_title }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block head %} | {% block head %} | ||||||
| @ -5,7 +5,7 @@ from django.test import TestCase | |||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
| from rest_framework.exceptions import AuthenticationFailed | from rest_framework.exceptions import AuthenticationFailed | ||||||
|  |  | ||||||
| from authentik.api.auth import token_from_header | from authentik.api.authentication import token_from_header | ||||||
| from authentik.core.models import Token, TokenIntents | from authentik.core.models import Token, TokenIntents | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,6 +11,6 @@ class TestConfig(APITestCase): | |||||||
|     def test_config(self): |     def test_config(self): | ||||||
|         """Test YAML generation""" |         """Test YAML generation""" | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse("authentik_api:configs-list"), |             reverse("authentik_api:config"), | ||||||
|         ) |         ) | ||||||
|         self.assertTrue(loads(response.content.decode())) |         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""" | """core Configs API""" | ||||||
| from drf_yasg.utils import swagger_auto_schema | from os import environ, path | ||||||
| from rest_framework.fields import BooleanField, CharField, ListField |  | ||||||
|  | from django.conf import settings | ||||||
|  | from django.db import models | ||||||
|  | from drf_spectacular.utils import extend_schema | ||||||
|  | from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME | ||||||
|  | from rest_framework.fields import BooleanField, CharField, ChoiceField, ListField | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ViewSet | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  | from authentik.events.geo import GEOIP_READER | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  |  | ||||||
|  |  | ||||||
| class FooterLinkSerializer(PassiveSerializer): | class Capabilities(models.TextChoices): | ||||||
|     """Links returned in Config API""" |     """Define capabilities which influence which APIs can/should be used""" | ||||||
|  |  | ||||||
|     href = CharField(read_only=True) |     CAN_SAVE_MEDIA = "can_save_media" | ||||||
|     name = CharField(read_only=True) |     CAN_GEO_IP = "can_geo_ip" | ||||||
|  |     CAN_BACKUP = "can_backup" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ConfigSerializer(PassiveSerializer): | class ConfigSerializer(PassiveSerializer): | ||||||
|     """Serialize authentik Config into DRF Object""" |     """Serialize authentik Config into DRF Object""" | ||||||
|  |  | ||||||
|     branding_logo = CharField(read_only=True) |  | ||||||
|     branding_title = CharField(read_only=True) |  | ||||||
|     ui_footer_links = ListField(child=FooterLinkSerializer(), read_only=True) |  | ||||||
|  |  | ||||||
|     error_reporting_enabled = BooleanField(read_only=True) |     error_reporting_enabled = BooleanField(read_only=True) | ||||||
|     error_reporting_environment = CharField(read_only=True) |     error_reporting_environment = CharField(read_only=True) | ||||||
|     error_reporting_send_pii = BooleanField(read_only=True) |     error_reporting_send_pii = BooleanField(read_only=True) | ||||||
|  |  | ||||||
|  |     capabilities = ListField(child=ChoiceField(choices=Capabilities.choices)) | ||||||
|  |  | ||||||
| class ConfigsViewSet(ViewSet): |  | ||||||
|  | class ConfigView(APIView): | ||||||
|     """Read-only view set that returns the current session's Configs""" |     """Read-only view set that returns the current session's Configs""" | ||||||
|  |  | ||||||
|     permission_classes = [AllowAny] |     permission_classes = [AllowAny] | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: ConfigSerializer(many=False)}) |     def get_capabilities(self) -> list[Capabilities]: | ||||||
|     def list(self, request: Request) -> Response: |         """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""" |         """Retrive public configuration options""" | ||||||
|         config = ConfigSerializer( |         config = ConfigSerializer( | ||||||
|             { |             { | ||||||
|                 "branding_logo": CONFIG.y("authentik.branding.logo"), |  | ||||||
|                 "branding_title": CONFIG.y("authentik.branding.title"), |  | ||||||
|                 "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), |                 "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), | ||||||
|                 "error_reporting_environment": CONFIG.y("error_reporting.environment"), |                 "error_reporting_environment": CONFIG.y("error_reporting.environment"), | ||||||
|                 "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"), |                 "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"), | ||||||
|                 "ui_footer_links": CONFIG.y("authentik.footer_links"), |                 "capabilities": self.get_capabilities(), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|         return Response(config.data) |         return Response(config.data) | ||||||
|  | |||||||
| @ -1,18 +1,18 @@ | |||||||
| """api v2 urls""" | """api v2 urls""" | ||||||
| from django.urls import path, re_path | from django.urls import path | ||||||
| from drf_yasg import openapi | from drf_spectacular.views import SpectacularAPIView | ||||||
| from drf_yasg.views import get_schema_view |  | ||||||
| from rest_framework import routers | from rest_framework import routers | ||||||
| from rest_framework.permissions import AllowAny |  | ||||||
|  |  | ||||||
| from authentik.admin.api.meta import AppsViewSet | from authentik.admin.api.meta import AppsViewSet | ||||||
| from authentik.admin.api.metrics import AdministrationMetricsViewSet | from authentik.admin.api.metrics import AdministrationMetricsViewSet | ||||||
|  | from authentik.admin.api.system import SystemView | ||||||
| from authentik.admin.api.tasks import TaskViewSet | from authentik.admin.api.tasks import TaskViewSet | ||||||
| from authentik.admin.api.version import VersionViewSet | from authentik.admin.api.version import VersionView | ||||||
| from authentik.admin.api.workers import WorkerViewSet | from authentik.admin.api.workers import WorkerView | ||||||
| from authentik.api.v2.config import ConfigsViewSet | from authentik.api.v2.config import ConfigView | ||||||
| from authentik.api.views import SwaggerView | from authentik.api.views import APIBrowserView | ||||||
| from authentik.core.api.applications import ApplicationViewSet | from authentik.core.api.applications import ApplicationViewSet | ||||||
|  | from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | ||||||
| from authentik.core.api.groups import GroupViewSet | from authentik.core.api.groups import GroupViewSet | ||||||
| from authentik.core.api.propertymappings import PropertyMappingViewSet | from authentik.core.api.propertymappings import PropertyMappingViewSet | ||||||
| from authentik.core.api.providers import ProviderViewSet | from authentik.core.api.providers import ProviderViewSet | ||||||
| @ -28,12 +28,12 @@ from authentik.flows.api.bindings import FlowStageBindingViewSet | |||||||
| from authentik.flows.api.flows import FlowViewSet | from authentik.flows.api.flows import FlowViewSet | ||||||
| from authentik.flows.api.stages import StageViewSet | from authentik.flows.api.stages import StageViewSet | ||||||
| from authentik.flows.views import FlowExecutorView | from authentik.flows.views import FlowExecutorView | ||||||
| from authentik.outposts.api.outpost_service_connections import ( | from authentik.outposts.api.outposts import OutpostViewSet | ||||||
|  | from authentik.outposts.api.service_connections import ( | ||||||
|     DockerServiceConnectionViewSet, |     DockerServiceConnectionViewSet, | ||||||
|     KubernetesServiceConnectionViewSet, |     KubernetesServiceConnectionViewSet, | ||||||
|     ServiceConnectionViewSet, |     ServiceConnectionViewSet, | ||||||
| ) | ) | ||||||
| from authentik.outposts.api.outposts import OutpostViewSet |  | ||||||
| from authentik.policies.api.bindings import PolicyBindingViewSet | from authentik.policies.api.bindings import PolicyBindingViewSet | ||||||
| from authentik.policies.api.policies import PolicyViewSet | from authentik.policies.api.policies import PolicyViewSet | ||||||
| from authentik.policies.dummy.api import DummyPolicyViewSet | from authentik.policies.dummy.api import DummyPolicyViewSet | ||||||
| @ -66,6 +66,11 @@ from authentik.sources.oauth.api.source_connection import ( | |||||||
| ) | ) | ||||||
| from authentik.sources.plex.api import PlexSourceViewSet | from authentik.sources.plex.api import PlexSourceViewSet | ||||||
| from authentik.sources.saml.api import SAMLSourceViewSet | from authentik.sources.saml.api import SAMLSourceViewSet | ||||||
|  | from authentik.stages.authenticator_duo.api import ( | ||||||
|  |     AuthenticatorDuoStageViewSet, | ||||||
|  |     DuoAdminDeviceViewSet, | ||||||
|  |     DuoDeviceViewSet, | ||||||
|  | ) | ||||||
| from authentik.stages.authenticator_static.api import ( | from authentik.stages.authenticator_static.api import ( | ||||||
|     AuthenticatorStaticStageViewSet, |     AuthenticatorStaticStageViewSet, | ||||||
|     StaticAdminDeviceViewSet, |     StaticAdminDeviceViewSet, | ||||||
| @ -97,24 +102,21 @@ from authentik.stages.user_delete.api import UserDeleteStageViewSet | |||||||
| from authentik.stages.user_login.api import UserLoginStageViewSet | from authentik.stages.user_login.api import UserLoginStageViewSet | ||||||
| from authentik.stages.user_logout.api import UserLogoutStageViewSet | from authentik.stages.user_logout.api import UserLogoutStageViewSet | ||||||
| from authentik.stages.user_write.api import UserWriteStageViewSet | from authentik.stages.user_write.api import UserWriteStageViewSet | ||||||
|  | from authentik.tenants.api import TenantViewSet | ||||||
|  |  | ||||||
| router = routers.DefaultRouter() | router = routers.DefaultRouter() | ||||||
|  |  | ||||||
| router.register("root/config", ConfigsViewSet, basename="configs") |  | ||||||
|  |  | ||||||
| router.register("admin/version", VersionViewSet, basename="admin_version") |  | ||||||
| router.register("admin/workers", WorkerViewSet, basename="admin_workers") |  | ||||||
| router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics") |  | ||||||
| router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") | ||||||
| router.register("admin/apps", AppsViewSet, basename="apps") | router.register("admin/apps", AppsViewSet, basename="apps") | ||||||
|  |  | ||||||
|  | router.register("core/authenticated_sessions", AuthenticatedSessionViewSet) | ||||||
| router.register("core/applications", ApplicationViewSet) | router.register("core/applications", ApplicationViewSet) | ||||||
| router.register("core/groups", GroupViewSet) | router.register("core/groups", GroupViewSet) | ||||||
| router.register("core/users", UserViewSet) | router.register("core/users", UserViewSet) | ||||||
| router.register("core/user_consent", UserConsentViewSet) | router.register("core/user_consent", UserConsentViewSet) | ||||||
| router.register("core/tokens", TokenViewSet) | router.register("core/tokens", TokenViewSet) | ||||||
|  | router.register("core/tenants", TenantViewSet) | ||||||
|  |  | ||||||
| router.register("outposts/outposts", OutpostViewSet) |  | ||||||
| router.register("outposts/instances", OutpostViewSet) | router.register("outposts/instances", OutpostViewSet) | ||||||
| router.register("outposts/service_connections/all", ServiceConnectionViewSet) | router.register("outposts/service_connections/all", ServiceConnectionViewSet) | ||||||
| router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) | router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) | ||||||
| @ -166,14 +168,31 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) | |||||||
| router.register("propertymappings/saml", SAMLPropertyMappingViewSet) | router.register("propertymappings/saml", SAMLPropertyMappingViewSet) | ||||||
| router.register("propertymappings/scope", ScopeMappingViewSet) | router.register("propertymappings/scope", ScopeMappingViewSet) | ||||||
|  |  | ||||||
|  | router.register("authenticators/duo", DuoDeviceViewSet) | ||||||
| router.register("authenticators/static", StaticDeviceViewSet) | router.register("authenticators/static", StaticDeviceViewSet) | ||||||
| router.register("authenticators/totp", TOTPDeviceViewSet) | router.register("authenticators/totp", TOTPDeviceViewSet) | ||||||
| router.register("authenticators/webauthn", WebAuthnDeviceViewSet) | router.register("authenticators/webauthn", WebAuthnDeviceViewSet) | ||||||
| router.register("authenticators/admin/static", StaticAdminDeviceViewSet) | router.register( | ||||||
| router.register("authenticators/admin/totp", TOTPAdminDeviceViewSet) |     "authenticators/admin/duo", | ||||||
| router.register("authenticators/admin/webauthn", WebAuthnAdminDeviceViewSet) |     DuoAdminDeviceViewSet, | ||||||
|  |     basename="admin-duodevice", | ||||||
|  | ) | ||||||
|  | router.register( | ||||||
|  |     "authenticators/admin/static", | ||||||
|  |     StaticAdminDeviceViewSet, | ||||||
|  |     basename="admin-staticdevice", | ||||||
|  | ) | ||||||
|  | router.register( | ||||||
|  |     "authenticators/admin/totp", TOTPAdminDeviceViewSet, basename="admin-totpdevice" | ||||||
|  | ) | ||||||
|  | router.register( | ||||||
|  |     "authenticators/admin/webauthn", | ||||||
|  |     WebAuthnAdminDeviceViewSet, | ||||||
|  |     basename="admin-webauthndevice", | ||||||
|  | ) | ||||||
|  |  | ||||||
| router.register("stages/all", StageViewSet) | router.register("stages/all", StageViewSet) | ||||||
|  | router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet) | ||||||
| router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet) | router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet) | ||||||
| router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet) | router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet) | ||||||
| router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet) | router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet) | ||||||
| @ -196,32 +215,26 @@ router.register("stages/user_write", UserWriteStageViewSet) | |||||||
| router.register("stages/dummy", DummyStageViewSet) | router.register("stages/dummy", DummyStageViewSet) | ||||||
| router.register("policies/dummy", DummyPolicyViewSet) | router.register("policies/dummy", DummyPolicyViewSet) | ||||||
|  |  | ||||||
| info = openapi.Info( |  | ||||||
|     title="authentik API", |  | ||||||
|     default_version="v2beta", |  | ||||||
|     contact=openapi.Contact(email="hello@beryju.org"), |  | ||||||
|     license=openapi.License( |  | ||||||
|         name="GNU GPLv3", |  | ||||||
|         url="https://github.com/goauthentik/authentik/blob/master/LICENSE", |  | ||||||
|     ), |  | ||||||
| ) |  | ||||||
| SchemaView = get_schema_view(info, public=True, permission_classes=(AllowAny,)) |  | ||||||
|  |  | ||||||
| urlpatterns = ( | urlpatterns = ( | ||||||
|     [ |     [ | ||||||
|         path("", SwaggerView.as_view(), name="swagger"), |         path("", APIBrowserView.as_view(), name="schema-browser"), | ||||||
|     ] |     ] | ||||||
|     + router.urls |     + router.urls | ||||||
|     + [ |     + [ | ||||||
|  |         path( | ||||||
|  |             "admin/metrics/", | ||||||
|  |             AdministrationMetricsViewSet.as_view(), | ||||||
|  |             name="admin_metrics", | ||||||
|  |         ), | ||||||
|  |         path("admin/version/", VersionView.as_view(), name="admin_version"), | ||||||
|  |         path("admin/workers/", WorkerView.as_view(), name="admin_workers"), | ||||||
|  |         path("admin/system/", SystemView.as_view(), name="admin_system"), | ||||||
|  |         path("root/config/", ConfigView.as_view(), name="config"), | ||||||
|         path( |         path( | ||||||
|             "flows/executor/<slug:flow_slug>/", |             "flows/executor/<slug:flow_slug>/", | ||||||
|             FlowExecutorView.as_view(), |             FlowExecutorView.as_view(), | ||||||
|             name="flow-executor", |             name="flow-executor", | ||||||
|         ), |         ), | ||||||
|         re_path( |         path("schema/", SpectacularAPIView.as_view(), name="schema"), | ||||||
|             r"^swagger(?P<format>\.json|\.yaml)$", |  | ||||||
|             SchemaView.without_ui(cache_timeout=0), |  | ||||||
|             name="schema-json", |  | ||||||
|         ), |  | ||||||
|     ] |     ] | ||||||
| ) | ) | ||||||
|  | |||||||
| @ -5,18 +5,15 @@ from django.urls import reverse | |||||||
| from django.views.generic import TemplateView | from django.views.generic import TemplateView | ||||||
|  |  | ||||||
|  |  | ||||||
| class SwaggerView(TemplateView): | class APIBrowserView(TemplateView): | ||||||
|     """Show swagger view based on rapi-doc""" |     """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]: |     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||||
|         path = self.request.build_absolute_uri( |         path = self.request.build_absolute_uri( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:schema-json", |                 "authentik_api:schema", | ||||||
|                 kwargs={ |  | ||||||
|                     "format": ".json", |  | ||||||
|                 }, |  | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         return super().get_context_data(path=path, **kwargs) |         return super().get_context_data(path=path, **kwargs) | ||||||
|  | |||||||
| @ -1,14 +1,23 @@ | |||||||
| """Application API Views""" | """Application API Views""" | ||||||
| from typing import Optional |  | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.http.response import HttpResponseBadRequest | from django.http.response import HttpResponseBadRequest | ||||||
| from django.shortcuts import get_object_or_404 | from django.shortcuts import get_object_or_404 | ||||||
| from drf_yasg import openapi | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_yasg.utils import no_body, swagger_auto_schema | from drf_spectacular.utils import ( | ||||||
|  |     OpenApiParameter, | ||||||
|  |     OpenApiResponse, | ||||||
|  |     extend_schema, | ||||||
|  |     inline_serializer, | ||||||
|  | ) | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import SerializerMethodField | from rest_framework.fields import ( | ||||||
|  |     BooleanField, | ||||||
|  |     CharField, | ||||||
|  |     FileField, | ||||||
|  |     IntegerField, | ||||||
|  |     ReadOnlyField, | ||||||
|  | ) | ||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -20,9 +29,13 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.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.events.models import EventAction | ||||||
|  | from authentik.policies.api.exec import PolicyTestResultSerializer | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
|  | from authentik.policies.types import PolicyResult | ||||||
|  | from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -35,12 +48,10 @@ def user_app_cache_key(user_pk: str) -> str: | |||||||
| class ApplicationSerializer(ModelSerializer): | class ApplicationSerializer(ModelSerializer): | ||||||
|     """Application Serializer""" |     """Application Serializer""" | ||||||
|  |  | ||||||
|     launch_url = SerializerMethodField() |     launch_url = ReadOnlyField(source="get_launch_url") | ||||||
|     provider_obj = ProviderSerializer(source="get_provider", required=False) |     provider_obj = ProviderSerializer(source="get_provider", required=False) | ||||||
|  |  | ||||||
|     def get_launch_url(self, instance: Application) -> Optional[str]: |     meta_icon = ReadOnlyField(source="get_meta_icon") | ||||||
|         """Get generated launch URL""" |  | ||||||
|         return instance.get_launch_url() |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -58,9 +69,12 @@ class ApplicationSerializer(ModelSerializer): | |||||||
|             "meta_publisher", |             "meta_publisher", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|         ] |         ] | ||||||
|  |         extra_kwargs = { | ||||||
|  |             "meta_icon": {"read_only": True}, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationViewSet(ModelViewSet): | class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Application Viewset""" |     """Application Viewset""" | ||||||
|  |  | ||||||
|     queryset = Application.objects.all() |     queryset = Application.objects.all() | ||||||
| @ -92,36 +106,48 @@ class ApplicationViewSet(ModelViewSet): | |||||||
|                 applications.append(application) |                 applications.append(application) | ||||||
|         return applications |         return applications | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|  |         request=inline_serializer( | ||||||
|  |             "CheckAccessRequest", fields={"for_user": IntegerField(required=False)} | ||||||
|  |         ), | ||||||
|         responses={ |         responses={ | ||||||
|             204: "Access granted", |             200: PolicyTestResultSerializer(), | ||||||
|             403: "Access denied", |             404: OpenApiResponse(description="for_user user not found"), | ||||||
|         } |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["GET"]) |     @action(detail=True, methods=["POST"]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def check_access(self, request: Request, slug: str) -> Response: |     def check_access(self, request: Request, slug: str) -> Response: | ||||||
|         """Check access to a single application by slug""" |         """Check access to a single application by slug""" | ||||||
|         # Don't use self.get_object as that checks for view_application permission |         # Don't use self.get_object as that checks for view_application permission | ||||||
|         # which the user might not have, even if they have access |         # which the user might not have, even if they have access | ||||||
|         application = get_object_or_404(Application, slug=slug) |         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() |         engine.build() | ||||||
|         if engine.passing: |         result = engine.result | ||||||
|             return Response(status=204) |         response = PolicyTestResultSerializer(PolicyResult(False)) | ||||||
|         return Response(status=403) |         if result.passing: | ||||||
|  |             response = PolicyTestResultSerializer(PolicyResult(True)) | ||||||
|  |         if self.request.user.is_superuser: | ||||||
|  |             response = PolicyTestResultSerializer(result) | ||||||
|  |         return Response(response.data) | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         manual_parameters=[ |         parameters=[ | ||||||
|             openapi.Parameter( |             OpenApiParameter( | ||||||
|                 name="superuser_full_list", |                 name="superuser_full_list", | ||||||
|                 in_=openapi.IN_QUERY, |                 location=OpenApiParameter.QUERY, | ||||||
|                 type=openapi.TYPE_BOOLEAN, |                 type=OpenApiTypes.BOOL, | ||||||
|             ) |             ) | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|     def list(self, request: Request) -> Response: |     def list(self, request: Request) -> Response: | ||||||
|         """Custom list method that checks Policy based access instead of guardian""" |         """Custom list method that checks Policy based access instead of guardian""" | ||||||
|  |         self.request.session.pop(USER_LOGIN_AUTHENTICATED, None) | ||||||
|         queryset = self._filter_queryset_for_list(self.get_queryset()) |         queryset = self._filter_queryset_for_list(self.get_queryset()) | ||||||
|         self.paginate_queryset(queryset) |         self.paginate_queryset(queryset) | ||||||
|  |  | ||||||
| @ -151,17 +177,20 @@ class ApplicationViewSet(ModelViewSet): | |||||||
|         return self.get_paginated_response(serializer.data) |         return self.get_paginated_response(serializer.data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.change_application") |     @permission_required("authentik_core.change_application") | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         request_body=no_body, |         request={ | ||||||
|         manual_parameters=[ |             "multipart/form-data": inline_serializer( | ||||||
|             openapi.Parameter( |                 "SetIcon", | ||||||
|                 name="file", |                 fields={ | ||||||
|                 in_=openapi.IN_FORM, |                     "file": FileField(required=False), | ||||||
|                 type=openapi.TYPE_FILE, |                     "clear": BooleanField(default=False), | ||||||
|                 required=True, |                 }, | ||||||
|             ) |             ) | ||||||
|         ], |         }, | ||||||
|         responses={200: "Success", 400: "Bad request"}, |         responses={ | ||||||
|  |             200: OpenApiResponse(description="Success"), | ||||||
|  |             400: OpenApiResponse(description="Bad request"), | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     @action( |     @action( | ||||||
|         detail=True, |         detail=True, | ||||||
| @ -175,16 +204,46 @@ class ApplicationViewSet(ModelViewSet): | |||||||
|         """Set application icon""" |         """Set application icon""" | ||||||
|         app: Application = self.get_object() |         app: Application = self.get_object() | ||||||
|         icon = request.FILES.get("file", None) |         icon = request.FILES.get("file", None) | ||||||
|         if not icon: |         clear = request.data.get("clear", False) | ||||||
|             return HttpResponseBadRequest() |         if clear: | ||||||
|  |             # .delete() saves the model by default | ||||||
|  |             app.meta_icon.delete() | ||||||
|  |             return Response({}) | ||||||
|  |         if icon: | ||||||
|             app.meta_icon = icon |             app.meta_icon = icon | ||||||
|             app.save() |             app.save() | ||||||
|             return Response({}) |             return Response({}) | ||||||
|  |         return HttpResponseBadRequest() | ||||||
|  |  | ||||||
|  |     @permission_required("authentik_core.change_application") | ||||||
|  |     @extend_schema( | ||||||
|  |         request=inline_serializer("SetIconURL", fields={"url": CharField()}), | ||||||
|  |         responses={ | ||||||
|  |             200: OpenApiResponse(description="Success"), | ||||||
|  |             400: OpenApiResponse(description="Bad request"), | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     @action( | ||||||
|  |         detail=True, | ||||||
|  |         pagination_class=None, | ||||||
|  |         filter_backends=[], | ||||||
|  |         methods=["POST"], | ||||||
|  |     ) | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def set_icon_url(self, request: Request, slug: str): | ||||||
|  |         """Set application icon (as URL)""" | ||||||
|  |         app: Application = self.get_object() | ||||||
|  |         url = request.data.get("url", None) | ||||||
|  |         if url is None: | ||||||
|  |             return HttpResponseBadRequest() | ||||||
|  |         app.meta_icon.name = url | ||||||
|  |         app.save() | ||||||
|  |         return Response({}) | ||||||
|  |  | ||||||
|     @permission_required( |     @permission_required( | ||||||
|         "authentik_core.view_application", ["authentik_events.view_event"] |         "authentik_core.view_application", ["authentik_events.view_event"] | ||||||
|     ) |     ) | ||||||
|     @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)}) |     @extend_schema(responses={200: CoordinateSerializer(many=True)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def metrics(self, request: Request, slug: str): |     def metrics(self, request: Request, slug: str): | ||||||
|  | |||||||
							
								
								
									
										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.viewsets import ModelViewSet | ||||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
|  |  | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import is_dict | from authentik.core.api.utils import is_dict | ||||||
| from authentik.core.models import Group | from authentik.core.models import Group | ||||||
|  |  | ||||||
| @ -20,7 +21,7 @@ class GroupSerializer(ModelSerializer): | |||||||
|         fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] |         fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class GroupViewSet(ModelViewSet): | class GroupViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Group Viewset""" |     """Group Viewset""" | ||||||
|  |  | ||||||
|     queryset = Group.objects.all() |     queryset = Group.objects.all() | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| """PropertyMapping API Views""" | """PropertyMapping API Views""" | ||||||
| from json import dumps | from json import dumps | ||||||
|  |  | ||||||
| from drf_yasg import openapi | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_yasg.utils import swagger_auto_schema | from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| @ -14,6 +14,7 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField | |||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import ( | ||||||
|     MetaNameSerializer, |     MetaNameSerializer, | ||||||
|     PassiveSerializer, |     PassiveSerializer, | ||||||
| @ -65,6 +66,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri | |||||||
| class PropertyMappingViewSet( | class PropertyMappingViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|  |     UsedByMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -78,10 +80,10 @@ class PropertyMappingViewSet( | |||||||
|     filterset_fields = {"managed": ["isnull"]} |     filterset_fields = {"managed": ["isnull"]} | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self):  # pragma: no cover | ||||||
|         return PropertyMapping.objects.select_subclasses() |         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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable property-mapping types""" |         """Get all creatable property-mapping types""" | ||||||
| @ -100,14 +102,17 @@ class PropertyMappingViewSet( | |||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_propertymapping") |     @permission_required("authentik_core.view_propertymapping") | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         request_body=PolicyTestSerializer(), |         request=PolicyTestSerializer(), | ||||||
|         responses={200: PropertyMappingTestResultSerializer, 400: "Invalid parameters"}, |         responses={ | ||||||
|         manual_parameters=[ |             200: PropertyMappingTestResultSerializer, | ||||||
|             openapi.Parameter( |             400: OpenApiResponse(description="Invalid parameters"), | ||||||
|  |         }, | ||||||
|  |         parameters=[ | ||||||
|  |             OpenApiParameter( | ||||||
|                 name="format_result", |                 name="format_result", | ||||||
|                 in_=openapi.IN_QUERY, |                 location=OpenApiParameter.QUERY, | ||||||
|                 type=openapi.TYPE_BOOLEAN, |                 type=OpenApiTypes.BOOL, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """Provider API Views""" | """Provider API Views""" | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_yasg.utils import swagger_auto_schema | from drf_spectacular.utils import extend_schema | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import ReadOnlyField | 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.serializers import ModelSerializer, SerializerMethodField | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
| from authentik.lib.utils.reflection import all_subclasses | from authentik.lib.utils.reflection import all_subclasses | ||||||
| @ -22,7 +23,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|  |  | ||||||
|     component = SerializerMethodField() |     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""" |         """Get object component so that we know how to edit the object""" | ||||||
|         # pyright: reportGeneralTypeIssues=false |         # pyright: reportGeneralTypeIssues=false | ||||||
|         if obj.__class__ == Provider: |         if obj.__class__ == Provider: | ||||||
| @ -48,6 +49,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | |||||||
| class ProviderViewSet( | class ProviderViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|  |     UsedByMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -63,10 +65,10 @@ class ProviderViewSet( | |||||||
|         "application__name", |         "application__name", | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self):  # pragma: no cover | ||||||
|         return Provider.objects.select_subclasses() |         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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable provider types""" |         """Get all creatable provider types""" | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """Source API Views""" | """Source API Views""" | ||||||
| from typing import Iterable | from typing import Iterable | ||||||
|  |  | ||||||
| from drf_yasg.utils import swagger_auto_schema | from drf_spectacular.utils import extend_schema | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -10,6 +10,7 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField | |||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.models import Source | from authentik.core.models import Source | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| @ -24,7 +25,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|  |  | ||||||
|     component = SerializerMethodField() |     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""" |         """Get object component so that we know how to edit the object""" | ||||||
|         # pyright: reportGeneralTypeIssues=false |         # pyright: reportGeneralTypeIssues=false | ||||||
|         if obj.__class__ == Source: |         if obj.__class__ == Source: | ||||||
| @ -52,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
| class SourceViewSet( | class SourceViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|  |     UsedByMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -61,10 +63,10 @@ class SourceViewSet( | |||||||
|     serializer_class = SourceSerializer |     serializer_class = SourceSerializer | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self):  # pragma: no cover | ||||||
|         return Source.objects.select_subclasses() |         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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable source types""" |         """Get all creatable source types""" | ||||||
| @ -87,7 +89,7 @@ class SourceViewSet( | |||||||
|             ) |             ) | ||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |         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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def user_settings(self, request: Request) -> Response: |     def user_settings(self, request: Request) -> Response: | ||||||
|         """Get all sources the user can configure""" |         """Get all sources the user can configure""" | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """Tokens API Viewset""" | """Tokens API Viewset""" | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
| from drf_yasg.utils import swagger_auto_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -9,6 +9,7 @@ from rest_framework.serializers import ModelSerializer | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.users import UserSerializer | from authentik.core.api.users import UserSerializer | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import Token, TokenIntents | from authentik.core.models import Token, TokenIntents | ||||||
| @ -43,7 +44,7 @@ class TokenViewSerializer(PassiveSerializer): | |||||||
|     key = CharField(read_only=True) |     key = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenViewSet(ModelViewSet): | class TokenViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Token Viewset""" |     """Token Viewset""" | ||||||
|  |  | ||||||
|     lookup_field = "identifier" |     lookup_field = "identifier" | ||||||
| @ -67,10 +68,10 @@ class TokenViewSet(ModelViewSet): | |||||||
|         serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API) |         serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_token_key") |     @permission_required("authentik_core.view_token_key") | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: TokenViewSerializer(many=False), |             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=[]) |     @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.utils.http import urlencode | ||||||
| from django_filters.filters import BooleanFilter, CharFilter | from django_filters.filters import BooleanFilter, CharFilter | ||||||
| from django_filters.filterset import FilterSet | from django_filters.filterset import FilterSet | ||||||
| from drf_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 guardian.utils import get_anonymous_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, JSONField, SerializerMethodField | from rest_framework.fields import CharField, JSONField, SerializerMethodField | ||||||
| @ -25,6 +25,7 @@ from rest_framework_guardian.filters import ObjectPermissionsFilter | |||||||
| from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.groups import GroupSerializer | from authentik.core.api.groups import GroupSerializer | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | ||||||
| from authentik.core.middleware import ( | from authentik.core.middleware import ( | ||||||
|     SESSION_IMPERSONATE_ORIGINAL_USER, |     SESSION_IMPERSONATE_ORIGINAL_USER, | ||||||
| @ -32,7 +33,7 @@ from authentik.core.middleware import ( | |||||||
| ) | ) | ||||||
| from authentik.core.models import Token, TokenIntents, User | from authentik.core.models import Token, TokenIntents, User | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
| from authentik.flows.models import Flow, FlowDesignation | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserSerializer(ModelSerializer): | class UserSerializer(ModelSerializer): | ||||||
| @ -77,13 +78,13 @@ class UserMetricsSerializer(PassiveSerializer): | |||||||
|     logins_failed_per_1h = SerializerMethodField() |     logins_failed_per_1h = SerializerMethodField() | ||||||
|     authorizations_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, _): |     def get_logins_per_1h(self, _): | ||||||
|         """Get successful logins per hour for the last 24 hours""" |         """Get successful logins per hour for the last 24 hours""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
|         return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk) |         return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk) | ||||||
|  |  | ||||||
|     @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True)) |     @extend_schema_field(CoordinateSerializer(many=True)) | ||||||
|     def get_logins_failed_per_1h(self, _): |     def get_logins_failed_per_1h(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per hour for the last 24 hours""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
| @ -91,7 +92,7 @@ class UserMetricsSerializer(PassiveSerializer): | |||||||
|             action=EventAction.LOGIN_FAILED, context__username=user.username |             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, _): |     def get_authorizations_per_1h(self, _): | ||||||
|         """Get failed logins per hour for the last 24 hours""" |         """Get failed logins per hour for the last 24 hours""" | ||||||
|         user = self.context["user"] |         user = self.context["user"] | ||||||
| @ -131,7 +132,7 @@ class UsersFilter(FilterSet): | |||||||
|         fields = ["username", "name", "is_active", "is_superuser", "attributes"] |         fields = ["username", "name", "is_active", "is_superuser", "attributes"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserViewSet(ModelViewSet): | class UserViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """User Viewset""" |     """User Viewset""" | ||||||
|  |  | ||||||
|     queryset = User.objects.none() |     queryset = User.objects.none() | ||||||
| @ -139,10 +140,10 @@ class UserViewSet(ModelViewSet): | |||||||
|     search_fields = ["username", "name", "is_active"] |     search_fields = ["username", "name", "is_active"] | ||||||
|     filterset_class = UsersFilter |     filterset_class = UsersFilter | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self):  # pragma: no cover | ||||||
|         return User.objects.all().exclude(pk=get_anonymous_user().pk) |         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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name |     # pylint: disable=invalid-name | ||||||
|     def me(self, request: Request) -> Response: |     def me(self, request: Request) -> Response: | ||||||
| @ -158,7 +159,7 @@ class UserViewSet(ModelViewSet): | |||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) |     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) | ||||||
|     @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)}) |     @extend_schema(responses={200: UserMetricsSerializer(many=False)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def metrics(self, request: Request, pk: int) -> Response: |     def metrics(self, request: Request, pk: int) -> Response: | ||||||
| @ -169,15 +170,19 @@ class UserViewSet(ModelViewSet): | |||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.reset_user_password") |     @permission_required("authentik_core.reset_user_password") | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={"200": LinkSerializer(many=False), "404": "No recovery flow found."}, |         responses={ | ||||||
|  |             "200": LinkSerializer(many=False), | ||||||
|  |             "404": OpenApiResponse(description="No recovery flow found."), | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def recovery(self, request: Request, pk: int) -> Response: |     def recovery(self, request: Request, pk: int) -> Response: | ||||||
|         """Create a temporary link that a user can use to recover their accounts""" |         """Create a temporary link that a user can use to recover their accounts""" | ||||||
|  |         tenant: Tenant = request._request.tenant | ||||||
|         # Check that there is a recovery flow, if not return an error |         # Check that there is a recovery flow, if not return an error | ||||||
|         flow = Flow.with_policy(request, designation=FlowDesignation.RECOVERY) |         flow = tenant.flow_recovery | ||||||
|         if not flow: |         if not flow: | ||||||
|             raise Http404 |             raise Http404 | ||||||
|         user: User = self.get_object() |         user: User = self.get_object() | ||||||
| @ -188,7 +193,8 @@ class UserViewSet(ModelViewSet): | |||||||
|         ) |         ) | ||||||
|         querystring = urlencode({"token": token.key}) |         querystring = urlencode({"token": token.key}) | ||||||
|         link = request.build_absolute_uri( |         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}) |         return Response({"link": link}) | ||||||
|  |  | ||||||
|  | |||||||
| @ -28,6 +28,9 @@ class PassiveSerializer(Serializer): | |||||||
|     ) -> Model:  # pragma: no cover |     ) -> Model:  # pragma: no cover | ||||||
|         return Model() |         return Model() | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = Model | ||||||
|  |  | ||||||
|  |  | ||||||
| class MetaNameSerializer(PassiveSerializer): | class MetaNameSerializer(PassiveSerializer): | ||||||
|     """Add verbose names to response""" |     """Add verbose names to response""" | ||||||
|  | |||||||
| @ -2,6 +2,10 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
|  | from django.db import ProgrammingError | ||||||
|  |  | ||||||
|  | from authentik.core.signals import GAUGE_MODELS | ||||||
|  | from authentik.lib.utils.reflection import get_apps | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikCoreConfig(AppConfig): | class AuthentikCoreConfig(AppConfig): | ||||||
| @ -15,3 +19,12 @@ class AuthentikCoreConfig(AppConfig): | |||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.core.signals") |         import_module("authentik.core.signals") | ||||||
|         import_module("authentik.core.managed") |         import_module("authentik.core.managed") | ||||||
|  |         try: | ||||||
|  |             for app in get_apps(): | ||||||
|  |                 for model in app.get_models(): | ||||||
|  |                     GAUGE_MODELS.labels( | ||||||
|  |                         model_name=model._meta.model_name, | ||||||
|  |                         app=model._meta.app_label, | ||||||
|  |                     ).set(model.objects.count()) | ||||||
|  |         except ProgrammingError: | ||||||
|  |             pass | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from channels.generic.websocket import JsonWebsocketConsumer | |||||||
| from rest_framework.exceptions import AuthenticationFailed | from rest_framework.exceptions import AuthenticationFailed | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.auth import token_from_header | from authentik.api.authentication import token_from_header | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | |||||||
| @ -42,10 +42,14 @@ class RequestIDMiddleware: | |||||||
|         if not hasattr(request, "request_id"): |         if not hasattr(request, "request_id"): | ||||||
|             request_id = uuid4().hex |             request_id = uuid4().hex | ||||||
|             setattr(request, "request_id", request_id) |             setattr(request, "request_id", request_id) | ||||||
|             LOCAL.authentik = {"request_id": request_id} |             LOCAL.authentik = { | ||||||
|  |                 "request_id": request_id, | ||||||
|  |                 "host": request.get_host(), | ||||||
|  |             } | ||||||
|         response = self.get_response(request) |         response = self.get_response(request) | ||||||
|         response[RESPONSE_HEADER_ID] = request.request_id |         response[RESPONSE_HEADER_ID] = request.request_id | ||||||
|         del LOCAL.authentik["request_id"] |         del LOCAL.authentik["request_id"] | ||||||
|  |         del LOCAL.authentik["host"] | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -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 threadlocal has authentik defined, add request_id to log""" | ||||||
|     if hasattr(LOCAL, "authentik"): |     if hasattr(LOCAL, "authentik"): | ||||||
|         event_dict["request_id"] = LOCAL.authentik.get("request_id", "") |         event_dict["request_id"] = LOCAL.authentik.get("request_id", "") | ||||||
|  |         event_dict["host"] = LOCAL.authentik.get("host", "") | ||||||
|     return event_dict |     return event_dict | ||||||
|  | |||||||
							
								
								
									
										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 urllib.parse import urlencode | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
|  | import django.db.models.options as options | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.models import AbstractUser | from django.contrib.auth.models import AbstractUser | ||||||
| from django.contrib.auth.models import UserManager as DjangoUserManager | from django.contrib.auth.models import UserManager as DjangoUserManager | ||||||
|  | from django.core import validators | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db.models import Q, QuerySet | from django.db.models import Q, QuerySet | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| @ -23,11 +25,11 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.core.exceptions import PropertyMappingExpressionException | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
| from authentik.core.signals import password_changed | from authentik.core.signals import password_changed | ||||||
| from authentik.core.types import UILoginButton | from authentik.core.types import UILoginButton, UserSettingSerializer | ||||||
| from authentik.flows.challenge import Challenge |  | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.models import CreatedUpdatedModel, SerializerModel | from authentik.lib.models import CreatedUpdatedModel, SerializerModel | ||||||
|  | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.managed.models import ManagedModel | from authentik.managed.models import ManagedModel | ||||||
| from authentik.policies.models import PolicyBindingModel | from authentik.policies.models import PolicyBindingModel | ||||||
|  |  | ||||||
| @ -40,6 +42,9 @@ GRAVATAR_URL = "https://secure.gravatar.com" | |||||||
| DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",) | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_token_duration(): | def default_token_duration(): | ||||||
|     """Default duration a Token is valid""" |     """Default duration a Token is valid""" | ||||||
|     return now() + timedelta(minutes=30) |     return now() + timedelta(minutes=30) | ||||||
| @ -214,12 +219,28 @@ class Application(PolicyBindingModel): | |||||||
|         "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT |         "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     meta_launch_url = models.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/* |     # 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_description = models.TextField(default="", blank=True) | ||||||
|     meta_publisher = models.TextField(default="", blank=True) |     meta_publisher = models.TextField(default="", blank=True) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def get_meta_icon(self) -> Optional[str]: | ||||||
|  |         """Get the URL to the App Icon image. If the name is /static or starts with http | ||||||
|  |         it is returned as-is""" | ||||||
|  |         if not self.meta_icon: | ||||||
|  |             return None | ||||||
|  |         if self.meta_icon.name.startswith("http") or self.meta_icon.name.startswith( | ||||||
|  |             "/static" | ||||||
|  |         ): | ||||||
|  |             return self.meta_icon.name | ||||||
|  |         return self.meta_icon.url | ||||||
|  |  | ||||||
|     def get_launch_url(self) -> Optional[str]: |     def get_launch_url(self) -> Optional[str]: | ||||||
|         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" |         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" | ||||||
|         if self.meta_launch_url: |         if self.meta_launch_url: | ||||||
| @ -324,9 +345,9 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     @property |     @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 |         """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 |         return None | ||||||
|  |  | ||||||
|     def __str__(self): |     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 used to authenticate the User for API Access or confirm another Stage like Email.""" | ||||||
|  |  | ||||||
|     token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) |     token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|     identifier = models.SlugField(max_length=255) |     identifier = models.SlugField(max_length=255, unique=True) | ||||||
|     key = models.TextField(default=default_token_key) |     key = models.TextField(default=default_token_key) | ||||||
|     intent = models.TextField( |     intent = models.TextField( | ||||||
|         choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION |         choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION | ||||||
| @ -452,3 +473,33 @@ class PropertyMapping(SerializerModel, ManagedModel): | |||||||
|  |  | ||||||
|         verbose_name = _("Property Mapping") |         verbose_name = _("Property Mapping") | ||||||
|         verbose_name_plural = _("Property Mappings") |         verbose_name_plural = _("Property Mappings") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AuthenticatedSession(ExpiringModel): | ||||||
|  |     """Additional session class for authenticated users. Augments the standard django session | ||||||
|  |     to achieve the following: | ||||||
|  |         - Make it queryable by user | ||||||
|  |         - Have a direct connection to user objects | ||||||
|  |         - Allow users to view their own sessions and terminate them | ||||||
|  |         - Save structured and well-defined information. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     uuid = models.UUIDField(default=uuid4, primary_key=True) | ||||||
|  |  | ||||||
|  |     session_key = models.CharField(max_length=40) | ||||||
|  |     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||||
|  |  | ||||||
|  |     last_ip = models.TextField() | ||||||
|  |     last_user_agent = models.TextField(blank=True) | ||||||
|  |     last_used = models.DateTimeField(auto_now=True) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def from_request(request: HttpRequest, user: User) -> "AuthenticatedSession": | ||||||
|  |         """Create a new session from a http request""" | ||||||
|  |         return AuthenticatedSession( | ||||||
|  |             session_key=request.session.session_key, | ||||||
|  |             user=user, | ||||||
|  |             last_ip=get_client_ip(request), | ||||||
|  |             last_user_agent=request.META.get("HTTP_USER_AGENT", ""), | ||||||
|  |             expires=request.session.get_expiry_date(), | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -1,20 +1,38 @@ | |||||||
| """authentik core signals""" | """authentik core signals""" | ||||||
|  | from typing import TYPE_CHECKING | ||||||
|  |  | ||||||
|  | from django.contrib.auth.signals import user_logged_in, user_logged_out | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.core.signals import Signal | from django.core.signals import Signal | ||||||
|  | from django.db.models import Model | ||||||
| from django.db.models.signals import post_save | from django.db.models.signals import post_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
|  | from django.http.request import HttpRequest | ||||||
|  | from prometheus_client import Gauge | ||||||
|  |  | ||||||
| # Arguments: user: User, password: str | # Arguments: user: User, password: str | ||||||
| password_changed = Signal() | password_changed = Signal() | ||||||
|  |  | ||||||
|  | GAUGE_MODELS = Gauge( | ||||||
|  |     "authentik_models", "Count of various objects", ["model_name", "app"] | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from authentik.core.models import User | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def post_save_application(sender, instance, created: bool, **_): | def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||||
|     """Clear user's application cache upon application creation""" |     """Clear user's application cache upon application creation""" | ||||||
|     from authentik.core.api.applications import user_app_cache_key |     from authentik.core.api.applications import user_app_cache_key | ||||||
|     from authentik.core.models import Application |     from authentik.core.models import Application | ||||||
|  |  | ||||||
|  |     GAUGE_MODELS.labels( | ||||||
|  |         model_name=sender._meta.model_name, | ||||||
|  |         app=sender._meta.app_label, | ||||||
|  |     ).set(sender.objects.count()) | ||||||
|  |  | ||||||
|     if sender != Application: |     if sender != Application: | ||||||
|         return |         return | ||||||
|     if not created:  # pragma: no cover |     if not created:  # pragma: no cover | ||||||
| @ -22,3 +40,23 @@ def post_save_application(sender, instance, created: bool, **_): | |||||||
|     # Also delete user application cache |     # Also delete user application cache | ||||||
|     keys = cache.keys(user_app_cache_key("*")) |     keys = cache.keys(user_app_cache_key("*")) | ||||||
|     cache.delete_many(keys) |     cache.delete_many(keys) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(user_logged_in) | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): | ||||||
|  |     """Create an AuthenticatedSession from request""" | ||||||
|  |     from authentik.core.models import AuthenticatedSession | ||||||
|  |  | ||||||
|  |     AuthenticatedSession.from_request(request, user).save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(user_logged_out) | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): | ||||||
|  |     """Delete AuthenticatedSession if it exists""" | ||||||
|  |     from authentik.core.models import AuthenticatedSession | ||||||
|  |  | ||||||
|  |     AuthenticatedSession.objects.filter( | ||||||
|  |         session_key=request.session.session_key | ||||||
|  |     ).delete() | ||||||
|  | |||||||
| @ -7,8 +7,7 @@ | |||||||
|     <head> |     <head> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|         <title>{% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %}</title> |         <title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title> | ||||||
|         <link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> |  | ||||||
|         <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> |         <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}"> | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}"> | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block title %} | {% block title %} | ||||||
| {% trans 'End session' %} - {{ config.authentik.branding.title }} | {% trans 'End session' %} - {{ tenant.branding_title }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
| 
 | 
 | ||||||
| {% block card_title %} | {% block card_title %} | ||||||
| @ -4,7 +4,9 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| {% block head_before %} | {% block head_before %} | ||||||
|  | {% if flow.compatibility_mode %} | ||||||
| <script>ShadyDOM = { force: !navigator.webdriver };</script> | <script>ShadyDOM = { force: !navigator.webdriver };</script> | ||||||
|  | {% endif %} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
|  | |||||||
| @ -26,10 +26,7 @@ | |||||||
|     <div class="ak-login-container"> |     <div class="ak-login-container"> | ||||||
|         <header class="pf-c-login__header"> |         <header class="pf-c-login__header"> | ||||||
|             <div class="pf-c-brand ak-brand"> |             <div class="pf-c-brand ak-brand"> | ||||||
|                 <img src="{{ config.authentik.branding.logo }}" alt="authentik icon" /> |                 <img src="{{ tenant.branding_logo }}" alt="authentik icon" /> | ||||||
|                 {% if config.authentik.branding.title_show %} |  | ||||||
|                 <p>{{ config.authentik.branding.title }}</p> |  | ||||||
|                 {% endif %} |  | ||||||
|             </div> |             </div> | ||||||
|         </header> |         </header> | ||||||
|         {% block main_container %} |         {% block main_container %} | ||||||
| @ -49,12 +46,12 @@ | |||||||
|         <footer class="pf-c-login__footer"> |         <footer class="pf-c-login__footer"> | ||||||
|             <p></p> |             <p></p> | ||||||
|             <ul class="pf-c-list pf-m-inline"> |             <ul class="pf-c-list pf-m-inline"> | ||||||
|                 {% for link in config.authentik.footer_links %} |                 {% for link in footer_links %} | ||||||
|                 <li> |                 <li> | ||||||
|                     <a href="{{ link.href }}">{{ link.name }}</a> |                     <a href="{{ link.href }}">{{ link.name }}</a> | ||||||
|                 </li> |                 </li> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|                 {% if config.authentik.branding.title != "authentik" %} |                 {% if tenant.branding_title != "authentik" %} | ||||||
|                 <li> |                 <li> | ||||||
|                     <a href="https://goauthentik.io"> |                     <a href="https://goauthentik.io"> | ||||||
|                         {% trans 'Powered by authentik' %} |                         {% trans 'Powered by authentik' %} | ||||||
|  | |||||||
| @ -26,20 +26,26 @@ class TestApplicationsAPI(APITestCase): | |||||||
|     def test_check_access(self): |     def test_check_access(self): | ||||||
|         """Test check_access operation""" |         """Test check_access operation""" | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|         response = self.client.get( |         response = self.client.post( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:application-check-access", |                 "authentik_api:application-check-access", | ||||||
|                 kwargs={"slug": self.allowed.slug}, |                 kwargs={"slug": self.allowed.slug}, | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 204) |         self.assertEqual(response.status_code, 200) | ||||||
|         response = self.client.get( |         self.assertJSONEqual( | ||||||
|  |             force_str(response.content), {"messages": [], "passing": True} | ||||||
|  |         ) | ||||||
|  |         response = self.client.post( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:application-check-access", |                 "authentik_api:application-check-access", | ||||||
|                 kwargs={"slug": self.denied.slug}, |                 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): |     def test_list(self): | ||||||
|         """Test list operation without superuser_full_list""" |         """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() |     object_uid = CharField() | ||||||
|     component = CharField() |     component = CharField() | ||||||
|     title = CharField() |     title = CharField() | ||||||
|  |     configure_url = CharField() | ||||||
|  | |||||||
| @ -6,6 +6,8 @@ from django.views.generic import RedirectView | |||||||
| from django.views.generic.base import TemplateView | from django.views.generic.base import TemplateView | ||||||
|  |  | ||||||
| from authentik.core.views import impersonate | from authentik.core.views import impersonate | ||||||
|  | from authentik.core.views.interface import FlowInterfaceView | ||||||
|  | from authentik.core.views.session import EndSessionView | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path( |     path( | ||||||
| @ -32,7 +34,18 @@ urlpatterns = [ | |||||||
|     ), |     ), | ||||||
|     path( |     path( | ||||||
|         "if/flow/<slug:flow_slug>/", |         "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", |         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 typing import Any | ||||||
| 
 | 
 | ||||||
| from django.shortcuts import get_object_or_404 | from django.shortcuts import get_object_or_404 | ||||||
| from django.views.generic.base import TemplateView | from django.views.generic.base import TemplateView | ||||||
| 
 | 
 | ||||||
| from authentik.core.models import Application | 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""" |     """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]: |     def resolve_provider_application(self): | ||||||
|         context = super().get_context_data(**kwargs) |         self.application = get_object_or_404( | ||||||
| 
 |  | ||||||
|         context["application"] = get_object_or_404( |  | ||||||
|             Application, slug=self.kwargs["application_slug"] |             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 |         return context | ||||||
| @ -1,12 +1,14 @@ | |||||||
| """Crypto API Views""" | """Crypto API Views""" | ||||||
| import django_filters |  | ||||||
| from cryptography.hazmat.backends import default_backend | from cryptography.hazmat.backends import default_backend | ||||||
| from cryptography.hazmat.primitives.serialization import load_pem_private_key | from cryptography.hazmat.primitives.serialization import load_pem_private_key | ||||||
| from cryptography.x509 import load_pem_x509_certificate | from cryptography.x509 import load_pem_x509_certificate | ||||||
| from django.http.response import HttpResponse | from django.http.response import HttpResponse | ||||||
|  | from django.urls import reverse | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from drf_yasg import openapi | from django_filters import FilterSet | ||||||
| from drf_yasg.utils import swagger_auto_schema | from django_filters.filters import BooleanFilter | ||||||
|  | from drf_spectacular.types import OpenApiTypes | ||||||
|  | from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import ( | from rest_framework.fields import ( | ||||||
|     CharField, |     CharField, | ||||||
| @ -20,6 +22,7 @@ from rest_framework.serializers import ModelSerializer, ValidationError | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.crypto.builder import CertificateBuilder | from authentik.crypto.builder import CertificateBuilder | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| @ -33,6 +36,9 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|     cert_subject = SerializerMethodField() |     cert_subject = SerializerMethodField() | ||||||
|     private_key_available = SerializerMethodField() |     private_key_available = SerializerMethodField() | ||||||
|  |  | ||||||
|  |     certificate_download_url = SerializerMethodField() | ||||||
|  |     private_key_download_url = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_cert_subject(self, instance: CertificateKeyPair) -> str: |     def get_cert_subject(self, instance: CertificateKeyPair) -> str: | ||||||
|         """Get certificate subject as full rfc4514""" |         """Get certificate subject as full rfc4514""" | ||||||
|         return instance.certificate.subject.rfc4514_string() |         return instance.certificate.subject.rfc4514_string() | ||||||
| @ -41,6 +47,26 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|         """Show if this keypair has a private key configured or not""" |         """Show if this keypair has a private key configured or not""" | ||||||
|         return instance.key_data != "" and instance.key_data is not None |         return instance.key_data != "" and instance.key_data is not None | ||||||
|  |  | ||||||
|  |     def get_certificate_download_url(self, instance: CertificateKeyPair) -> str: | ||||||
|  |         """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: |     def validate_certificate_data(self, value: str) -> str: | ||||||
|         """Verify that input is a valid PEM x509 Certificate""" |         """Verify that input is a valid PEM x509 Certificate""" | ||||||
|         try: |         try: | ||||||
| @ -77,6 +103,8 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|             "cert_expiry", |             "cert_expiry", | ||||||
|             "cert_subject", |             "cert_subject", | ||||||
|             "private_key_available", |             "private_key_available", | ||||||
|  |             "certificate_download_url", | ||||||
|  |             "private_key_download_url", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "key_data": {"write_only": True}, |             "key_data": {"write_only": True}, | ||||||
| @ -100,10 +128,10 @@ class CertificateGenerationSerializer(PassiveSerializer): | |||||||
|     validity_days = IntegerField(initial=365) |     validity_days = IntegerField(initial=365) | ||||||
|  |  | ||||||
|  |  | ||||||
| class CertificateKeyPairFilter(django_filters.FilterSet): | class CertificateKeyPairFilter(FilterSet): | ||||||
|     """Filter for certificates""" |     """Filter for certificates""" | ||||||
|  |  | ||||||
|     has_key = django_filters.BooleanFilter( |     has_key = BooleanFilter( | ||||||
|         label="Only return certificate-key pairs with keys", method="filter_has_key" |         label="Only return certificate-key pairs with keys", method="filter_has_key" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
| @ -117,7 +145,7 @@ class CertificateKeyPairFilter(django_filters.FilterSet): | |||||||
|         fields = ["name"] |         fields = ["name"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class CertificateKeyPairViewSet(ModelViewSet): | class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """CertificateKeyPair Viewset""" |     """CertificateKeyPair Viewset""" | ||||||
|  |  | ||||||
|     queryset = CertificateKeyPair.objects.all() |     queryset = CertificateKeyPair.objects.all() | ||||||
| @ -125,9 +153,12 @@ class CertificateKeyPairViewSet(ModelViewSet): | |||||||
|     filterset_class = CertificateKeyPairFilter |     filterset_class = CertificateKeyPairFilter | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_crypto.add_certificatekeypair"]) |     @permission_required(None, ["authentik_crypto.add_certificatekeypair"]) | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         request_body=CertificateGenerationSerializer(), |         request=CertificateGenerationSerializer(), | ||||||
|         responses={200: CertificateKeyPairSerializer, 400: "Bad request"}, |         responses={ | ||||||
|  |             200: CertificateKeyPairSerializer, | ||||||
|  |             400: OpenApiResponse(description="Bad request"), | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=False, methods=["POST"]) |     @action(detail=False, methods=["POST"]) | ||||||
|     def generate(self, request: Request) -> Response: |     def generate(self, request: Request) -> Response: | ||||||
| @ -147,12 +178,12 @@ class CertificateKeyPairViewSet(ModelViewSet): | |||||||
|         serializer = self.get_serializer(instance) |         serializer = self.get_serializer(instance) | ||||||
|         return Response(serializer.data) |         return Response(serializer.data) | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         manual_parameters=[ |         parameters=[ | ||||||
|             openapi.Parameter( |             OpenApiParameter( | ||||||
|                 name="download", |                 name="download", | ||||||
|                 in_=openapi.IN_QUERY, |                 location=OpenApiParameter.QUERY, | ||||||
|                 type=openapi.TYPE_BOOLEAN, |                 type=OpenApiTypes.BOOL, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|         responses={200: CertificateDataSerializer(many=False)}, |         responses={200: CertificateDataSerializer(many=False)}, | ||||||
| @ -180,12 +211,12 @@ class CertificateKeyPairViewSet(ModelViewSet): | |||||||
|             CertificateDataSerializer({"data": certificate.certificate_data}).data |             CertificateDataSerializer({"data": certificate.certificate_data}).data | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         manual_parameters=[ |         parameters=[ | ||||||
|             openapi.Parameter( |             OpenApiParameter( | ||||||
|                 name="download", |                 name="download", | ||||||
|                 in_=openapi.IN_QUERY, |                 location=OpenApiParameter.QUERY, | ||||||
|                 type=openapi.TYPE_BOOLEAN, |                 type=OpenApiTypes.BOOL, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|         responses={200: CertificateDataSerializer(many=False)}, |         responses={200: CertificateDataSerializer(many=False)}, | ||||||
|  | |||||||
| @ -4,10 +4,14 @@ import datetime | |||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  |  | ||||||
|  | from authentik.core.api.used_by import DeleteAction | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.crypto.api import CertificateKeyPairSerializer | from authentik.crypto.api import CertificateKeyPairSerializer | ||||||
| from authentik.crypto.builder import CertificateBuilder | from authentik.crypto.builder import CertificateBuilder | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
|  | from authentik.flows.models import Flow | ||||||
|  | from authentik.providers.oauth2.generators import generate_client_secret | ||||||
|  | from authentik.providers.oauth2.models import OAuth2Provider | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestCrypto(TestCase): | class TestCrypto(TestCase): | ||||||
| @ -91,3 +95,35 @@ class TestCrypto(TestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(200, response.status_code) |         self.assertEqual(200, response.status_code) | ||||||
|         self.assertIn("Content-Disposition", response) |         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 | import django_filters | ||||||
| from django.db.models.aggregates import Count | from django.db.models.aggregates import Count | ||||||
| from django.db.models.fields.json import KeyTextTransform | from django.db.models.fields.json import KeyTextTransform | ||||||
| from drf_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 guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, DictField, IntegerField | from rest_framework.fields import CharField, DictField, IntegerField | ||||||
| @ -38,12 +39,6 @@ class EventSerializer(ModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventTopPerUserParams(PassiveSerializer): |  | ||||||
|     """Query params for top_per_user""" |  | ||||||
|  |  | ||||||
|     top_n = IntegerField(default=15) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventTopPerUserSerializer(PassiveSerializer): | class EventTopPerUserSerializer(PassiveSerializer): | ||||||
|     """Response object of Event's top_per_user""" |     """Response object of Event's top_per_user""" | ||||||
|  |  | ||||||
| @ -111,12 +106,19 @@ class EventViewSet(ReadOnlyModelViewSet): | |||||||
|     ] |     ] | ||||||
|     filterset_class = EventsFilter |     filterset_class = EventsFilter | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         method="GET", |         methods=["GET"], | ||||||
|         responses={200: EventTopPerUserSerializer(many=True)}, |         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): |     def top_per_user(self, request: Request): | ||||||
|         """Get the top_n events grouped by user count""" |         """Get the top_n events grouped by user count""" | ||||||
|         filtered_action = request.query_params.get("action", EventAction.LOGIN) |         filtered_action = request.query_params.get("action", EventAction.LOGIN) | ||||||
| @ -134,7 +136,7 @@ class EventViewSet(ReadOnlyModelViewSet): | |||||||
|             .order_by("-counted_events")[:top_n] |             .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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def actions(self, request: Request) -> Response: |     def actions(self, request: Request) -> Response: | ||||||
|         """Get all actions""" |         """Get all actions""" | ||||||
|  | |||||||
| @ -1,12 +1,13 @@ | |||||||
| """Notification API Views""" | """Notification API Views""" | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from guardian.utils import get_anonymous_user |  | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.fields import ReadOnlyField | from rest_framework.fields import ReadOnlyField | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
|  |  | ||||||
|  | from authentik.api.authorization import OwnerFilter, OwnerPermissions | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.events.api.event import EventSerializer | from authentik.events.api.event import EventSerializer | ||||||
| from authentik.events.models import Notification | from authentik.events.models import Notification | ||||||
|  |  | ||||||
| @ -35,6 +36,7 @@ class NotificationViewSet( | |||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.UpdateModelMixin, |     mixins.UpdateModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|  |     UsedByMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -49,12 +51,5 @@ class NotificationViewSet( | |||||||
|         "event", |         "event", | ||||||
|         "seen", |         "seen", | ||||||
|     ] |     ] | ||||||
|     filter_backends = [ |     permission_classes = [OwnerPermissions] | ||||||
|         DjangoFilterBackend, |     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] | ||||||
|         OrderingFilter, |  | ||||||
|         SearchFilter, |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     def get_queryset(self): |  | ||||||
|         user = self.request.user if self.request else get_anonymous_user() |  | ||||||
|         return Notification.objects.filter(user=user.pk) |  | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from rest_framework.serializers import ModelSerializer | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.groups import GroupSerializer | from authentik.core.api.groups import GroupSerializer | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.events.models import NotificationRule | from authentik.events.models import NotificationRule | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -24,7 +25,7 @@ class NotificationRuleSerializer(ModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotificationRuleViewSet(ModelViewSet): | class NotificationRuleViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """NotificationRule Viewset""" |     """NotificationRule Viewset""" | ||||||
|  |  | ||||||
|     queryset = NotificationRule.objects.all() |     queryset = NotificationRule.objects.all() | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """NotificationTransport API Views""" | """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.decorators import action | ||||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -8,6 +9,7 @@ from rest_framework.serializers import ModelSerializer, Serializer | |||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.events.models import ( | from authentik.events.models import ( | ||||||
|     Notification, |     Notification, | ||||||
|     NotificationSeverity, |     NotificationSeverity, | ||||||
| @ -22,7 +24,7 @@ class NotificationTransportSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     mode_verbose = SerializerMethodField() |     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 selected mode with a UI Label""" | ||||||
|         return TransportMode(instance.mode).label |         return TransportMode(instance.mode).label | ||||||
|  |  | ||||||
| @ -51,19 +53,19 @@ class NotificationTransportTestSerializer(Serializer): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotificationTransportViewSet(ModelViewSet): | class NotificationTransportViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """NotificationTransport Viewset""" |     """NotificationTransport Viewset""" | ||||||
|  |  | ||||||
|     queryset = NotificationTransport.objects.all() |     queryset = NotificationTransport.objects.all() | ||||||
|     serializer_class = NotificationTransportSerializer |     serializer_class = NotificationTransportSerializer | ||||||
|  |  | ||||||
|     @permission_required("authentik_events.change_notificationtransport") |     @permission_required("authentik_events.change_notificationtransport") | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: NotificationTransportTestSerializer(many=False), |             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"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"]) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
| @ -83,4 +85,4 @@ class NotificationTransportViewSet(ModelViewSet): | |||||||
|             response.is_valid() |             response.is_valid() | ||||||
|             return Response(response.data) |             return Response(response.data) | ||||||
|         except NotificationTransportError as exc: |         except NotificationTransportError as exc: | ||||||
|             return Response(str(exc.__cause__ or None), status=503) |             return Response(str(exc.__cause__ or None), status=500) | ||||||
|  | |||||||
| @ -1,7 +1,10 @@ | |||||||
| """authentik events app""" | """authentik events app""" | ||||||
|  | from datetime import timedelta | ||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
|  | from django.db import ProgrammingError | ||||||
|  | from django.utils.timezone import now | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikEventsConfig(AppConfig): | class AuthentikEventsConfig(AppConfig): | ||||||
| @ -13,3 +16,12 @@ class AuthentikEventsConfig(AppConfig): | |||||||
|  |  | ||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.events.signals") |         import_module("authentik.events.signals") | ||||||
|  |         try: | ||||||
|  |             from authentik.events.models import Event | ||||||
|  |  | ||||||
|  |             date_from = now() - timedelta(days=1) | ||||||
|  |  | ||||||
|  |             for event in Event.objects.filter(created__gte=date_from): | ||||||
|  |                 event._set_prom_metrics() | ||||||
|  |         except ProgrammingError: | ||||||
|  |             pass | ||||||
|  | |||||||
| @ -1,7 +1,12 @@ | |||||||
| """events GeoIP Reader""" | """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.database import Reader | ||||||
|  | from geoip2.errors import GeoIP2Error | ||||||
|  | from geoip2.models import City | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| @ -9,17 +14,78 @@ from authentik.lib.config import CONFIG | |||||||
| LOGGER = get_logger() | 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""" |         """Get GeoIP Reader, if configured, otherwise none""" | ||||||
|         path = CONFIG.y("authentik.geoip") |         path = CONFIG.y("authentik.geoip") | ||||||
|         if path == "" or not path: |         if path == "" or not path: | ||||||
|         return None |             return | ||||||
|         try: |         try: | ||||||
|             reader = Reader(path) |             reader = Reader(path) | ||||||
|         LOGGER.info("Enabled GeoIP support") |             LOGGER.info("Loaded GeoIP database") | ||||||
|         return reader |             self.__reader = reader | ||||||
|     except OSError: |             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 |             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.http import HttpRequest | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from geoip2.errors import GeoIP2Error | from prometheus_client import Gauge | ||||||
| from requests import RequestException, post | from requests import RequestException, post | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -28,6 +28,11 @@ from authentik.policies.models import PolicyBindingModel | |||||||
| from authentik.stages.email.utils import TemplateEmailMessage | from authentik.stages.email.utils import TemplateEmailMessage | ||||||
|  |  | ||||||
| LOGGER = get_logger("authentik.events") | LOGGER = get_logger("authentik.events") | ||||||
|  | GAUGE_EVENTS = Gauge( | ||||||
|  |     "authentik_events", | ||||||
|  |     "Events in authentik", | ||||||
|  |     ["action", "user_username", "app", "client_ip"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def default_event_duration(): | def default_event_duration(): | ||||||
| @ -72,6 +77,7 @@ class EventAction(models.TextChoices): | |||||||
|     MODEL_CREATED = "model_created" |     MODEL_CREATED = "model_created" | ||||||
|     MODEL_UPDATED = "model_updated" |     MODEL_UPDATED = "model_updated" | ||||||
|     MODEL_DELETED = "model_deleted" |     MODEL_DELETED = "model_deleted" | ||||||
|  |     EMAIL_SENT = "email_sent" | ||||||
|  |  | ||||||
|     UPDATE_AVAILABLE = "update_available" |     UPDATE_AVAILABLE = "update_available" | ||||||
|  |  | ||||||
| @ -143,7 +149,7 @@ class Event(ExpiringModel): | |||||||
|                     request.session[SESSION_IMPERSONATE_USER] |                     request.session[SESSION_IMPERSONATE_USER] | ||||||
|                 ) |                 ) | ||||||
|         # User 255.255.255.255 as fallback if IP cannot be determined |         # User 255.255.255.255 as fallback if IP cannot be determined | ||||||
|         self.client_ip = get_client_ip(request) or "255.255.255.255" |         self.client_ip = get_client_ip(request) | ||||||
|         # Apply GeoIP Data, when enabled |         # Apply GeoIP Data, when enabled | ||||||
|         self.with_geoip() |         self.with_geoip() | ||||||
|         # If there's no app set, we get it from the requests too |         # If there's no app set, we get it from the requests too | ||||||
| @ -152,22 +158,20 @@ class Event(ExpiringModel): | |||||||
|         self.save() |         self.save() | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|     def with_geoip(self): |     def with_geoip(self):  # pragma: no cover | ||||||
|         """Apply GeoIP Data, when enabled""" |         """Apply GeoIP Data, when enabled""" | ||||||
|         if not GEOIP_READER: |         city = GEOIP_READER.city_dict(self.client_ip) | ||||||
|  |         if not city: | ||||||
|             return |             return | ||||||
|         try: |         self.context["geo"] = city | ||||||
|             response = GEOIP_READER.city(self.client_ip) |  | ||||||
|             self.context["geo"] = { |     def _set_prom_metrics(self): | ||||||
|                 "continent": response.continent.code, |         GAUGE_EVENTS.labels( | ||||||
|                 "country": response.country.iso_code, |             action=self.action, | ||||||
|                 "lat": response.location.latitude, |             user_username=self.user.get("username"), | ||||||
|                 "long": response.location.longitude, |             app=self.app, | ||||||
|             } |             client_ip=self.client_ip, | ||||||
|             if response.city.name: |         ).set(self.created.timestamp()) | ||||||
|                 self.context["geo"]["city"] = response.city.name |  | ||||||
|         except GeoIP2Error as exc: |  | ||||||
|             LOGGER.warning("Failed to add geoIP Data to event", exc=exc) |  | ||||||
|  |  | ||||||
|     def save(self, *args, **kwargs): |     def save(self, *args, **kwargs): | ||||||
|         if self._state.adding: |         if self._state.adding: | ||||||
| @ -178,7 +182,8 @@ class Event(ExpiringModel): | |||||||
|                 client_ip=self.client_ip, |                 client_ip=self.client_ip, | ||||||
|                 user=self.user, |                 user=self.user, | ||||||
|             ) |             ) | ||||||
|         return super().save(*args, **kwargs) |         super().save(*args, **kwargs) | ||||||
|  |         self._set_prom_metrics() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def summary(self) -> str: |     def summary(self) -> str: | ||||||
|  | |||||||
| @ -2,14 +2,22 @@ | |||||||
| from dataclasses import dataclass, field | from dataclasses import dataclass, field | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from enum import Enum | from enum import Enum | ||||||
|  | from timeit import default_timer | ||||||
| from traceback import format_tb | from traceback import format_tb | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from celery import Task | from celery import Task | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
|  | from prometheus_client import Gauge | ||||||
|  |  | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  | GAUGE_TASKS = Gauge( | ||||||
|  |     "authentik_system_tasks", | ||||||
|  |     "System tasks and their status", | ||||||
|  |     ["task_name", "task_uid", "status"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TaskResultStatus(Enum): | class TaskResultStatus(Enum): | ||||||
|     """Possible states of tasks""" |     """Possible states of tasks""" | ||||||
| @ -43,7 +51,9 @@ class TaskInfo: | |||||||
|     """Info about a task run""" |     """Info about a task run""" | ||||||
|  |  | ||||||
|     task_name: str |     task_name: str | ||||||
|     finish_timestamp: datetime |     start_timestamp: float | ||||||
|  |     finish_timestamp: float | ||||||
|  |     finish_time: datetime | ||||||
|  |  | ||||||
|     result: TaskResult |     result: TaskResult | ||||||
|  |  | ||||||
| @ -73,12 +83,28 @@ class TaskInfo: | |||||||
|         """Delete task info from cache""" |         """Delete task info from cache""" | ||||||
|         return cache.delete(f"task_{self.task_name}") |         return cache.delete(f"task_{self.task_name}") | ||||||
|  |  | ||||||
|  |     def set_prom_metrics(self): | ||||||
|  |         """Update prometheus metrics""" | ||||||
|  |         start = default_timer() | ||||||
|  |         if hasattr(self, "start_timestamp"): | ||||||
|  |             start = self.start_timestamp | ||||||
|  |         try: | ||||||
|  |             duration = max(self.finish_timestamp - start, 0) | ||||||
|  |         except TypeError: | ||||||
|  |             duration = 0 | ||||||
|  |         GAUGE_TASKS.labels( | ||||||
|  |             task_name=self.task_name, | ||||||
|  |             task_uid=self.result.uid or "", | ||||||
|  |             status=self.result.status, | ||||||
|  |         ).set(duration) | ||||||
|  |  | ||||||
|     def save(self, timeout_hours=6): |     def save(self, timeout_hours=6): | ||||||
|         """Save task into cache""" |         """Save task into cache""" | ||||||
|         key = f"task_{self.task_name}" |         key = f"task_{self.task_name}" | ||||||
|         if self.result.uid: |         if self.result.uid: | ||||||
|             key += f"_{self.result.uid}" |             key += f"_{self.result.uid}" | ||||||
|             self.task_name += f"_{self.result.uid}" |             self.task_name += f"_{self.result.uid}" | ||||||
|  |         self.set_prom_metrics() | ||||||
|         cache.set(key, self, timeout=timeout_hours * 60 * 60) |         cache.set(key, self, timeout=timeout_hours * 60 * 60) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -98,6 +124,7 @@ class MonitoredTask(Task): | |||||||
|         self._uid = None |         self._uid = None | ||||||
|         self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[]) |         self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[]) | ||||||
|         self.result_timeout_hours = 6 |         self.result_timeout_hours = 6 | ||||||
|  |         self.start = default_timer() | ||||||
|  |  | ||||||
|     def set_uid(self, uid: str): |     def set_uid(self, uid: str): | ||||||
|         """Set UID, so in the case of an unexpected error its saved correctly""" |         """Set UID, so in the case of an unexpected error its saved correctly""" | ||||||
| @ -117,7 +144,9 @@ class MonitoredTask(Task): | |||||||
|             TaskInfo( |             TaskInfo( | ||||||
|                 task_name=self.__name__, |                 task_name=self.__name__, | ||||||
|                 task_description=self.__doc__, |                 task_description=self.__doc__, | ||||||
|                 finish_timestamp=datetime.now(), |                 start_timestamp=self.start, | ||||||
|  |                 finish_timestamp=default_timer(), | ||||||
|  |                 finish_time=datetime.now(), | ||||||
|                 result=self._result, |                 result=self._result, | ||||||
|                 task_call_module=self.__module__, |                 task_call_module=self.__module__, | ||||||
|                 task_call_func=self.__name__, |                 task_call_func=self.__name__, | ||||||
| @ -133,7 +162,9 @@ class MonitoredTask(Task): | |||||||
|         TaskInfo( |         TaskInfo( | ||||||
|             task_name=self.__name__, |             task_name=self.__name__, | ||||||
|             task_description=self.__doc__, |             task_description=self.__doc__, | ||||||
|             finish_timestamp=datetime.now(), |             start_timestamp=self.start, | ||||||
|  |             finish_timestamp=default_timer(), | ||||||
|  |             finish_time=datetime.now(), | ||||||
|             result=self._result, |             result=self._result, | ||||||
|             task_call_module=self.__module__, |             task_call_module=self.__module__, | ||||||
|             task_call_func=self.__name__, |             task_call_func=self.__name__, | ||||||
| @ -151,3 +182,7 @@ class MonitoredTask(Task): | |||||||
|  |  | ||||||
|     def run(self, *args, **kwargs): |     def run(self, *args, **kwargs): | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | for task in TaskInfo.all().values(): | ||||||
|  |     task.set_prom_metrics() | ||||||
|  | |||||||
							
								
								
									
										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.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.flows.api.stages import StageSerializer | from authentik.flows.api.stages import StageSerializer | ||||||
| from authentik.flows.models import FlowStageBinding | from authentik.flows.models import FlowStageBinding | ||||||
|  |  | ||||||
| @ -27,7 +28,7 @@ class FlowStageBindingSerializer(ModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowStageBindingViewSet(ModelViewSet): | class FlowStageBindingViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """FlowStageBinding Viewset""" |     """FlowStageBinding Viewset""" | ||||||
|  |  | ||||||
|     queryset = FlowStageBinding.objects.all() |     queryset = FlowStageBinding.objects.all() | ||||||
|  | |||||||
| @ -6,10 +6,11 @@ from django.db.models import Model | |||||||
| from django.http.response import HttpResponseBadRequest, JsonResponse | from django.http.response import HttpResponseBadRequest, JsonResponse | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from drf_yasg import openapi | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_yasg.utils import no_body, swagger_auto_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
|  | from rest_framework.fields import BooleanField, FileField, ReadOnlyField | ||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -23,6 +24,7 @@ from rest_framework.viewsets import ModelViewSet | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import CacheSerializer, LinkSerializer | from authentik.core.api.utils import CacheSerializer, LinkSerializer | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
| @ -41,10 +43,18 @@ class FlowSerializer(ModelSerializer): | |||||||
|  |  | ||||||
|     cache_count = SerializerMethodField() |     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""" |         """Get count of cached flows""" | ||||||
|         return len(cache.keys(f"{cache_key(flow)}*")) |         return len(cache.keys(f"{cache_key(flow)}*")) | ||||||
|  |  | ||||||
|  |     def get_export_url(self, flow: Flow) -> str: | ||||||
|  |         """Get export URL for flow""" | ||||||
|  |         return reverse("authentik_api:flow-export", kwargs={"slug": flow.slug}) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Flow |         model = Flow | ||||||
| @ -60,7 +70,12 @@ class FlowSerializer(ModelSerializer): | |||||||
|             "policies", |             "policies", | ||||||
|             "cache_count", |             "cache_count", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|  |             "compatibility_mode", | ||||||
|  |             "export_url", | ||||||
|         ] |         ] | ||||||
|  |         extra_kwargs = { | ||||||
|  |             "background": {"read_only": True}, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowDiagramSerializer(Serializer): | class FlowDiagramSerializer(Serializer): | ||||||
| @ -87,7 +102,7 @@ class DiagramElement: | |||||||
|         return f"{self.identifier}=>{self.type}: {self.rest}" |         return f"{self.identifier}=>{self.type}: {self.rest}" | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowViewSet(ModelViewSet): | class FlowViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Flow Viewset""" |     """Flow Viewset""" | ||||||
|  |  | ||||||
|     queryset = Flow.objects.all() |     queryset = Flow.objects.all() | ||||||
| @ -97,16 +112,19 @@ class FlowViewSet(ModelViewSet): | |||||||
|     filterset_fields = ["flow_uuid", "name", "slug", "designation"] |     filterset_fields = ["flow_uuid", "name", "slug", "designation"] | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_flows.view_flow_cache"]) |     @permission_required(None, ["authentik_flows.view_flow_cache"]) | ||||||
|     @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) |     @extend_schema(responses={200: CacheSerializer(many=False)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def cache_info(self, request: Request) -> Response: |     def cache_info(self, request: Request) -> Response: | ||||||
|         """Info about cached flows""" |         """Info about cached flows""" | ||||||
|         return Response(data={"count": len(cache.keys("flow_*"))}) |         return Response(data={"count": len(cache.keys("flow_*"))}) | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_flows.clear_flow_cache"]) |     @permission_required(None, ["authentik_flows.clear_flow_cache"]) | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         request_body=no_body, |         request=OpenApiTypes.NONE, | ||||||
|         responses={204: "Successfully cleared cache", 400: "Bad request"}, |         responses={ | ||||||
|  |             204: OpenApiResponse(description="Successfully cleared cache"), | ||||||
|  |             400: OpenApiResponse(description="Bad request"), | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=False, methods=["POST"]) |     @action(detail=False, methods=["POST"]) | ||||||
|     def cache_clear(self, request: Request) -> Response: |     def cache_clear(self, request: Request) -> Response: | ||||||
| @ -133,17 +151,16 @@ class FlowViewSet(ModelViewSet): | |||||||
|             "authentik_stages_prompt.change_prompt", |             "authentik_stages_prompt.change_prompt", | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         request_body=no_body, |         request={ | ||||||
|         manual_parameters=[ |             "multipart/form-data": inline_serializer( | ||||||
|             openapi.Parameter( |                 "SetIcon", fields={"file": FileField()} | ||||||
|                 name="file", |  | ||||||
|                 in_=openapi.IN_FORM, |  | ||||||
|                 type=openapi.TYPE_FILE, |  | ||||||
|                 required=True, |  | ||||||
|             ) |             ) | ||||||
|         ], |         }, | ||||||
|         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,)) |     @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,)) | ||||||
|     def import_flow(self, request: Request) -> Response: |     def import_flow(self, request: Request) -> Response: | ||||||
| @ -157,8 +174,8 @@ class FlowViewSet(ModelViewSet): | |||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|         successful = importer.apply() |         successful = importer.apply() | ||||||
|         if not successful: |         if not successful: | ||||||
|             return Response(status=204) |  | ||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|  |         return Response(status=204) | ||||||
|  |  | ||||||
|     @permission_required( |     @permission_required( | ||||||
|         "authentik_flows.export_flow", |         "authentik_flows.export_flow", | ||||||
| @ -171,11 +188,9 @@ class FlowViewSet(ModelViewSet): | |||||||
|             "authentik_stages_prompt.view_prompt", |             "authentik_stages_prompt.view_prompt", | ||||||
|         ], |         ], | ||||||
|     ) |     ) | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             "200": openapi.Response( |             "200": OpenApiResponse(response=OpenApiTypes.BINARY), | ||||||
|                 "File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE) |  | ||||||
|             ), |  | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
| @ -188,7 +203,7 @@ class FlowViewSet(ModelViewSet): | |||||||
|         response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' |         response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: FlowDiagramSerializer()}) |     @extend_schema(responses={200: FlowDiagramSerializer()}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) |     @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def diagram(self, request: Request, slug: str) -> Response: |     def diagram(self, request: Request, slug: str) -> Response: | ||||||
| @ -259,17 +274,20 @@ class FlowViewSet(ModelViewSet): | |||||||
|         return Response({"diagram": diagram}) |         return Response({"diagram": diagram}) | ||||||
|  |  | ||||||
|     @permission_required("authentik_flows.change_flow") |     @permission_required("authentik_flows.change_flow") | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         request_body=no_body, |         request={ | ||||||
|         manual_parameters=[ |             "multipart/form-data": inline_serializer( | ||||||
|             openapi.Parameter( |                 "SetIcon", | ||||||
|                 name="file", |                 fields={ | ||||||
|                 in_=openapi.IN_FORM, |                     "file": FileField(required=False), | ||||||
|                 type=openapi.TYPE_FILE, |                     "clear": BooleanField(default=False), | ||||||
|                 required=True, |                 }, | ||||||
|             ) |             ) | ||||||
|         ], |         }, | ||||||
|         responses={200: "Success", 400: "Bad request"}, |         responses={ | ||||||
|  |             200: OpenApiResponse(description="Success"), | ||||||
|  |             400: OpenApiResponse(description="Bad request"), | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     @action( |     @action( | ||||||
|         detail=True, |         detail=True, | ||||||
| @ -281,16 +299,49 @@ class FlowViewSet(ModelViewSet): | |||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def set_background(self, request: Request, slug: str): |     def set_background(self, request: Request, slug: str): | ||||||
|         """Set Flow background""" |         """Set Flow background""" | ||||||
|         app: Flow = self.get_object() |         flow: Flow = self.get_object() | ||||||
|         icon = request.FILES.get("file", None) |         background = request.FILES.get("file", None) | ||||||
|         if not icon: |         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() |         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({}) |         return Response({}) | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={200: LinkSerializer(many=False), 400: "Flow not applicable"}, |         responses={ | ||||||
|  |             200: LinkSerializer(many=False), | ||||||
|  |             400: OpenApiResponse(description="Flow not applicable"), | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|  | |||||||
| @ -1,16 +1,17 @@ | |||||||
| """Flow Stage API Views""" | """Flow Stage API Views""" | ||||||
| from typing import Iterable | 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 import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import BooleanField |  | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
| from authentik.flows.api.flows import FlowSerializer | from authentik.flows.api.flows import FlowSerializer | ||||||
| @ -20,12 +21,6 @@ from authentik.lib.utils.reflection import all_subclasses | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class StageUserSettingSerializer(UserSettingSerializer): |  | ||||||
|     """User settings but can include a configure flow""" |  | ||||||
|  |  | ||||||
|     configure_flow = BooleanField(required=False) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class StageSerializer(ModelSerializer, MetaNameSerializer): | class StageSerializer(ModelSerializer, MetaNameSerializer): | ||||||
|     """Stage Serializer""" |     """Stage Serializer""" | ||||||
|  |  | ||||||
| @ -55,6 +50,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer): | |||||||
| class StageViewSet( | class StageViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|  |     UsedByMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -65,10 +61,10 @@ class StageViewSet( | |||||||
|     search_fields = ["name"] |     search_fields = ["name"] | ||||||
|     filterset_fields = ["name"] |     filterset_fields = ["name"] | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self):  # pragma: no cover | ||||||
|         return Stage.objects.select_subclasses() |         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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable stage types""" |         """Get all creatable stage types""" | ||||||
| @ -86,7 +82,7 @@ class StageViewSet( | |||||||
|         data = sorted(data, key=lambda x: x["name"]) |         data = sorted(data, key=lambda x: x["name"]) | ||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |         return Response(TypeCreateSerializer(data, many=True).data) | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: StageUserSettingSerializer(many=True)}) |     @extend_schema(responses={200: UserSettingSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def user_settings(self, request: Request) -> Response: |     def user_settings(self, request: Request) -> Response: | ||||||
|         """Get all stages the user can configure""" |         """Get all stages the user can configure""" | ||||||
| @ -98,8 +94,9 @@ class StageViewSet( | |||||||
|                 continue |                 continue | ||||||
|             user_settings.initial_data["object_uid"] = str(stage.pk) |             user_settings.initial_data["object_uid"] = str(stage.pk) | ||||||
|             if hasattr(stage, "configure_flow"): |             if hasattr(stage, "configure_flow"): | ||||||
|                 user_settings.initial_data["configure_flow"] = bool( |                 user_settings.initial_data["configure_url"] = reverse( | ||||||
|                     stage.configure_flow |                     "authentik_flows:configure", | ||||||
|  |                     kwargs={"stage_uuid": stage.pk}, | ||||||
|                 ) |                 ) | ||||||
|             if not user_settings.is_valid(): |             if not user_settings.is_valid(): | ||||||
|                 LOGGER.warning(user_settings.errors) |                 LOGGER.warning(user_settings.errors) | ||||||
|  | |||||||
| @ -2,6 +2,9 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
|  | from django.db.utils import ProgrammingError | ||||||
|  |  | ||||||
|  | from authentik.lib.utils.reflection import all_subclasses | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikFlowsConfig(AppConfig): | class AuthentikFlowsConfig(AppConfig): | ||||||
| @ -14,3 +17,10 @@ class AuthentikFlowsConfig(AppConfig): | |||||||
|  |  | ||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.flows.signals") |         import_module("authentik.flows.signals") | ||||||
|  |         try: | ||||||
|  |             from authentik.flows.models import Stage | ||||||
|  |  | ||||||
|  |             for stage in all_subclasses(Stage): | ||||||
|  |                 _ = stage().type | ||||||
|  |         except ProgrammingError: | ||||||
|  |             pass | ||||||
|  | |||||||
| @ -28,6 +28,14 @@ class ErrorDetailSerializer(PassiveSerializer): | |||||||
|     code = CharField() |     code = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ContextualFlowInfo(PassiveSerializer): | ||||||
|  |     """Contextual flow information for a challenge""" | ||||||
|  |  | ||||||
|  |     title = CharField(required=False, allow_blank=True) | ||||||
|  |     background = CharField(required=False) | ||||||
|  |     cancel_url = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
| class Challenge(PassiveSerializer): | class Challenge(PassiveSerializer): | ||||||
|     """Challenge that gets sent to the client based on which stage |     """Challenge that gets sent to the client based on which stage | ||||||
|     is currently active""" |     is currently active""" | ||||||
| @ -35,9 +43,8 @@ class Challenge(PassiveSerializer): | |||||||
|     type = ChoiceField( |     type = ChoiceField( | ||||||
|         choices=[(x.value, x.name) for x in ChallengeTypes], |         choices=[(x.value, x.name) for x in ChallengeTypes], | ||||||
|     ) |     ) | ||||||
|     component = CharField(required=False) |     flow_info = ContextualFlowInfo(required=False) | ||||||
|     title = CharField(required=False) |     component = CharField(default="") | ||||||
|     background = CharField(required=False) |  | ||||||
|  |  | ||||||
|     response_errors = DictField( |     response_errors = DictField( | ||||||
|         child=ErrorDetailSerializer(many=True), allow_empty=True, required=False |         child=ErrorDetailSerializer(many=True), allow_empty=True, required=False | ||||||
| @ -48,18 +55,20 @@ class RedirectChallenge(Challenge): | |||||||
|     """Challenge type to redirect the client""" |     """Challenge type to redirect the client""" | ||||||
|  |  | ||||||
|     to = CharField() |     to = CharField() | ||||||
|  |     component = CharField(default="xak-flow-redirect") | ||||||
|  |  | ||||||
|  |  | ||||||
| class ShellChallenge(Challenge): | class ShellChallenge(Challenge): | ||||||
|     """Legacy challenge type to render HTML as-is""" |     """challenge type to render HTML as-is""" | ||||||
|  |  | ||||||
|     body = CharField() |     body = CharField() | ||||||
|  |     component = CharField(default="xak-flow-shell") | ||||||
|  |  | ||||||
|  |  | ||||||
| class WithUserInfoChallenge(Challenge): | class WithUserInfoChallenge(Challenge): | ||||||
|     """Challenge base which shows some user info""" |     """Challenge base which shows some user info""" | ||||||
|  |  | ||||||
|     pending_user = CharField() |     pending_user = CharField(allow_blank=True) | ||||||
|     pending_user_avatar = CharField() |     pending_user_avatar = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -67,6 +76,7 @@ class AccessDeniedChallenge(Challenge): | |||||||
|     """Challenge when a flow's active stage calls `stage_invalid()`.""" |     """Challenge when a flow's active stage calls `stage_invalid()`.""" | ||||||
|  |  | ||||||
|     error_message = CharField(required=False) |     error_message = CharField(required=False) | ||||||
|  |     component = CharField(default="ak-stage-access-denied") | ||||||
|  |  | ||||||
|  |  | ||||||
| class PermissionSerializer(PassiveSerializer): | class PermissionSerializer(PassiveSerializer): | ||||||
| @ -80,6 +90,7 @@ class ChallengeResponse(PassiveSerializer): | |||||||
|     """Base class for all challenge responses""" |     """Base class for all challenge responses""" | ||||||
|  |  | ||||||
|     stage: Optional["StageView"] |     stage: Optional["StageView"] | ||||||
|  |     component = CharField(default="xak-flow-response-default") | ||||||
|  |  | ||||||
|     def __init__(self, instance=None, data=None, **kwargs): |     def __init__(self, instance=None, data=None, **kwargs): | ||||||
|         self.stage = kwargs.pop("stage", None) |         self.stage = kwargs.pop("stage", None) | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ context["user_backend"] = "django.contrib.auth.backends.ModelBackend" | |||||||
| return True""" | return True""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     from authentik.stages.prompt.models import FieldTypes |     from authentik.stages.prompt.models import FieldTypes | ||||||
|  |  | ||||||
|     User = apps.get_model("authentik_core", "User") |     User = apps.get_model("authentik_core", "User") | ||||||
| @ -52,20 +52,20 @@ def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) | |||||||
|  |  | ||||||
|     # Create a policy that sets the flow's user |     # Create a policy that sets the flow's user | ||||||
|     prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( |     prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( | ||||||
|         name="default-oob-prefill-user", |         name="default-oobe-prefill-user", | ||||||
|         defaults={"expression": PREFILL_POLICY_EXPRESSION}, |         defaults={"expression": PREFILL_POLICY_EXPRESSION}, | ||||||
|     ) |     ) | ||||||
|     password_usable_policy, _ = ExpressionPolicy.objects.using( |     password_usable_policy, _ = ExpressionPolicy.objects.using( | ||||||
|         db_alias |         db_alias | ||||||
|     ).update_or_create( |     ).update_or_create( | ||||||
|         name="default-oob-password-usable", |         name="default-oobe-password-usable", | ||||||
|         defaults={"expression": PW_USABLE_POLICY_EXPRESSION}, |         defaults={"expression": PW_USABLE_POLICY_EXPRESSION}, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     prompt_header, _ = Prompt.objects.using(db_alias).update_or_create( |     prompt_header, _ = Prompt.objects.using(db_alias).update_or_create( | ||||||
|         field_key="oob-header-text", |         field_key="oobe-header-text", | ||||||
|         defaults={ |         defaults={ | ||||||
|             "label": "oob-header-text", |             "label": "oobe-header-text", | ||||||
|             "type": FieldTypes.STATIC, |             "type": FieldTypes.STATIC, | ||||||
|             "placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.", |             "placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.", | ||||||
|             "order": 100, |             "order": 100, | ||||||
| @ -84,7 +84,7 @@ def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) | |||||||
|     password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat") |     password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat") | ||||||
|  |  | ||||||
|     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( |     prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( | ||||||
|         name="default-oob-password", |         name="default-oobe-password", | ||||||
|     ) |     ) | ||||||
|     prompt_stage.fields.set( |     prompt_stage.fields.set( | ||||||
|         [prompt_header, prompt_email, password_first, password_second] |         [prompt_header, prompt_email, password_first, password_second] | ||||||
| @ -102,7 +102,7 @@ def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) | |||||||
|         slug="initial-setup", |         slug="initial-setup", | ||||||
|         designation=FlowDesignation.STAGE_CONFIGURATION, |         designation=FlowDesignation.STAGE_CONFIGURATION, | ||||||
|         defaults={ |         defaults={ | ||||||
|             "name": "default-oob-setup", |             "name": "default-oobe-setup", | ||||||
|             "title": "Welcome to authentik!", |             "title": "Welcome to authentik!", | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
| @ -146,5 +146,5 @@ class Migration(migrations.Migration): | |||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     operations = [ |     operations = [ | ||||||
|         migrations.RunPython(create_default_oob_flow), |         migrations.RunPython(create_default_oobe_flow), | ||||||
|     ] |     ] | ||||||
|  | |||||||
							
								
								
									
										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): |     def __str__(self): | ||||||
|         if hasattr(self, "__in_memory_type"): |         if hasattr(self, "__in_memory_type"): | ||||||
|             return f"In-memory Stage {getattr(self, '__in_memory_type')}" |             return f"In-memory Stage {getattr(self, '__in_memory_type')}" | ||||||
|         return self.name |         return f"Stage {self.name}" | ||||||
|  |  | ||||||
|  |  | ||||||
| def in_memory_stage(view: Type["StageView"]) -> Stage: | def in_memory_stage(view: Type["StageView"]) -> Stage: | ||||||
| @ -110,11 +110,31 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|  |  | ||||||
|     background = models.FileField( |     background = models.FileField( | ||||||
|         upload_to="flow-backgrounds/", |         upload_to="flow-backgrounds/", | ||||||
|         default="../static/dist/assets/images/flow_background.jpg", |         default=None, | ||||||
|         blank=True, |         null=True, | ||||||
|         help_text=_("Background shown during execution"), |         help_text=_("Background shown during execution"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     compatibility_mode = models.BooleanField( | ||||||
|  |         default=True, | ||||||
|  |         help_text=_( | ||||||
|  |             "Enable compatibility mode, increases compatibility with " | ||||||
|  |             "password managers on mobile devices." | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def background_url(self) -> str: | ||||||
|  |         """Get the URL to the background image. If the name is /static or starts with http | ||||||
|  |         it is returned as-is""" | ||||||
|  |         if not self.background: | ||||||
|  |             return "/static/dist/assets/images/flow_background.jpg" | ||||||
|  |         if self.background.name.startswith("http") or self.background.name.startswith( | ||||||
|  |             "/static" | ||||||
|  |         ): | ||||||
|  |             return self.background.name | ||||||
|  |         return self.background.url | ||||||
|  |  | ||||||
|     stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) |     stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -142,11 +162,6 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|         LOGGER.debug("with_policy: no flow found", filters=flow_filter) |         LOGGER.debug("with_policy: no flow found", filters=flow_filter) | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]: |  | ||||||
|         """Get a related flow with `designation`. Currently this only queries |  | ||||||
|         Flows by `designation`, but will eventually use `self` for related lookups.""" |  | ||||||
|         return Flow.with_policy(request, designation=designation) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Flow {self.name} ({self.slug})" |         return f"Flow {self.name} ({self.slug})" | ||||||
|  |  | ||||||
| @ -197,7 +212,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel): | |||||||
|         return FlowStageBindingSerializer |         return FlowStageBindingSerializer | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"{self.target} #{self.order}" |         return f"Flow-stage binding #{self.order} to {self.target}" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ from typing import Any, Optional | |||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  | from prometheus_client import Histogram | ||||||
| from sentry_sdk.hub import Hub | from sentry_sdk.hub import Hub | ||||||
| from sentry_sdk.tracing import Span | from sentry_sdk.tracing import Span | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
| @ -14,6 +15,7 @@ from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableExce | |||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowStageBinding, Stage | from authentik.flows.models import Flow, FlowStageBinding, Stage | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
|  | from authentik.root.monitoring import UpdatingGauge | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||||
| @ -21,6 +23,16 @@ PLAN_CONTEXT_SSO = "is_sso" | |||||||
| PLAN_CONTEXT_REDIRECT = "redirect" | PLAN_CONTEXT_REDIRECT = "redirect" | ||||||
| PLAN_CONTEXT_APPLICATION = "application" | PLAN_CONTEXT_APPLICATION = "application" | ||||||
| PLAN_CONTEXT_SOURCE = "source" | PLAN_CONTEXT_SOURCE = "source" | ||||||
|  | GAUGE_FLOWS_CACHED = UpdatingGauge( | ||||||
|  |     "authentik_flows_cached", | ||||||
|  |     "Cached flows", | ||||||
|  |     update_func=lambda: len(cache.keys("flow_*") or []), | ||||||
|  | ) | ||||||
|  | HIST_FLOWS_PLAN_TIME = Histogram( | ||||||
|  |     "authentik_flows_plan_time", | ||||||
|  |     "Duration to build a plan for a flow", | ||||||
|  |     ["flow_slug"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def cache_key(flow: Flow, user: Optional[User] = None) -> str: | def cache_key(flow: Flow, user: Optional[User] = None) -> str: | ||||||
| @ -146,6 +158,7 @@ class FlowPlanner: | |||||||
|             ) |             ) | ||||||
|             plan = self._build_plan(user, request, default_context) |             plan = self._build_plan(user, request, default_context) | ||||||
|             cache.set(cache_key(self.flow, user), plan) |             cache.set(cache_key(self.flow, user), plan) | ||||||
|  |             GAUGE_FLOWS_CACHED.update() | ||||||
|             if not plan.stages and not self.allow_empty_flows: |             if not plan.stages and not self.allow_empty_flows: | ||||||
|                 raise EmptyFlowException() |                 raise EmptyFlowException() | ||||||
|             return plan |             return plan | ||||||
| @ -158,7 +171,9 @@ class FlowPlanner: | |||||||
|     ) -> FlowPlan: |     ) -> FlowPlan: | ||||||
|         """Build flow plan by checking each stage in their respective |         """Build flow plan by checking each stage in their respective | ||||||
|         order and checking the applied policies""" |         order and checking the applied policies""" | ||||||
|         with Hub.current.start_span(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: Span | ||||||
|             span.set_data("flow", self.flow) |             span.set_data("flow", self.flow) | ||||||
|             span.set_data("user", user) |             span.set_data("user", user) | ||||||
| @ -202,6 +217,7 @@ class FlowPlanner: | |||||||
|                     marker = ReevaluateMarker(binding=binding, user=user) |                     marker = ReevaluateMarker(binding=binding, user=user) | ||||||
|                 if stage: |                 if stage: | ||||||
|                     plan.append(stage, marker) |                     plan.append(stage, marker) | ||||||
|  |             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(plan): finished building", |             "f(plan): finished building", | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from django.contrib.auth.models import AnonymousUser | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.http.response import HttpResponse | from django.http.response import HttpResponse | ||||||
|  | from django.urls import reverse | ||||||
| from django.views.generic.base import View | from django.views.generic.base import View | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| @ -11,6 +12,7 @@ from authentik.core.models import DEFAULT_AVATAR, User | |||||||
| from authentik.flows.challenge import ( | from authentik.flows.challenge import ( | ||||||
|     Challenge, |     Challenge, | ||||||
|     ChallengeResponse, |     ChallengeResponse, | ||||||
|  |     ContextualFlowInfo, | ||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| @ -48,14 +50,17 @@ class StageView(View): | |||||||
|         self.executor = executor |         self.executor = executor | ||||||
|         super().__init__(**kwargs) |         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, |         """Either show the matched User object or show what the user entered, | ||||||
|         based on what the earlier stage (mostly IdentificationStage) set. |         based on what the earlier stage (mostly IdentificationStage) set. | ||||||
|         _USER_IDENTIFIER overrides the first User, as PENDING_USER is used for |         _USER_IDENTIFIER overrides the first User, as PENDING_USER is used for | ||||||
|         other things besides the form display. |         other things besides the form display. | ||||||
|  |  | ||||||
|         If no user is pending, returns request.user""" |         If no user is pending, returns request.user""" | ||||||
|         if 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( |             return User( | ||||||
|                 username=self.executor.plan.context.get( |                 username=self.executor.plan.context.get( | ||||||
|                     PLAN_CONTEXT_PENDING_USER_IDENTIFIER |                     PLAN_CONTEXT_PENDING_USER_IDENTIFIER | ||||||
| @ -93,15 +98,21 @@ class ChallengeStageView(StageView): | |||||||
|  |  | ||||||
|     def _get_challenge(self, *args, **kwargs) -> Challenge: |     def _get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         challenge = self.get_challenge(*args, **kwargs) |         challenge = self.get_challenge(*args, **kwargs) | ||||||
|         if "title" not in challenge.initial_data: |         if "flow_info" not in challenge.initial_data: | ||||||
|             challenge.initial_data["title"] = self.executor.flow.title |             flow_info = ContextualFlowInfo( | ||||||
|         if "background" not in challenge.initial_data: |                 data={ | ||||||
|             challenge.initial_data["background"] = self.executor.flow.background.url |                     "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 isinstance(challenge, WithUserInfoChallenge): | ||||||
|             # If there's a pending user, update the `username` field |             # If there's a pending user, update the `username` field | ||||||
|             # this field is only used by password managers. |             # this field is only used by password managers. | ||||||
|             # If there's no user set, an error is raised later. |             # If there's no user set, an error is raised later. | ||||||
|             if user := self.get_pending_user(): |             if user := self.get_pending_user(for_display=True): | ||||||
|                 challenge.initial_data["pending_user"] = user.username |                 challenge.initial_data["pending_user"] = user.username | ||||||
|             challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR |             challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR | ||||||
|             if not isinstance(user, AnonymousUser): |             if not isinstance(user, AnonymousUser): | ||||||
|  | |||||||
| @ -93,7 +93,11 @@ class TestFlowExecutor(TestCase): | |||||||
|             { |             { | ||||||
|                 "component": "ak-stage-access-denied", |                 "component": "ak-stage-access-denied", | ||||||
|                 "error_message": FlowNonApplicableException.__doc__, |                 "error_message": FlowNonApplicableException.__doc__, | ||||||
|  |                 "flow_info": { | ||||||
|  |                     "background": flow.background_url, | ||||||
|  |                     "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|                     "title": "", |                     "title": "", | ||||||
|  |                 }, | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
| @ -289,7 +293,11 @@ class TestFlowExecutor(TestCase): | |||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": reverse("authentik_core:root-redirect"), | ||||||
|  |                 "type": ChallengeTypes.REDIRECT.value, | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_reevaluate_keep(self): |     def test_reevaluate_keep(self): | ||||||
| @ -366,7 +374,11 @@ class TestFlowExecutor(TestCase): | |||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": reverse("authentik_core:root-redirect"), | ||||||
|  |                 "type": ChallengeTypes.REDIRECT.value, | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_reevaluate_remove_consecutive(self): |     def test_reevaluate_remove_consecutive(self): | ||||||
| @ -414,10 +426,13 @@ class TestFlowExecutor(TestCase): | |||||||
|             self.assertJSONEqual( |             self.assertJSONEqual( | ||||||
|                 force_str(response.content), |                 force_str(response.content), | ||||||
|                 { |                 { | ||||||
|                     "background": flow.background.url, |  | ||||||
|                     "type": ChallengeTypes.NATIVE.value, |                     "type": ChallengeTypes.NATIVE.value, | ||||||
|                     "component": "ak-stage-dummy", |                     "component": "ak-stage-dummy", | ||||||
|                     "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( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             { |             { | ||||||
|                 "background": flow.background.url, |  | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|                 "component": "ak-stage-dummy", |                 "component": "ak-stage-dummy", | ||||||
|                 "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.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             force_str(response.content), | ||||||
|             {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, |             { | ||||||
|  |                 "component": "xak-flow-redirect", | ||||||
|  |                 "to": reverse("authentik_core:root-redirect"), | ||||||
|  |                 "type": ChallengeTypes.REDIRECT.value, | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_stageview_user_identifier(self): |     def test_stageview_user_identifier(self): | ||||||
| @ -489,4 +511,4 @@ class TestFlowExecutor(TestCase): | |||||||
|         executor.flow = flow |         executor.flow = flow | ||||||
|  |  | ||||||
|         stage_view = StageView(executor) |         stage_view = StageView(executor) | ||||||
|         self.assertEqual(ident, stage_view.get_pending_user().username) |         self.assertEqual(ident, stage_view.get_pending_user(for_display=True).username) | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """flow urls""" | """flow urls""" | ||||||
| from django.urls import path | from django.urls import path | ||||||
| from django.views.generic import RedirectView |  | ||||||
|  |  | ||||||
| from authentik.flows.models import FlowDesignation | from authentik.flows.models import FlowDesignation | ||||||
| from authentik.flows.views import CancelView, ConfigureFlowInitView, ToDefaultFlow | from authentik.flows.views import CancelView, ConfigureFlowInitView, ToDefaultFlow | ||||||
| @ -16,30 +15,10 @@ urlpatterns = [ | |||||||
|         ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION), |         ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION), | ||||||
|         name="default-invalidation", |         name="default-invalidation", | ||||||
|     ), |     ), | ||||||
|     path( |  | ||||||
|         "-/default/recovery/", |  | ||||||
|         ToDefaultFlow.as_view(designation=FlowDesignation.RECOVERY), |  | ||||||
|         name="default-recovery", |  | ||||||
|     ), |  | ||||||
|     path( |  | ||||||
|         "-/default/enrollment/", |  | ||||||
|         ToDefaultFlow.as_view(designation=FlowDesignation.ENROLLMENT), |  | ||||||
|         name="default-enrollment", |  | ||||||
|     ), |  | ||||||
|     path( |  | ||||||
|         "-/default/unenrollment/", |  | ||||||
|         ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT), |  | ||||||
|         name="default-unenrollment", |  | ||||||
|     ), |  | ||||||
|     path("-/cancel/", CancelView.as_view(), name="cancel"), |     path("-/cancel/", CancelView.as_view(), name="cancel"), | ||||||
|     path( |     path( | ||||||
|         "-/configure/<uuid:stage_uuid>/", |         "-/configure/<uuid:stage_uuid>/", | ||||||
|         ConfigureFlowInitView.as_view(), |         ConfigureFlowInitView.as_view(), | ||||||
|         name="configure", |         name="configure", | ||||||
|     ), |     ), | ||||||
|     path( |  | ||||||
|         "<slug:flow_slug>/", |  | ||||||
|         RedirectView.as_view(pattern_name="authentik_core:if-flow"), |  | ||||||
|         name="flow-executor-shell", |  | ||||||
|     ), |  | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -2,16 +2,23 @@ | |||||||
| from traceback import format_tb | from traceback import format_tb | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
| from django.contrib.auth.mixins import LoginRequiredMixin | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
| from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| from django.template.response import TemplateResponse | from django.template.response import TemplateResponse | ||||||
|  | from django.urls.base import reverse | ||||||
| from django.utils.decorators import method_decorator | from django.utils.decorators import method_decorator | ||||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||||
| from django.views.generic import View | from django.views.generic import View | ||||||
| from drf_yasg import openapi | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_yasg.utils import no_body, swagger_auto_schema | from drf_spectacular.utils import ( | ||||||
|  |     OpenApiParameter, | ||||||
|  |     OpenApiResponse, | ||||||
|  |     PolymorphicProxySerializer, | ||||||
|  |     extend_schema, | ||||||
|  | ) | ||||||
| from rest_framework.permissions import AllowAny | from rest_framework.permissions import AllowAny | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
| from sentry_sdk import capture_exception | from sentry_sdk import capture_exception | ||||||
| @ -27,6 +34,7 @@ from authentik.flows.challenge import ( | |||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|     RedirectChallenge, |     RedirectChallenge, | ||||||
|     ShellChallenge, |     ShellChallenge, | ||||||
|  |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | ||||||
| @ -36,8 +44,9 @@ from authentik.flows.planner import ( | |||||||
|     FlowPlan, |     FlowPlan, | ||||||
|     FlowPlanner, |     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.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||||
|  | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| # Argument used to redirect user after login | # Argument used to redirect user after login | ||||||
| @ -47,6 +56,43 @@ SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre" | |||||||
| SESSION_KEY_GET = "authentik_flows_get" | SESSION_KEY_GET = "authentik_flows_get" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def challenge_types(): | ||||||
|  |     """This is a workaround for PolymorphicProxySerializer not accepting a callable for | ||||||
|  |     `serializers`. This function returns a class which is an iterator, which returns the | ||||||
|  |     subclasses of Challenge, and Challenge itself.""" | ||||||
|  |  | ||||||
|  |     class Inner(dict): | ||||||
|  |         """dummy class with custom callback on .items()""" | ||||||
|  |  | ||||||
|  |         def items(self): | ||||||
|  |             mapping = {} | ||||||
|  |             classes = all_subclasses(Challenge) | ||||||
|  |             classes.remove(WithUserInfoChallenge) | ||||||
|  |             for cls in classes: | ||||||
|  |                 mapping[cls().fields["component"].default] = cls | ||||||
|  |             return mapping.items() | ||||||
|  |  | ||||||
|  |     return Inner() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def challenge_response_types(): | ||||||
|  |     """This is a workaround for PolymorphicProxySerializer not accepting a callable for | ||||||
|  |     `serializers`. This function returns a class which is an iterator, which returns the | ||||||
|  |     subclasses of Challenge, and Challenge itself.""" | ||||||
|  |  | ||||||
|  |     class Inner(dict): | ||||||
|  |         """dummy class with custom callback on .items()""" | ||||||
|  |  | ||||||
|  |         def items(self): | ||||||
|  |             mapping = {} | ||||||
|  |             classes = all_subclasses(ChallengeResponse) | ||||||
|  |             for cls in classes: | ||||||
|  |                 mapping[cls(stage=None).fields["component"].default] = cls | ||||||
|  |             return mapping.items() | ||||||
|  |  | ||||||
|  |     return Inner() | ||||||
|  |  | ||||||
|  |  | ||||||
| @method_decorator(xframe_options_sameorigin, name="dispatch") | @method_decorator(xframe_options_sameorigin, name="dispatch") | ||||||
| class FlowExecutorView(APIView): | class FlowExecutorView(APIView): | ||||||
|     """Stage 1 Flow executor, passing requests to Stage Views""" |     """Stage 1 Flow executor, passing requests to Stage Views""" | ||||||
| @ -125,19 +171,25 @@ class FlowExecutorView(APIView): | |||||||
|         self.current_stage_view.request = request |         self.current_stage_view.request = request | ||||||
|         return super().dispatch(request) |         return super().dispatch(request) | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: Challenge(), |             200: PolymorphicProxySerializer( | ||||||
|             404: "No Token found",  # This error can be raised by the email stage |                 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, |         request=OpenApiTypes.NONE, | ||||||
|         manual_parameters=[ |         parameters=[ | ||||||
|             openapi.Parameter( |             OpenApiParameter( | ||||||
|                 "query", |                 name="query", | ||||||
|                 openapi.IN_QUERY, |                 location=OpenApiParameter.QUERY, | ||||||
|                 required=True, |                 required=True, | ||||||
|                 description="Querystring as received", |                 description="Querystring as received", | ||||||
|                 type=openapi.TYPE_STRING, |                 type=OpenApiTypes.STR, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|         operation_id="flows_executor_get", |         operation_id="flows_executor_get", | ||||||
| @ -153,20 +205,32 @@ class FlowExecutorView(APIView): | |||||||
|             stage_response = self.current_stage_view.get(request, *args, **kwargs) |             stage_response = self.current_stage_view.get(request, *args, **kwargs) | ||||||
|             return to_stage_response(request, stage_response) |             return to_stage_response(request, stage_response) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|  |             if settings.DEBUG or settings.TEST: | ||||||
|  |                 raise exc | ||||||
|             capture_exception(exc) |             capture_exception(exc) | ||||||
|             self._logger.warning(exc) |             self._logger.warning(exc) | ||||||
|             return to_stage_response(request, FlowErrorResponse(request, exc)) |             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||||
|  |  | ||||||
|     @swagger_auto_schema( |     @extend_schema( | ||||||
|         responses={200: Challenge()}, |         responses={ | ||||||
|         request_body=ChallengeResponse(), |             200: PolymorphicProxySerializer( | ||||||
|         manual_parameters=[ |                 component_name="FlowChallengeRequest", | ||||||
|             openapi.Parameter( |                 serializers=challenge_types(), | ||||||
|                 "query", |                 resource_type_field_name="component", | ||||||
|                 openapi.IN_QUERY, |             ), | ||||||
|  |         }, | ||||||
|  |         request=PolymorphicProxySerializer( | ||||||
|  |             component_name="FlowChallengeResponse", | ||||||
|  |             serializers=challenge_response_types(), | ||||||
|  |             resource_type_field_name="component", | ||||||
|  |         ), | ||||||
|  |         parameters=[ | ||||||
|  |             OpenApiParameter( | ||||||
|  |                 name="query", | ||||||
|  |                 location=OpenApiParameter.QUERY, | ||||||
|                 required=True, |                 required=True, | ||||||
|                 description="Querystring as received", |                 description="Querystring as received", | ||||||
|                 type=openapi.TYPE_STRING, |                 type=OpenApiTypes.STR, | ||||||
|             ) |             ) | ||||||
|         ], |         ], | ||||||
|         operation_id="flows_executor_solve", |         operation_id="flows_executor_solve", | ||||||
| @ -182,6 +246,8 @@ class FlowExecutorView(APIView): | |||||||
|             stage_response = self.current_stage_view.post(request, *args, **kwargs) |             stage_response = self.current_stage_view.post(request, *args, **kwargs) | ||||||
|             return to_stage_response(request, stage_response) |             return to_stage_response(request, stage_response) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|  |             if settings.DEBUG or settings.TEST: | ||||||
|  |                 raise exc | ||||||
|             capture_exception(exc) |             capture_exception(exc) | ||||||
|             self._logger.warning(exc) |             self._logger.warning(exc) | ||||||
|             return to_stage_response(request, FlowErrorResponse(request, exc)) |             return to_stage_response(request, FlowErrorResponse(request, exc)) | ||||||
| @ -218,7 +284,7 @@ class FlowExecutorView(APIView): | |||||||
|         if self.plan.stages: |         if self.plan.stages: | ||||||
|             self._logger.debug( |             self._logger.debug( | ||||||
|                 "f(exec): Continuing with next stage", |                 "f(exec): Continuing with next stage", | ||||||
|                 reamining=len(self.plan.stages), |                 remaining=len(self.plan.stages), | ||||||
|             ) |             ) | ||||||
|             kwargs = self.kwargs |             kwargs = self.kwargs | ||||||
|             kwargs.update({"flow_slug": self.flow.slug}) |             kwargs.update({"flow_slug": self.flow.slug}) | ||||||
| @ -244,9 +310,13 @@ class FlowExecutorView(APIView): | |||||||
|             AccessDeniedChallenge( |             AccessDeniedChallenge( | ||||||
|                 { |                 { | ||||||
|                     "error_message": error_message, |                     "error_message": error_message, | ||||||
|                     "title": self.flow.title, |  | ||||||
|                     "type": ChallengeTypes.NATIVE.value, |                     "type": ChallengeTypes.NATIVE.value, | ||||||
|                     "component": "ak-stage-access-denied", |                     "component": "ak-stage-access-denied", | ||||||
|  |                     "flow_info": { | ||||||
|  |                         "title": self.flow.title, | ||||||
|  |                         "background": self.flow.background_url, | ||||||
|  |                         "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|  |                     }, | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
| @ -307,7 +377,17 @@ class ToDefaultFlow(View): | |||||||
|     designation: Optional[FlowDesignation] = None |     designation: Optional[FlowDesignation] = None | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: |     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||||
|  |         tenant: Tenant = request.tenant | ||||||
|  |         flow = None | ||||||
|  |         # First, attempt to get default flow from tenant | ||||||
|  |         if self.designation == FlowDesignation.AUTHENTICATION: | ||||||
|  |             flow = tenant.flow_authentication | ||||||
|  |         if self.designation == FlowDesignation.INVALIDATION: | ||||||
|  |             flow = tenant.flow_invalidation | ||||||
|  |         # If no flow was set, get the first based on slug and policy | ||||||
|  |         if not flow: | ||||||
|             flow = Flow.with_policy(request, designation=self.designation) |             flow = Flow.with_policy(request, designation=self.designation) | ||||||
|  |         # If we still don't have a flow, 404 | ||||||
|         if not flow: |         if not flow: | ||||||
|             raise Http404 |             raise Http404 | ||||||
|         # If user already has a pending plan, clear it so we don't have to later. |         # If user already has a pending plan, clear it so we don't have to later. | ||||||
| @ -338,7 +418,10 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons | |||||||
|         ) |         ) | ||||||
|         return HttpChallengeResponse( |         return HttpChallengeResponse( | ||||||
|             RedirectChallenge( |             RedirectChallenge( | ||||||
|                 {"type": ChallengeTypes.REDIRECT, "to": str(redirect_url)} |                 { | ||||||
|  |                     "type": ChallengeTypes.REDIRECT, | ||||||
|  |                     "to": str(redirect_url), | ||||||
|  |                 } | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     if isinstance(source, TemplateResponse): |     if isinstance(source, TemplateResponse): | ||||||
| @ -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: |     if source.__class__ == HttpResponse: | ||||||
|         return HttpChallengeResponse( |         return HttpChallengeResponse( | ||||||
|             ShellChallenge( |             ShellChallenge( | ||||||
|  | |||||||
| @ -10,9 +10,6 @@ from urllib.parse import urlparse | |||||||
|  |  | ||||||
| import yaml | import yaml | ||||||
| from django.conf import ImproperlyConfigured | 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( | SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob( | ||||||
|     "/etc/authentik/config.d/*.yml", recursive=True |     "/etc/authentik/config.d/*.yml", recursive=True | ||||||
| @ -21,11 +18,6 @@ ENV_PREFIX = "AUTHENTIK" | |||||||
| ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") | 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: | class ConfigLoader: | ||||||
|     """Search through SEARCH_PATHS and load configuration. Environment variables starting with |     """Search through SEARCH_PATHS and load configuration. Environment variables starting with | ||||||
|     `ENV_PREFIX` are also applied. |     `ENV_PREFIX` are also applied. | ||||||
|  | |||||||
| @ -47,10 +47,7 @@ outposts: | |||||||
|  |  | ||||||
| authentik: | authentik: | ||||||
|   avatars: gravatar  # gravatar or none |   avatars: gravatar  # gravatar or none | ||||||
|   geoip: "" |   geoip: "./GeoLite2-City.mmdb" | ||||||
|   branding: |  | ||||||
|     title: authentik |  | ||||||
|     logo: /static/dist/assets/icons/icon_left_brand.svg |  | ||||||
|   # Optionally add links to the footer on the login page |   # Optionally add links to the footer on the login page | ||||||
|   footer_links: |   footer_links: | ||||||
|     - name: Documentation |     - name: Documentation | ||||||
|  | |||||||
| @ -26,8 +26,8 @@ class BaseEvaluator: | |||||||
|     _filename: str |     _filename: str | ||||||
|  |  | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|         # update authentik/policies/expression/templates/policy/expression/form.html |         # update website/docs/expressions/_objects.md | ||||||
|         # update website/docs/policies/expression.md |         # update website/docs/expressions/_functions.md | ||||||
|         self._globals = { |         self._globals = { | ||||||
|             "regex_match": BaseEvaluator.expr_filter_regex_match, |             "regex_match": BaseEvaluator.expr_filter_regex_match, | ||||||
|             "regex_replace": BaseEvaluator.expr_filter_regex_replace, |             "regex_replace": BaseEvaluator.expr_filter_regex_replace, | ||||||
|  | |||||||
| @ -8,7 +8,11 @@ from botocore.exceptions import BotoCoreError | |||||||
| from celery.exceptions import CeleryError | from celery.exceptions import CeleryError | ||||||
| from channels.middleware import BaseMiddleware | from channels.middleware import BaseMiddleware | ||||||
| from channels_redis.core import ChannelFull | from channels_redis.core import ChannelFull | ||||||
| from django.core.exceptions import SuspiciousOperation, ValidationError | from django.core.exceptions import ( | ||||||
|  |     ImproperlyConfigured, | ||||||
|  |     SuspiciousOperation, | ||||||
|  |     ValidationError, | ||||||
|  | ) | ||||||
| from django.db import InternalError, OperationalError, ProgrammingError | from django.db import InternalError, OperationalError, ProgrammingError | ||||||
| from django.http.response import Http404 | from django.http.response import Http404 | ||||||
| from django_redis.exceptions import ConnectionInterrupted | from django_redis.exceptions import ConnectionInterrupted | ||||||
| @ -51,7 +55,8 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|         ConnectionResetError, |         ConnectionResetError, | ||||||
|         OSError, |         OSError, | ||||||
|         PermissionError, |         PermissionError, | ||||||
|         # Django DB Errors |         # Django Errors | ||||||
|  |         ImproperlyConfigured, | ||||||
|         OperationalError, |         OperationalError, | ||||||
|         InternalError, |         InternalError, | ||||||
|         ProgrammingError, |         ProgrammingError, | ||||||
| @ -89,6 +94,13 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|         if isinstance(exc_value, ignored_classes): |         if isinstance(exc_value, ignored_classes): | ||||||
|             return None |             return None | ||||||
|     if "logger" in event: |     if "logger" in event: | ||||||
|         if event["logger"] in ["dbbackup", "botocore"]: |         if event["logger"] in [ | ||||||
|  |             "dbbackup", | ||||||
|  |             "botocore", | ||||||
|  |             "kombu", | ||||||
|  |             "asyncio", | ||||||
|  |             "multiprocessing", | ||||||
|  |             "django_redis", | ||||||
|  |         ]: | ||||||
|             return None |             return None | ||||||
|     return event |     return event | ||||||
|  | |||||||
| @ -5,9 +5,10 @@ from django.http import HttpRequest | |||||||
|  |  | ||||||
| OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | ||||||
| USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | 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. |     """Attempt to get the client's IP by checking common HTTP Headers. | ||||||
|     Returns none if no IP Could be found""" |     Returns none if no IP Could be found""" | ||||||
|     headers = ( |     headers = ( | ||||||
| @ -19,7 +20,7 @@ def _get_client_ip_from_meta(meta: dict[str, Any]) -> Optional[str]: | |||||||
|         if _header in meta: |         if _header in meta: | ||||||
|             ips: list[str] = meta.get(_header).split(",") |             ips: list[str] = meta.get(_header).split(",") | ||||||
|             return ips[0].strip() |             return ips[0].strip() | ||||||
|     return None |     return DEFAULT_IP | ||||||
|  |  | ||||||
|  |  | ||||||
| def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: | 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] |     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. |     """Attempt to get the client's IP by checking common HTTP Headers. | ||||||
|     Returns none if no IP Could be found""" |     Returns none if no IP Could be found""" | ||||||
|     if request: |     if request: | ||||||
| @ -45,4 +46,4 @@ def get_client_ip(request: Optional[HttpRequest]) -> Optional[str]: | |||||||
|         if override: |         if override: | ||||||
|             return override |             return override | ||||||
|         return _get_client_ip_from_meta(request.META) |         return _get_client_ip_from_meta(request.META) | ||||||
|     return None |     return DEFAULT_IP | ||||||
|  | |||||||
| @ -2,28 +2,6 @@ | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.template.response import TemplateResponse | from django.template.response import TemplateResponse | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from django.views.generic import CreateView |  | ||||||
| from guardian.shortcuts import assign_perm |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class CreateAssignPermView(CreateView): |  | ||||||
|     """Assign permissions to object after creation""" |  | ||||||
|  |  | ||||||
|     permissions = [ |  | ||||||
|         "%s.view_%s", |  | ||||||
|         "%s.change_%s", |  | ||||||
|         "%s.delete_%s", |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     def form_valid(self, form): |  | ||||||
|         response = super().form_valid(form) |  | ||||||
|         for permission in self.permissions: |  | ||||||
|             full_permission = permission % ( |  | ||||||
|                 self.object._meta.app_label, |  | ||||||
|                 self.object._meta.model_name, |  | ||||||
|             ) |  | ||||||
|             assign_perm(full_permission, self.request.user, self.object) |  | ||||||
|         return response |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def bad_request_message( | def bad_request_message( | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ class AuthentikManagedConfig(AppConfig): | |||||||
|     """authentik Managed app""" |     """authentik Managed app""" | ||||||
|  |  | ||||||
|     name = "authentik.managed" |     name = "authentik.managed" | ||||||
|     label = "authentik_Managed" |     label = "authentik_managed" | ||||||
|     verbose_name = "authentik Managed" |     verbose_name = "authentik Managed" | ||||||
|  |  | ||||||
|     def ready(self) -> None: |     def ready(self) -> None: | ||||||
|  | |||||||
| @ -1,24 +1,60 @@ | |||||||
| """Outpost API Views""" | """Outpost API Views""" | ||||||
| from dacite.core import from_dict | from dacite.core import from_dict | ||||||
| from dacite.exceptions import DaciteError | 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.decorators import action | ||||||
| from rest_framework.fields import BooleanField, CharField, DateTimeField | from rest_framework.fields import BooleanField, CharField, DateTimeField | ||||||
|  | from rest_framework.relations import PrimaryKeyRelatedField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import JSONField, ModelSerializer, ValidationError | from rest_framework.serializers import JSONField, ModelSerializer, ValidationError | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer, is_dict | 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): | class OutpostSerializer(ModelSerializer): | ||||||
|     """Outpost Serializer""" |     """Outpost Serializer""" | ||||||
|  |  | ||||||
|     config = JSONField(validators=[is_dict], source="_config") |     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) |     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: |     def validate_config(self, config) -> dict: | ||||||
|         """Check that the config has all required fields""" |         """Check that the config has all required fields""" | ||||||
| @ -38,9 +74,11 @@ class OutpostSerializer(ModelSerializer): | |||||||
|             "providers", |             "providers", | ||||||
|             "providers_obj", |             "providers_obj", | ||||||
|             "service_connection", |             "service_connection", | ||||||
|  |             "service_connection_obj", | ||||||
|             "token_identifier", |             "token_identifier", | ||||||
|             "config", |             "config", | ||||||
|         ] |         ] | ||||||
|  |         extra_kwargs = {"type": {"required": True}} | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostDefaultConfigSerializer(PassiveSerializer): | class OutpostDefaultConfigSerializer(PassiveSerializer): | ||||||
| @ -58,7 +96,7 @@ class OutpostHealthSerializer(PassiveSerializer): | |||||||
|     version_outdated = BooleanField(read_only=True) |     version_outdated = BooleanField(read_only=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostViewSet(ModelViewSet): | class OutpostViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """Outpost Viewset""" |     """Outpost Viewset""" | ||||||
|  |  | ||||||
|     queryset = Outpost.objects.all() |     queryset = Outpost.objects.all() | ||||||
| @ -70,10 +108,10 @@ class OutpostViewSet(ModelViewSet): | |||||||
|         "name", |         "name", | ||||||
|         "providers__name", |         "providers__name", | ||||||
|     ] |     ] | ||||||
|     ordering = ["name"] |     ordering = ["name", "service_connection__name"] | ||||||
|  |  | ||||||
|     @swagger_auto_schema(responses={200: OutpostHealthSerializer(many=True)}) |     @extend_schema(responses={200: OutpostHealthSerializer(many=True)}) | ||||||
|     @action(methods=["GET"], detail=True) |     @action(methods=["GET"], detail=True, pagination_class=None) | ||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def health(self, request: Request, pk: int) -> Response: |     def health(self, request: Request, pk: int) -> Response: | ||||||
|         """Get outposts current health""" |         """Get outposts current health""" | ||||||
| @ -90,7 +128,7 @@ class OutpostViewSet(ModelViewSet): | |||||||
|             ) |             ) | ||||||
|         return Response(OutpostHealthSerializer(states, many=True).data) |         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"]) |     @action(detail=False, methods=["GET"]) | ||||||
|     def default_settings(self, request: Request) -> Response: |     def default_settings(self, request: Request) -> Response: | ||||||
|         """Global default outpost config""" |         """Global default outpost config""" | ||||||
|  | |||||||
| @ -2,18 +2,19 @@ | |||||||
| from dataclasses import asdict | from dataclasses import asdict | ||||||
| 
 | 
 | ||||||
| from django.utils.translation import gettext_lazy as _ | 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.client.configuration import Configuration | ||||||
| from kubernetes.config.config_exception import ConfigException | from kubernetes.config.config_exception import ConfigException | ||||||
| from kubernetes.config.kube_config import load_kube_config_from_dict | from kubernetes.config.kube_config import load_kube_config_from_dict | ||||||
| from rest_framework import mixins, serializers | from rest_framework import mixins, serializers | ||||||
| from rest_framework.decorators import action | 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.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet | from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||||
| 
 | 
 | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import ( | ||||||
|     MetaNameSerializer, |     MetaNameSerializer, | ||||||
|     PassiveSerializer, |     PassiveSerializer, | ||||||
| @ -30,11 +31,7 @@ from authentik.outposts.models import ( | |||||||
| class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): | class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): | ||||||
|     """ServiceConnection Serializer""" |     """ServiceConnection Serializer""" | ||||||
| 
 | 
 | ||||||
|     component = SerializerMethodField() |     component = ReadOnlyField() | ||||||
| 
 |  | ||||||
|     def get_component(self, obj: OutpostServiceConnection) -> str: |  | ||||||
|         """Get object component so that we know how to edit the object""" |  | ||||||
|         return obj.component |  | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
| 
 | 
 | ||||||
| @ -59,6 +56,7 @@ class ServiceConnectionStateSerializer(PassiveSerializer): | |||||||
| class ServiceConnectionViewSet( | class ServiceConnectionViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|  |     UsedByMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
| @ -69,7 +67,7 @@ class ServiceConnectionViewSet( | |||||||
|     search_fields = ["name"] |     search_fields = ["name"] | ||||||
|     filterset_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=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|         """Get all creatable service connection types""" |         """Get all creatable service connection types""" | ||||||
| @ -87,7 +85,7 @@ class ServiceConnectionViewSet( | |||||||
|             ) |             ) | ||||||
|         return Response(TypeCreateSerializer(data, many=True).data) |         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=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|     # pylint: disable=unused-argument, invalid-name |     # pylint: disable=unused-argument, invalid-name | ||||||
|     def state(self, request: Request, pk: str) -> Response: |     def state(self, request: Request, pk: str) -> Response: | ||||||
| @ -109,7 +107,7 @@ class DockerServiceConnectionSerializer(ServiceConnectionSerializer): | |||||||
|         ] |         ] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class DockerServiceConnectionViewSet(ModelViewSet): | class DockerServiceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """DockerServiceConnection Viewset""" |     """DockerServiceConnection Viewset""" | ||||||
| 
 | 
 | ||||||
|     queryset = DockerServiceConnection.objects.all() |     queryset = DockerServiceConnection.objects.all() | ||||||
| @ -122,7 +120,7 @@ class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): | |||||||
|     def validate_kubeconfig(self, kubeconfig): |     def validate_kubeconfig(self, kubeconfig): | ||||||
|         """Validate kubeconfig by attempting to load it""" |         """Validate kubeconfig by attempting to load it""" | ||||||
|         if kubeconfig == {}: |         if kubeconfig == {}: | ||||||
|             if not self.validated_data["local"]: |             if not self.initial_data["local"]: | ||||||
|                 raise serializers.ValidationError( |                 raise serializers.ValidationError( | ||||||
|                     _( |                     _( | ||||||
|                         "You can only use an empty kubeconfig when connecting to a local cluster." |                         "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"] |         fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig"] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class KubernetesServiceConnectionViewSet(ModelViewSet): | class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||||
|     """KubernetesServiceConnection Viewset""" |     """KubernetesServiceConnection Viewset""" | ||||||
| 
 | 
 | ||||||
|     queryset = KubernetesServiceConnection.objects.all() |     queryset = KubernetesServiceConnection.objects.all() | ||||||
| @ -8,11 +8,21 @@ from channels.exceptions import DenyConnection | |||||||
| from dacite import from_dict | from dacite import from_dict | ||||||
| from dacite.data import Data | from dacite.data import Data | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
|  | from prometheus_client import Gauge | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.channels import AuthJsonConsumer | from authentik.core.channels import AuthJsonConsumer | ||||||
| from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | 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() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -44,6 +54,8 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|  |  | ||||||
|     last_uid: Optional[str] = None |     last_uid: Optional[str] = None | ||||||
|  |  | ||||||
|  |     first_msg = False | ||||||
|  |  | ||||||
|     def connect(self): |     def connect(self): | ||||||
|         super().connect() |         super().connect() | ||||||
|         uuid = self.scope["url_route"]["kwargs"]["pk"] |         uuid = self.scope["url_route"]["kwargs"]["pk"] | ||||||
| @ -64,7 +76,14 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def disconnect(self, close_code): |     def disconnect(self, close_code): | ||||||
|         if self.outpost and self.last_uid: |         if self.outpost and self.last_uid: | ||||||
|             OutpostState.for_channel(self.outpost, self.last_uid).delete() |             state = OutpostState.for_instance_uid(self.outpost, self.last_uid) | ||||||
|  |             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( |         LOGGER.debug( | ||||||
|             "removed outpost instance from cache", |             "removed outpost instance from cache", | ||||||
|             outpost=self.outpost, |             outpost=self.outpost, | ||||||
| @ -75,17 +94,32 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|         msg = from_dict(WebsocketMessage, content) |         msg = from_dict(WebsocketMessage, content) | ||||||
|         uid = msg.args.get("uuid", self.channel_name) |         uid = msg.args.get("uuid", self.channel_name) | ||||||
|         self.last_uid = uid |         self.last_uid = uid | ||||||
|         state = OutpostState( |  | ||||||
|             uid=uid, |         if not self.outpost: | ||||||
|             channel_id=self.channel_name, |             raise DenyConnection() | ||||||
|             last_seen=datetime.now(), |  | ||||||
|             _outpost=self.outpost, |         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: |         if msg.instruction == WebsocketMessageInstruction.HELLO: | ||||||
|             state.version = msg.args.get("version", None) |             state.version = msg.args.get("version", None) | ||||||
|             state.build_hash = msg.args.get("buildHash", "") |             state.build_hash = msg.args.get("buildHash", "") | ||||||
|         elif msg.instruction == WebsocketMessageInstruction.ACK: |         elif msg.instruction == WebsocketMessageInstruction.ACK: | ||||||
|             return |             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) |         state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) | ||||||
|  |  | ||||||
|         response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) |         response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) | ||||||
|  | |||||||
| @ -71,6 +71,7 @@ class DockerController(BaseController): | |||||||
|                 }, |                 }, | ||||||
|                 "environment": self._get_env(), |                 "environment": self._get_env(), | ||||||
|                 "labels": self._get_labels(), |                 "labels": self._get_labels(), | ||||||
|  |                 "restart_policy": {"Name": "unless-stopped"}, | ||||||
|             } |             } | ||||||
|             if settings.TEST: |             if settings.TEST: | ||||||
|                 del container_args["ports"] |                 del container_args["ports"] | ||||||
| @ -80,27 +81,39 @@ class DockerController(BaseController): | |||||||
|                 True, |                 True, | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  |     # pylint: disable=too-many-return-statements | ||||||
|     def up(self): |     def up(self): | ||||||
|         try: |         try: | ||||||
|             container, has_been_created = self._get_container() |             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 |             # Check if the container is out of date, delete it and retry | ||||||
|             if len(container.image.tags) > 0: |             if len(container.image.tags) > 0: | ||||||
|                 tag: str = container.image.tags[0] |                 tag: str = container.image.tags[0] | ||||||
|                 _, _, version = tag.partition(":") |                 if tag != self.get_container_image(): | ||||||
|                 if version != __version__: |  | ||||||
|                     self.logger.info( |                     self.logger.info( | ||||||
|                         "Container has mismatched version, re-creating...", |                         "Container has mismatched image, re-creating...", | ||||||
|                         has=version, |                         has=tag, | ||||||
|                         should=__version__, |                         should=self.get_container_image(), | ||||||
|                     ) |                     ) | ||||||
|                     container.kill() |                     self.down() | ||||||
|                     container.remove(force=True) |  | ||||||
|                     return self.up() |                     return self.up() | ||||||
|             # Check that container values match our values |             # Check that container values match our values | ||||||
|             if self._comp_env(container): |             if self._comp_env(container): | ||||||
|                 self.logger.info("Container has outdated config, re-creating...") |                 self.logger.info("Container has outdated config, re-creating...") | ||||||
|                 container.kill() |                 self.down() | ||||||
|                 container.remove(force=True) |                 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() |                 return self.up() | ||||||
|             # Check that container is healthy |             # Check that container is healthy | ||||||
|             if ( |             if ( | ||||||
| @ -127,16 +140,16 @@ class DockerController(BaseController): | |||||||
|                 return None |                 return None | ||||||
|             return None |             return None | ||||||
|         except DockerException as exc: |         except DockerException as exc: | ||||||
|             raise ControllerException from exc |             raise ControllerException(str(exc)) from exc | ||||||
|  |  | ||||||
|     def down(self): |     def down(self): | ||||||
|         try: |         try: | ||||||
|             container, _ = self._get_container() |             container, _ = self._get_container() | ||||||
|             if container.status == "running": |             if container.status == "running": | ||||||
|                 container.kill() |                 container.kill() | ||||||
|             container.remove() |             container.remove(force=True) | ||||||
|         except DockerException as exc: |         except DockerException as exc: | ||||||
|             raise ControllerException from exc |             raise ControllerException(str(exc)) from exc | ||||||
|  |  | ||||||
|     def get_static_deployment(self) -> str: |     def get_static_deployment(self) -> str: | ||||||
|         """Generate docker-compose yaml for proxy, version 3.5""" |         """Generate docker-compose yaml for proxy, version 3.5""" | ||||||
|  | |||||||
| @ -376,16 +376,24 @@ class Outpost(models.Model): | |||||||
|     @property |     @property | ||||||
|     def token(self) -> Token: |     def token(self) -> Token: | ||||||
|         """Get/create token for auto-generated user""" |         """Get/create token for auto-generated user""" | ||||||
|         token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API) |         managed = f"goauthentik.io/outpost/{self.token_identifier}" | ||||||
|         if token.exists(): |         tokens = Token.filter_not_expired( | ||||||
|             return token.first() |             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( |         return Token.objects.create( | ||||||
|             user=self.user, |             user=self.user, | ||||||
|             identifier=self.token_identifier, |             identifier=self.token_identifier, | ||||||
|             intent=TokenIntents.INTENT_API, |             intent=TokenIntents.INTENT_API, | ||||||
|             description=f"Autogenerated by authentik for Outpost {self.name}", |             description=f"Autogenerated by authentik for Outpost {self.name}", | ||||||
|             expiring=False, |             expiring=False, | ||||||
|             managed=f"goauthentik.io/outpost/{self.token_identifier}", |             managed=managed, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: |     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||||
| @ -409,7 +417,7 @@ class OutpostState: | |||||||
|     """Outpost instance state, last_seen and version""" |     """Outpost instance state, last_seen and version""" | ||||||
|  |  | ||||||
|     uid: str |     uid: str | ||||||
|     channel_id: str |     channel_ids: list[str] = field(default_factory=list) | ||||||
|     last_seen: Optional[datetime] = field(default=None) |     last_seen: Optional[datetime] = field(default=None) | ||||||
|     version: Optional[str] = field(default=None) |     version: Optional[str] = field(default=None) | ||||||
|     version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION) |     version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION) | ||||||
| @ -432,21 +440,20 @@ class OutpostState: | |||||||
|         keys = cache.keys(f"{outpost.state_cache_prefix}_*") |         keys = cache.keys(f"{outpost.state_cache_prefix}_*") | ||||||
|         states = [] |         states = [] | ||||||
|         for key in keys: |         for key in keys: | ||||||
|             channel = key.replace(f"{outpost.state_cache_prefix}_", "") |             instance_uid = key.replace(f"{outpost.state_cache_prefix}_", "") | ||||||
|             states.append(OutpostState.for_channel(outpost, channel)) |             states.append(OutpostState.for_instance_uid(outpost, instance_uid)) | ||||||
|         return states |         return states | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def for_channel(outpost: Outpost, channel: str) -> "OutpostState": |     def for_instance_uid(outpost: Outpost, uid: str) -> "OutpostState": | ||||||
|         """Get state for a single channel""" |         """Get state for a single instance""" | ||||||
|         key = f"{outpost.state_cache_prefix}_{channel}" |         key = f"{outpost.state_cache_prefix}_{uid}" | ||||||
|         default_data = {"uid": channel, "channel_id": channel} |         default_data = {"uid": uid, "channel_ids": []} | ||||||
|         data = cache.get(key, default_data) |         data = cache.get(key, default_data) | ||||||
|         if isinstance(data, str): |         if isinstance(data, str): | ||||||
|             cache.delete(key) |             cache.delete(key) | ||||||
|             data = default_data |             data = default_data | ||||||
|         state = from_dict(OutpostState, data) |         state = from_dict(OutpostState, data) | ||||||
|         state.uid = channel |  | ||||||
|         # pylint: disable=protected-access |         # pylint: disable=protected-access | ||||||
|         state._outpost = outpost |         state._outpost = outpost | ||||||
|         return state |         return state | ||||||
|  | |||||||
| @ -65,6 +65,8 @@ def outpost_service_connection_state(connection_pk: Any): | |||||||
|         .select_subclasses() |         .select_subclasses() | ||||||
|         .first() |         .first() | ||||||
|     ) |     ) | ||||||
|  |     if not connection: | ||||||
|  |         return | ||||||
|     state = connection.fetch_state() |     state = connection.fetch_state() | ||||||
|     cache.set(connection.state_key, state, timeout=None) |     cache.set(connection.state_key, state, timeout=None) | ||||||
|  |  | ||||||
| @ -147,8 +149,9 @@ def outpost_post_save(model_class: str, model_pk: Any): | |||||||
|         return |         return | ||||||
|  |  | ||||||
|     if isinstance(instance, Outpost): |     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.token | ||||||
|  |         _ = instance.user | ||||||
|         LOGGER.debug("Trigger reconcile for outpost") |         LOGGER.debug("Trigger reconcile for outpost") | ||||||
|         outpost_controller.delay(instance.pk) |         outpost_controller.delay(instance.pk) | ||||||
|  |  | ||||||
| @ -199,11 +202,15 @@ def _outpost_single_update(outpost: Outpost, layer=None): | |||||||
|     # Ensure token again, because this function is called when anything related to an |     # Ensure token again, because this function is called when anything related to an | ||||||
|     # OutpostModel is saved, so we can be sure permissions are right |     # OutpostModel is saved, so we can be sure permissions are right | ||||||
|     _ = outpost.token |     _ = outpost.token | ||||||
|  |     _ = outpost.user | ||||||
|     if not layer:  # pragma: no cover |     if not layer:  # pragma: no cover | ||||||
|         layer = get_channel_layer() |         layer = get_channel_layer() | ||||||
|     for state in OutpostState.for_outpost(outpost): |     for state in OutpostState.for_outpost(outpost): | ||||||
|         LOGGER.debug("sending update", channel=state.channel_id, outpost=outpost) |         for channel in state.channel_ids: | ||||||
|         async_to_sync(layer.send)(state.channel_id, {"type": "event.update"}) |             LOGGER.debug( | ||||||
|  |                 "sending update", channel=channel, instance=state.uid, outpost=outpost | ||||||
|  |             ) | ||||||
|  |             async_to_sync(layer.send)(channel, {"type": "event.update"}) | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task() | @CELERY_APP.task() | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	