Compare commits
	
		
			194 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 680b182d95 | |||
| b2a832175e | |||
| b3ce8331f5 | |||
| ef0f618234 | |||
| b8a7186a55 | |||
| b39530f873 | |||
| 7937c84f2b | |||
| 621843c60c | |||
| c19da839b1 | |||
| fea1f3be6f | |||
| 6f5ec7838f | |||
| 94300492e7 | |||
| 5d3931c128 | |||
| 262a8b5ae8 | |||
| fe069c5e55 | |||
| c6e60c0ebc | |||
| 90b457c5ee | |||
| 5e724e4299 | |||
| b4c8dd6b91 | |||
| 63d163cc65 | |||
| 2b1356bb91 | |||
| ba9edd6c44 | |||
| 3b2b3262d7 | |||
| 5431e7fe9d | |||
| 7d9c74ce04 | |||
| 60c3cf890a | |||
| 4ec5df6b12 | |||
| 0403f6d373 | |||
| b7f4d15a94 | |||
| 56450887ca | |||
| 9bd613a31d | |||
| 3fe0483dbf | |||
| 63a28ca1e9 | |||
| 2543b075be | |||
| b8bdf7a035 | |||
| a3ff7cea23 | |||
| bb776c2710 | |||
| c9ad87d419 | |||
| 0d81eaffff | |||
| 6930c84425 | |||
| eaaeaccf5d | |||
| efbbd0adcf | |||
| c8d9771640 | |||
| 2b98637ca5 | |||
| e3f7185564 | |||
| d1198fc6c1 | |||
| 8cb5f8fbee | |||
| 31a58e2c25 | |||
| 229715acb2 | |||
| fad5b09aee | |||
| 2a670afd02 | |||
| b69248dd55 | |||
| 5ff5edf769 | |||
| 939889e0ec | |||
| 19ae6585dc | |||
| a81c847392 | |||
| c6ede78fba | |||
| cea1289186 | |||
| c297f28552 | |||
| 35b25bd76e | |||
| 64d7610b13 | |||
| 2c8fcff832 | |||
| 054e76d02a | |||
| 80fa132dd9 | |||
| 4c59c3abef | |||
| 22d319c0e7 | |||
| 89edd77484 | |||
| 04e52d8ba6 | |||
| 9b5e3921cb | |||
| 2bbad64dc3 | |||
| f6026fdb13 | |||
| 49def45ca3 | |||
| a4856969f4 | |||
| 2aa7266688 | |||
| 25817cae6b | |||
| 5383ae2c19 | |||
| c0c246edab | |||
| 831b32c279 | |||
| 70ccc63702 | |||
| de954250e5 | |||
| f268bd4c69 | |||
| 57a48b6350 | |||
| 9aac114115 | |||
| 66e3cbdc46 | |||
| 2d76d23f7b | |||
| 4327b35bc3 | |||
| f7047df40e | |||
| ef77a4b64e | |||
| 5d7d21076f | |||
| ede072889e | |||
| 9cb7e6c606 | |||
| e7d36c095d | |||
| b88eb430c1 | |||
| 641872a33a | |||
| 405c690193 | |||
| 932cf48d2b | |||
| 402819107d | |||
| 41f135126b | |||
| 591a339302 | |||
| 35f2c5d96a | |||
| fe6963c428 | |||
| 19cac4bf43 | |||
| 4ca564490e | |||
| fcb795c273 | |||
| 14c70b3e4a | |||
| ac880c28d7 | |||
| f3c6b9a4f6 | |||
| cba0cf0d76 | |||
| 73b67cf0f0 | |||
| 23a8052cc8 | |||
| 57c49c3865 | |||
| cbea51ae5b | |||
| 8962081d92 | |||
| e743f13f81 | |||
| b20a8b7c17 | |||
| b53c94d76a | |||
| d4419d66c1 | |||
| 79044368d2 | |||
| 426686957d | |||
| 28cb803fd9 | |||
| 85c3a36b62 | |||
| 9ba8a715b1 | |||
| 358750f66e | |||
| b9918529b8 | |||
| a5673b4ec8 | |||
| d9287d0c0e | |||
| d9c2b64116 | |||
| 2b150d3077 | |||
| dec7a9cfb9 | |||
| e0f48a30b7 | |||
| 973f14d911 | |||
| e8978adc1b | |||
| 3ca8d9c968 | |||
| 42636142fa | |||
| 57c459348f | |||
| 493b34cf0d | |||
| f0493f418b | |||
| d45a292652 | |||
| b21ea360db | |||
| 6816f8b851 | |||
| de714f0390 | |||
| 800df332b5 | |||
| 16c194d2dc | |||
| 53100a72fe | |||
| ec4c3f44cb | |||
| f10bd432b3 | |||
| 4de927ba5b | |||
| 74e578c2bf | |||
| e584fd1344 | |||
| 0e02925a3d | |||
| 5b837c3ccc | |||
| 2580371f94 | |||
| 4e9be85353 | |||
| 79508e1965 | |||
| 3a88dde545 | |||
| 31fc4d1cb9 | |||
| 09cd8f8f63 | |||
| d824b09365 | |||
| cabbd18880 | |||
| c9dda17c68 | |||
| bb8559ee18 | |||
| 5ae32e525c | |||
| 0832145a01 | |||
| 4167276c8f | |||
| afb84c7bc5 | |||
| 82b2c7e3f0 | |||
| fc8004db2b | |||
| ddfc943bba | |||
| 8c0c12292e | |||
| 803490d98b | |||
| 16835ab478 | |||
| 572b8d87b5 | |||
| 31d2ea65fd | |||
| f4ac2f50e2 | |||
| 969a3f0ddd | |||
| 4e18f47f28 | |||
| f10286edf8 | |||
| d789dcc28f | |||
| 715a71427e | |||
| 84c21d16cf | |||
| 2e4e17adb7 | |||
| 00cbaaf672 | |||
| 74e4e8f6aa | |||
| d78fda990a | |||
| 10d949f7a9 | |||
| 6661af032d | |||
| fb5e4a3af8 | |||
| 1dfad83a34 | |||
| 70025c648c | |||
| 676b77aa7c | |||
| e35e096266 | |||
| 7af12d4fec | |||
| 8d6db0fabf | |||
| 8ddcf99bf7 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2021.6.1-rc4 | current_version = 2021.6.3 | ||||||
| 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>.*) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| env | env | ||||||
| helm |  | ||||||
| static | static | ||||||
| htmlcov | htmlcov | ||||||
| *.env.yml | *.env.yml | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -6,6 +6,7 @@ daysUntilClose: 7 | |||||||
| exemptLabels: | exemptLabels: | ||||||
|   - pinned |   - pinned | ||||||
|   - security |   - security | ||||||
|  |   - pr_wanted | ||||||
| # Comment to post when marking an issue as stale. Set to `false` to disable | # Comment to post when marking an issue as stale. Set to `false` to disable | ||||||
| markComment: > | markComment: > | ||||||
|   This issue has been automatically marked as stale because it has not had |   This issue has been automatically marked as stale because it has not had | ||||||
|  | |||||||
							
								
								
									
										55
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										55
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -33,12 +33,21 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik:2021.6.1-rc4, |             beryju/authentik:2021.6.3, | ||||||
|             beryju/authentik:latest, |             beryju/authentik:latest, | ||||||
|             ghcr.io/goauthentik/server:2021.6.1-rc4, |             ghcr.io/goauthentik/server:2021.6.3, | ||||||
|             ghcr.io/goauthentik/server:latest |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|  |       - name: Building Docker Image (stable) | ||||||
|  |         if: ${{ github.event_name == 'release' && !contains('2021.6.3', 'rc') }} | ||||||
|  |         run: | | ||||||
|  |           docker pull beryju/authentik:latest | ||||||
|  |           docker tag beryju/authentik:latest beryju/authentik:stable | ||||||
|  |           docker push beryju/authentik:stable | ||||||
|  |           docker pull ghcr.io/goauthentik/server:latest | ||||||
|  |           docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable | ||||||
|  |           docker push ghcr.io/goauthentik/server:stable | ||||||
|   build-proxy: |   build-proxy: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
| @ -66,12 +75,21 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-proxy:2021.6.1-rc4, |             beryju/authentik-proxy:2021.6.3, | ||||||
|             beryju/authentik-proxy:latest, |             beryju/authentik-proxy:latest, | ||||||
|             ghcr.io/goauthentik/proxy:2021.6.1-rc4, |             ghcr.io/goauthentik/proxy:2021.6.3, | ||||||
|             ghcr.io/goauthentik/proxy:latest |             ghcr.io/goauthentik/proxy:latest | ||||||
|           file: outpost/proxy.Dockerfile |           file: outpost/proxy.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|  |       - name: Building Docker Image (stable) | ||||||
|  |         if: ${{ github.event_name == 'release' && !contains('2021.6.3', 'rc') }} | ||||||
|  |         run: | | ||||||
|  |           docker pull beryju/authentik-proxy:latest | ||||||
|  |           docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable | ||||||
|  |           docker push beryju/authentik-proxy:stable | ||||||
|  |           docker pull ghcr.io/goauthentik/proxy:latest | ||||||
|  |           docker tag ghcr.io/goauthentik/proxy:latest ghcr.io/goauthentik/proxy:stable | ||||||
|  |           docker push ghcr.io/goauthentik/proxy:stable | ||||||
|   build-ldap: |   build-ldap: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
| @ -99,14 +117,22 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-ldap:2021.6.1-rc4, |             beryju/authentik-ldap:2021.6.3, | ||||||
|             beryju/authentik-ldap:latest, |             beryju/authentik-ldap:latest, | ||||||
|             ghcr.io/goauthentik/ldap:2021.6.1-rc4, |             ghcr.io/goauthentik/ldap:2021.6.3, | ||||||
|             ghcr.io/goauthentik/ldap:latest |             ghcr.io/goauthentik/ldap:latest | ||||||
|           file: outpost/ldap.Dockerfile |           file: outpost/ldap.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|  |       - name: Building Docker Image (stable) | ||||||
|  |         if: ${{ github.event_name == 'release' && !contains('2021.6.3', 'rc') }} | ||||||
|  |         run: | | ||||||
|  |           docker pull beryju/authentik-ldap:latest | ||||||
|  |           docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable | ||||||
|  |           docker push beryju/authentik-ldap:stable | ||||||
|  |           docker pull ghcr.io/goauthentik/ldap:latest | ||||||
|  |           docker tag ghcr.io/goauthentik/ldap:latest ghcr.io/goauthentik/ldap:stable | ||||||
|  |           docker push ghcr.io/goauthentik/ldap:stable | ||||||
|   test-release: |   test-release: | ||||||
|     if: ${{ github.event_name == 'release' }} |  | ||||||
|     needs: |     needs: | ||||||
|       - build-server |       - build-server | ||||||
|       - build-proxy |       - build-proxy | ||||||
| @ -130,13 +156,26 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|  |       - name: Setup Node.js environment | ||||||
|  |         uses: actions/setup-node@v2.1.5 | ||||||
|  |         with: | ||||||
|  |           node-version: 12.x | ||||||
|  |       - name: Build web api client and web ui | ||||||
|  |         run: | | ||||||
|  |           export NODE_ENV=production | ||||||
|  |           make gen-web | ||||||
|  |           cd web | ||||||
|  |           npm i | ||||||
|  |           npm run build | ||||||
|       - name: Create a Sentry.io release |       - name: Create a Sentry.io release | ||||||
|         uses: getsentry/action-release@v1 |         uses: getsentry/action-release@v1 | ||||||
|  |         if: ${{ github.event_name == 'release' }} | ||||||
|         env: |         env: | ||||||
|           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} |           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | ||||||
|           SENTRY_ORG: beryjuorg |           SENTRY_ORG: beryjuorg | ||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2021.6.1-rc4 |           version: authentik@2021.6.3 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|  |           sourcemaps: './web/dist' | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -193,10 +193,6 @@ pip-selfcheck.json | |||||||
| local.env.yml | local.env.yml | ||||||
| .vscode/ | .vscode/ | ||||||
|  |  | ||||||
| ### Helm ### |  | ||||||
| # Chart dependencies |  | ||||||
| **/charts/*.tgz |  | ||||||
|  |  | ||||||
| # Selenium Screenshots | # Selenium Screenshots | ||||||
| selenium_screenshots/ | selenium_screenshots/ | ||||||
| backups/ | backups/ | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @ -46,6 +46,7 @@ webauthn = "*" | |||||||
| xmlsec = "*" | xmlsec = "*" | ||||||
| duo-client = "*" | duo-client = "*" | ||||||
| ua-parser = "*" | ua-parser = "*" | ||||||
|  | deepmerge = "*" | ||||||
|  |  | ||||||
| [requires] | [requires] | ||||||
| python_version = "3.9" | python_version = "3.9" | ||||||
|  | |||||||
							
								
								
									
										259
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										259
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "4fa1ad681762c867a95410074f31ac5d00119e187e0f38982cd59fdf301cccf5" |             "sha256": "f90d9fb4713eaf9c5ffe6a3858e64843670f79ab5007e7debf914c1f094c8d63" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": { |         "requires": { | ||||||
| @ -56,6 +56,7 @@ | |||||||
|                 "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", |                 "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", | ||||||
|                 "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" |                 "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==3.7.4.post0" |             "version": "==3.7.4.post0" | ||||||
|         }, |         }, | ||||||
|         "aioredis": { |         "aioredis": { | ||||||
| @ -70,20 +71,23 @@ | |||||||
|                 "sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2", |                 "sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2", | ||||||
|                 "sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb" |                 "sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==5.0.6" |             "version": "==5.0.6" | ||||||
|         }, |         }, | ||||||
|         "asgiref": { |         "asgiref": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", |                 "sha256:05914d0fa65a21711e732adc6572edad6c8da5f1435c3f0c060689ced5e85195", | ||||||
|                 "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" |                 "sha256:d36fa91dd90e3aa3c81a6bd426ccc8fb20bd3d22b0cf14a12800289e9c3e2563" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.3.4" |             "markers": "python_version >= '3.6'", | ||||||
|  |             "version": "==3.4.0" | ||||||
|         }, |         }, | ||||||
|         "async-timeout": { |         "async-timeout": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", |                 "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", | ||||||
|                 "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" |                 "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_full_version >= '3.5.3'", | ||||||
|             "version": "==3.0.1" |             "version": "==3.0.1" | ||||||
|         }, |         }, | ||||||
|         "attrs": { |         "attrs": { | ||||||
| @ -91,6 +95,7 @@ | |||||||
|                 "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", |                 "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", | ||||||
|                 "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" |                 "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==21.2.0" |             "version": "==21.2.0" | ||||||
|         }, |         }, | ||||||
|         "autobahn": { |         "autobahn": { | ||||||
| @ -98,6 +103,7 @@ | |||||||
|                 "sha256:9195df8af03b0ff29ccd4b7f5abbde957ee90273465942205f9a1bad6c3f07ac", |                 "sha256:9195df8af03b0ff29ccd4b7f5abbde957ee90273465942205f9a1bad6c3f07ac", | ||||||
|                 "sha256:e126c1f583e872fb59e79d36977cfa1f2d0a8a79f90ae31f406faae7664b8e03" |                 "sha256:e126c1f583e872fb59e79d36977cfa1f2d0a8a79f90ae31f406faae7664b8e03" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.7'", | ||||||
|             "version": "==21.3.1" |             "version": "==21.3.1" | ||||||
|         }, |         }, | ||||||
|         "automat": { |         "automat": { | ||||||
| @ -116,23 +122,26 @@ | |||||||
|         }, |         }, | ||||||
|         "boto3": { |         "boto3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:4cfab400cd9ca9b27b7dffb43f5675525ea5d36c81223d64d15542fdb16cdf7e", |                 "sha256:6300e9ee9a404038113250bd218e2c4827f5e676efb14e77de2ad2dcb67679bc", | ||||||
|                 "sha256:b0808a58c54c595b6cc6271cbc14a09bb89f0951ca9e8b105d1e94bef3ed24a0" |                 "sha256:be4714f0475c1f5183eea09ddbf568ced6fa41b0fc9976f2698b8442e1b17303" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.17.91" |             "version": "==1.17.102" | ||||||
|         }, |         }, | ||||||
|         "botocore": { |         "botocore": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:462e75419e6537efb2709b7eb5b8c7ade007d30209416f0476bd7c51a2ddc78d" |                 "sha256:2f57f7ceed1598d96cc497aeb45317db5d3b21a5aafea4732d0e561d0fc2a8fa", | ||||||
|  |                 "sha256:bdf08a4f7f01ead00d386848f089c08270499711447569c18d0db60023619c06" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.20.91" |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", | ||||||
|  |             "version": "==1.20.102" | ||||||
|         }, |         }, | ||||||
|         "cachetools": { |         "cachetools": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001", |                 "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001", | ||||||
|                 "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff" |                 "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version ~= '3.5'", | ||||||
|             "version": "==4.2.2" |             "version": "==4.2.2" | ||||||
|         }, |         }, | ||||||
|         "cbor2": { |         "cbor2": { | ||||||
| @ -151,15 +160,16 @@ | |||||||
|                 "sha256:ce6219986385778b1ab7f9b542f160bb4d3558f52975e914a27b774e47016fb7", |                 "sha256:ce6219986385778b1ab7f9b542f160bb4d3558f52975e914a27b774e47016fb7", | ||||||
|                 "sha256:d562b2773e14ee1d65ea5b85351a83a64d4f3fd011bc2b4c70a6e813e78203ce" |                 "sha256:d562b2773e14ee1d65ea5b85351a83a64d4f3fd011bc2b4c70a6e813e78203ce" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==5.4.0" |             "version": "==5.4.0" | ||||||
|         }, |         }, | ||||||
|         "celery": { |         "celery": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:1329de1edeaf734ef859e630cb42df2c116d53e59d2f46433b13aed196e85620", |                 "sha256:8d9a3de9162965e97f8e8cc584c67aad83b3f7a267584fa47701ed11c3e0d4b0", | ||||||
|                 "sha256:65f061c04578cf189cd7352c192e1a79fdeb370b916bff792bcc769560e81184" |                 "sha256:9dab2170b4038f7bf10ef2861dbf486ddf1d20592290a1040f7b7a1259705d42" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==5.1.0" |             "version": "==5.1.2" | ||||||
|         }, |         }, | ||||||
|         "certifi": { |         "certifi": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -243,6 +253,7 @@ | |||||||
|                 "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", |                 "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", | ||||||
|                 "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" |                 "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==4.0.0" |             "version": "==4.0.0" | ||||||
|         }, |         }, | ||||||
|         "click": { |         "click": { | ||||||
| @ -250,6 +261,7 @@ | |||||||
|                 "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", |                 "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", | ||||||
|                 "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" |                 "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==7.1.2" |             "version": "==7.1.2" | ||||||
|         }, |         }, | ||||||
|         "click-didyoumean": { |         "click-didyoumean": { | ||||||
| @ -309,8 +321,17 @@ | |||||||
|                 "sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f", |                 "sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f", | ||||||
|                 "sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393" |                 "sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==3.0.2" |             "version": "==3.0.2" | ||||||
|         }, |         }, | ||||||
|  |         "deepmerge": { | ||||||
|  |             "hashes": [ | ||||||
|  |                 "sha256:87166dbe9ba1a3348a45c9d4ada6778f518d41afc0b85aa017ea3041facc3f9c", | ||||||
|  |                 "sha256:f6fd7f1293c535fb599e197e750dbe8674503c5d2a89759b3c72a3c46746d4fd" | ||||||
|  |             ], | ||||||
|  |             "index": "pypi", | ||||||
|  |             "version": "==0.3.0" | ||||||
|  |         }, | ||||||
|         "defusedxml": { |         "defusedxml": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", |                 "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", | ||||||
| @ -413,11 +434,11 @@ | |||||||
|         }, |         }, | ||||||
|         "drf-spectacular": { |         "drf-spectacular": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:4d35e890b8139e1c056588c5529a2f2066615635482563f0840b96d3b879d7d2", |                 "sha256:6ffbfde7d96a4a2febd19182cc405217e1e86a50280fc739402291c93d1a32b7", | ||||||
|                 "sha256:f552476dfde647963c21615249672e7f4f9ece3788036b5ee5c6cc5ad50748ab" |                 "sha256:77593024bb899f69227abedcf87def7851a11c9978f781aa4b385a10f67a38b7" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==0.17.0" |             "version": "==0.17.2" | ||||||
|         }, |         }, | ||||||
|         "duo-client": { |         "duo-client": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -439,6 +460,7 @@ | |||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" |                 "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==0.18.2" |             "version": "==0.18.2" | ||||||
|         }, |         }, | ||||||
|         "geoip2": { |         "geoip2": { | ||||||
| @ -451,10 +473,11 @@ | |||||||
|         }, |         }, | ||||||
|         "google-auth": { |         "google-auth": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:9b235dbc876e49454cbedc52ae0abd540ef705ebccdf4fbe93553bb13f26b1a4", |                 "sha256:b3a67fa9ba5b768861dacf374c2135eb09fa14a0e40c851c3b8ea7abe6fc8fef", | ||||||
|                 "sha256:eb017521276a75492282c6ca4b718f26de112ed3bcbeaeeb02c1b82de425f909" |                 "sha256:e34e5f5de5610b202f9b40ebd9f8b27571d5c5537db9afed3a72b2db5a345039" | ||||||
|             ], |             ], | ||||||
|             "version": "==1.30.2" |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", | ||||||
|  |             "version": "==1.32.0" | ||||||
|         }, |         }, | ||||||
|         "gunicorn": { |         "gunicorn": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -469,6 +492,7 @@ | |||||||
|                 "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", |                 "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", | ||||||
|                 "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" |                 "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==0.12.0" |             "version": "==0.12.0" | ||||||
|         }, |         }, | ||||||
|         "hiredis": { |         "hiredis": { | ||||||
| @ -515,6 +539,7 @@ | |||||||
|                 "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0", |                 "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0", | ||||||
|                 "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a" |                 "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==2.0.0" |             "version": "==2.0.0" | ||||||
|         }, |         }, | ||||||
|         "httptools": { |         "httptools": { | ||||||
| @ -563,6 +588,7 @@ | |||||||
|                 "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", |                 "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", | ||||||
|                 "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" |                 "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.5'", | ||||||
|             "version": "==0.5.1" |             "version": "==0.5.1" | ||||||
|         }, |         }, | ||||||
|         "jmespath": { |         "jmespath": { | ||||||
| @ -570,6 +596,7 @@ | |||||||
|                 "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", |                 "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", | ||||||
|                 "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" |                 "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==0.10.0" |             "version": "==0.10.0" | ||||||
|         }, |         }, | ||||||
|         "jsonschema": { |         "jsonschema": { | ||||||
| @ -584,6 +611,7 @@ | |||||||
|                 "sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d", |                 "sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d", | ||||||
|                 "sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a" |                 "sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==5.1.0" |             "version": "==5.1.0" | ||||||
|         }, |         }, | ||||||
|         "kubernetes": { |         "kubernetes": { | ||||||
| @ -597,6 +625,9 @@ | |||||||
|         "ldap3": { |         "ldap3": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91", |                 "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91", | ||||||
|  |                 "sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59", | ||||||
|  |                 "sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c", | ||||||
|  |                 "sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056", | ||||||
|                 "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57" |                 "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
| @ -658,6 +689,7 @@ | |||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" |                 "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==2.0.3" |             "version": "==2.0.3" | ||||||
|         }, |         }, | ||||||
|         "msgpack": { |         "msgpack": { | ||||||
| @ -733,6 +765,7 @@ | |||||||
|                 "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", |                 "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", | ||||||
|                 "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" |                 "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==5.1.0" |             "version": "==5.1.0" | ||||||
|         }, |         }, | ||||||
|         "oauthlib": { |         "oauthlib": { | ||||||
| @ -740,6 +773,7 @@ | |||||||
|                 "sha256:42bf6354c2ed8c6acb54d971fce6f88193d97297e18602a3a886603f9d7730cc", |                 "sha256:42bf6354c2ed8c6acb54d971fce6f88193d97297e18602a3a886603f9d7730cc", | ||||||
|                 "sha256:8f0215fcc533dd8dd1bee6f4c412d4f0cd7297307d43ac61666389e3bc3198a3" |                 "sha256:8f0215fcc533dd8dd1bee6f4c412d4f0cd7297307d43ac61666389e3bc3198a3" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==3.1.1" |             "version": "==3.1.1" | ||||||
|         }, |         }, | ||||||
|         "packaging": { |         "packaging": { | ||||||
| @ -755,67 +789,85 @@ | |||||||
|                 "sha256:3a8baade6cb80bcfe43297e33e7623f3118d660d41387593758e2fb1ea173a86", |                 "sha256:3a8baade6cb80bcfe43297e33e7623f3118d660d41387593758e2fb1ea173a86", | ||||||
|                 "sha256:b014bc76815eb1399da8ce5fc84b7717a3e63652b0c0f8804092c9363acab1b2" |                 "sha256:b014bc76815eb1399da8ce5fc84b7717a3e63652b0c0f8804092c9363acab1b2" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==0.11.0" |             "version": "==0.11.0" | ||||||
|         }, |         }, | ||||||
|         "prompt-toolkit": { |         "prompt-toolkit": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", |                 "sha256:08360ee3a3148bdb5163621709ee322ec34fc4375099afa4bbf751e9b7b7fa4f", | ||||||
|                 "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" |                 "sha256:7089d8d2938043508aa9420ec18ce0922885304cddae87fb96eebca942299f88" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.0.18" |             "markers": "python_full_version >= '3.6.1'", | ||||||
|  |             "version": "==3.0.19" | ||||||
|         }, |         }, | ||||||
|         "psycopg2-binary": { |         "psycopg2-binary": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", |                 "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975", | ||||||
|                 "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", |                 "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd", | ||||||
|                 "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", |                 "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616", | ||||||
|                 "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", |                 "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2", | ||||||
|                 "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", |                 "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90", | ||||||
|                 "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", |                 "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a", | ||||||
|                 "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", |                 "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e", | ||||||
|                 "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", |                 "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d", | ||||||
|                 "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", |                 "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed", | ||||||
|                 "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", |                 "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a", | ||||||
|                 "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", |                 "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140", | ||||||
|                 "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", |                 "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32", | ||||||
|                 "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", |                 "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31", | ||||||
|                 "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", |                 "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a", | ||||||
|                 "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", |                 "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917", | ||||||
|                 "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", |                 "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf", | ||||||
|                 "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", |                 "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7", | ||||||
|                 "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", |                 "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0", | ||||||
|                 "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", |                 "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72", | ||||||
|                 "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", |                 "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698", | ||||||
|                 "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", |                 "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773", | ||||||
|                 "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", |                 "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68", | ||||||
|                 "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", |                 "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76", | ||||||
|                 "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", |                 "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4", | ||||||
|                 "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", |                 "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f", | ||||||
|                 "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", |                 "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34", | ||||||
|                 "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", |                 "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce", | ||||||
|                 "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", |                 "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a", | ||||||
|                 "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", |                 "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e" | ||||||
|                 "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", |  | ||||||
|                 "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", |  | ||||||
|                 "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", |  | ||||||
|                 "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", |  | ||||||
|                 "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", |  | ||||||
|                 "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" |  | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==2.8.6" |             "version": "==2.9.1" | ||||||
|         }, |         }, | ||||||
|         "pyasn1": { |         "pyasn1": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|  |                 "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", | ||||||
|  |                 "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", | ||||||
|  |                 "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", | ||||||
|  |                 "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", | ||||||
|                 "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", |                 "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", | ||||||
|                 "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" |                 "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", | ||||||
|  |                 "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", | ||||||
|  |                 "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", | ||||||
|  |                 "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", | ||||||
|  |                 "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", | ||||||
|  |                 "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", | ||||||
|  |                 "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", | ||||||
|  |                 "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.4.8" |             "version": "==0.4.8" | ||||||
|         }, |         }, | ||||||
|         "pyasn1-modules": { |         "pyasn1-modules": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|  |                 "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", | ||||||
|  |                 "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", | ||||||
|  |                 "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", | ||||||
|  |                 "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", | ||||||
|  |                 "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", | ||||||
|                 "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", |                 "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", | ||||||
|                 "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74" |                 "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", | ||||||
|  |                 "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", | ||||||
|  |                 "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", | ||||||
|  |                 "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", | ||||||
|  |                 "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", | ||||||
|  |                 "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", | ||||||
|  |                 "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.2.8" |             "version": "==0.2.8" | ||||||
|         }, |         }, | ||||||
| @ -824,6 +876,7 @@ | |||||||
|                 "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", |                 "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", | ||||||
|                 "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" |                 "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==2.20" |             "version": "==2.20" | ||||||
|         }, |         }, | ||||||
|         "pycryptodome": { |         "pycryptodome": { | ||||||
| @ -867,6 +920,7 @@ | |||||||
|                 "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", |                 "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", | ||||||
|                 "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" |                 "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.5'", | ||||||
|             "version": "==2.0.2" |             "version": "==2.0.2" | ||||||
|         }, |         }, | ||||||
|         "pyjwt": { |         "pyjwt": { | ||||||
| @ -889,27 +943,50 @@ | |||||||
|                 "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", |                 "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", | ||||||
|                 "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" |                 "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==2.4.7" |             "version": "==2.4.7" | ||||||
|         }, |         }, | ||||||
|         "pyrsistent": { |         "pyrsistent": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" |                 "sha256:097b96f129dd36a8c9e33594e7ebb151b1515eb52cceb08474c10a5479e799f2", | ||||||
|  |                 "sha256:2aaf19dc8ce517a8653746d98e962ef480ff34b6bc563fc067be6401ffb457c7", | ||||||
|  |                 "sha256:404e1f1d254d314d55adb8d87f4f465c8693d6f902f67eb6ef5b4526dc58e6ea", | ||||||
|  |                 "sha256:48578680353f41dca1ca3dc48629fb77dfc745128b56fc01096b2530c13fd426", | ||||||
|  |                 "sha256:4916c10896721e472ee12c95cdc2891ce5890898d2f9907b1b4ae0f53588b710", | ||||||
|  |                 "sha256:527be2bfa8dc80f6f8ddd65242ba476a6c4fb4e3aedbf281dfbac1b1ed4165b1", | ||||||
|  |                 "sha256:58a70d93fb79dc585b21f9d72487b929a6fe58da0754fa4cb9f279bb92369396", | ||||||
|  |                 "sha256:5e4395bbf841693eaebaa5bb5c8f5cdbb1d139e07c975c682ec4e4f8126e03d2", | ||||||
|  |                 "sha256:6b5eed00e597b5b5773b4ca30bd48a5774ef1e96f2a45d105db5b4ebb4bca680", | ||||||
|  |                 "sha256:73ff61b1411e3fb0ba144b8f08d6749749775fe89688093e1efef9839d2dcc35", | ||||||
|  |                 "sha256:772e94c2c6864f2cd2ffbe58bb3bdefbe2a32afa0acb1a77e472aac831f83427", | ||||||
|  |                 "sha256:773c781216f8c2900b42a7b638d5b517bb134ae1acbebe4d1e8f1f41ea60eb4b", | ||||||
|  |                 "sha256:a0c772d791c38bbc77be659af29bb14c38ced151433592e326361610250c605b", | ||||||
|  |                 "sha256:b29b869cf58412ca5738d23691e96d8aff535e17390128a1a52717c9a109da4f", | ||||||
|  |                 "sha256:c1a9ff320fa699337e05edcaae79ef8c2880b52720bc031b219e5b5008ebbdef", | ||||||
|  |                 "sha256:cd3caef37a415fd0dae6148a1b6957a8c5f275a62cca02e18474608cb263640c", | ||||||
|  |                 "sha256:d5ec194c9c573aafaceebf05fc400656722793dac57f254cd4741f3c27ae57b4", | ||||||
|  |                 "sha256:da6e5e818d18459fa46fac0a4a4e543507fe1110e808101277c5a2b5bab0cd2d", | ||||||
|  |                 "sha256:e79d94ca58fcafef6395f6352383fa1a76922268fa02caa2272fff501c2fdc78", | ||||||
|  |                 "sha256:f3ef98d7b76da5eb19c37fda834d50262ff9167c65658d1d8f974d2e4d90676b", | ||||||
|  |                 "sha256:f4c8cabb46ff8e5d61f56a037974228e978f26bfefce4f61a4b1ac0ba7a2ab72" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.17.3" |             "markers": "python_version >= '3.6'", | ||||||
|  |             "version": "==0.18.0" | ||||||
|         }, |         }, | ||||||
|         "python-dateutil": { |         "python-dateutil": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", |                 "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", | ||||||
|                 "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" |                 "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==2.8.1" |             "version": "==2.8.1" | ||||||
|         }, |         }, | ||||||
|         "python-dotenv": { |         "python-dotenv": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544", |                 "sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d", | ||||||
|                 "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f" |                 "sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d" | ||||||
|             ], |             ], | ||||||
|             "version": "==0.17.1" |             "version": "==0.18.0" | ||||||
|         }, |         }, | ||||||
|         "pytz": { |         "pytz": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -958,6 +1035,7 @@ | |||||||
|                 "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", |                 "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", | ||||||
|                 "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" |                 "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==3.5.3" |             "version": "==3.5.3" | ||||||
|         }, |         }, | ||||||
|         "requests": { |         "requests": { | ||||||
| @ -965,12 +1043,14 @@ | |||||||
|                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", |                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", | ||||||
|                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" |                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==2.25.1" |             "version": "==2.25.1" | ||||||
|         }, |         }, | ||||||
|         "requests-oauthlib": { |         "requests-oauthlib": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", |                 "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", | ||||||
|                 "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a" |                 "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", | ||||||
|  |                 "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.3.0" |             "version": "==1.3.0" | ||||||
| @ -1011,6 +1091,7 @@ | |||||||
|                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", |                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", | ||||||
|                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" |                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==1.16.0" |             "version": "==1.16.0" | ||||||
|         }, |         }, | ||||||
|         "sqlparse": { |         "sqlparse": { | ||||||
| @ -1018,6 +1099,7 @@ | |||||||
|                 "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", |                 "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", | ||||||
|                 "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" |                 "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.5'", | ||||||
|             "version": "==0.4.1" |             "version": "==0.4.1" | ||||||
|         }, |         }, | ||||||
|         "structlog": { |         "structlog": { | ||||||
| @ -1073,6 +1155,7 @@ | |||||||
|                 "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8", |                 "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8", | ||||||
|                 "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb" |                 "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==21.2.1" |             "version": "==21.2.1" | ||||||
|         }, |         }, | ||||||
|         "typing-extensions": { |         "typing-extensions": { | ||||||
| @ -1096,6 +1179,7 @@ | |||||||
|                 "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", |                 "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", | ||||||
|                 "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" |                 "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==3.0.1" |             "version": "==3.0.1" | ||||||
|         }, |         }, | ||||||
|         "urllib3": { |         "urllib3": { | ||||||
| @ -1103,11 +1187,11 @@ | |||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", |                 "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", | ||||||
|                 "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" |                 "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.5" |             "version": "==1.26.6" | ||||||
|         }, |         }, | ||||||
|         "uvicorn": { |         "uvicorn": { | ||||||
|             "extras": [ |             "extras": [ | ||||||
| @ -1140,6 +1224,7 @@ | |||||||
|                 "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", |                 "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", | ||||||
|                 "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" |                 "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==5.0.0" |             "version": "==5.0.0" | ||||||
|         }, |         }, | ||||||
|         "watchgod": { |         "watchgod": { | ||||||
| @ -1169,6 +1254,7 @@ | |||||||
|                 "sha256:b68e4959d704768fa20e35c9d508c8dc2bbc041fd8d267c0d7345cffe2824568", |                 "sha256:b68e4959d704768fa20e35c9d508c8dc2bbc041fd8d267c0d7345cffe2824568", | ||||||
|                 "sha256:e5c333bfa9fa739538b652b6f8c8fc2559f1d364243c8a689d7c0e1d41c2e611" |                 "sha256:e5c333bfa9fa739538b652b6f8c8fc2559f1d364243c8a689d7c0e1d41c2e611" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==1.1.0" |             "version": "==1.1.0" | ||||||
|         }, |         }, | ||||||
|         "websockets": { |         "websockets": { | ||||||
| @ -1266,6 +1352,7 @@ | |||||||
|                 "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", |                 "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", | ||||||
|                 "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" |                 "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==1.6.3" |             "version": "==1.6.3" | ||||||
|         }, |         }, | ||||||
|         "zope.interface": { |         "zope.interface": { | ||||||
| @ -1322,6 +1409,7 @@ | |||||||
|                 "sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4", |                 "sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4", | ||||||
|                 "sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263" |                 "sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==5.4.0" |             "version": "==5.4.0" | ||||||
|         } |         } | ||||||
|     }, |     }, | ||||||
| @ -1338,6 +1426,7 @@ | |||||||
|                 "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", |                 "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", | ||||||
|                 "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" |                 "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version ~= '3.6'", | ||||||
|             "version": "==2.5.6" |             "version": "==2.5.6" | ||||||
|         }, |         }, | ||||||
|         "attrs": { |         "attrs": { | ||||||
| @ -1345,6 +1434,7 @@ | |||||||
|                 "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", |                 "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", | ||||||
|                 "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" |                 "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==21.2.0" |             "version": "==21.2.0" | ||||||
|         }, |         }, | ||||||
|         "bandit": { |         "bandit": { | ||||||
| @ -1383,6 +1473,7 @@ | |||||||
|                 "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", |                 "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", | ||||||
|                 "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" |                 "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==4.0.0" |             "version": "==4.0.0" | ||||||
|         }, |         }, | ||||||
|         "click": { |         "click": { | ||||||
| @ -1390,6 +1481,7 @@ | |||||||
|                 "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", |                 "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", | ||||||
|                 "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" |                 "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==7.1.2" |             "version": "==7.1.2" | ||||||
|         }, |         }, | ||||||
|         "colorama": { |         "colorama": { | ||||||
| @ -1463,14 +1555,16 @@ | |||||||
|                 "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0", |                 "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0", | ||||||
|                 "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005" |                 "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.4'", | ||||||
|             "version": "==4.0.7" |             "version": "==4.0.7" | ||||||
|         }, |         }, | ||||||
|         "gitpython": { |         "gitpython": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:29fe82050709760081f588dd50ce83504feddbebdc4da6956d02351552b1c135", |                 "sha256:b838a895977b45ab6f0cc926a9045c8d1c44e2b653c1fcc39fe91f42c6e8f05b", | ||||||
|                 "sha256:ee24bdc93dce357630764db659edaf6b8d664d4ff5447ccfeedd2dc5c253f41e" |                 "sha256:fce760879cd2aebd2991b3542876dc5c4a909b30c9d69dfc488e504a8db37ee8" | ||||||
|             ], |             ], | ||||||
|             "version": "==3.1.17" |             "markers": "python_version >= '3.6'", | ||||||
|  |             "version": "==3.1.18" | ||||||
|         }, |         }, | ||||||
|         "idna": { |         "idna": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1488,10 +1582,11 @@ | |||||||
|         }, |         }, | ||||||
|         "isort": { |         "isort": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6", |                 "sha256:83510593e07e433b77bd5bff0f6f607dbafa06d1a89022616f02d8b699cfcd56", | ||||||
|                 "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d" |                 "sha256:8e2c107091cfec7286bc0f68a547d0ba4c094d460b732075b6fba674f1035c0c" | ||||||
|             ], |             ], | ||||||
|             "version": "==5.8.0" |             "markers": "python_version < '4' and python_full_version >= '3.6.1'", | ||||||
|  |             "version": "==5.9.1" | ||||||
|         }, |         }, | ||||||
|         "lazy-object-proxy": { |         "lazy-object-proxy": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
| @ -1518,6 +1613,7 @@ | |||||||
|                 "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", |                 "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", | ||||||
|                 "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" |                 "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", | ||||||
|             "version": "==1.6.0" |             "version": "==1.6.0" | ||||||
|         }, |         }, | ||||||
|         "mccabe": { |         "mccabe": { | ||||||
| @ -1554,6 +1650,7 @@ | |||||||
|                 "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", |                 "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", | ||||||
|                 "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" |                 "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.6'", | ||||||
|             "version": "==5.6.0" |             "version": "==5.6.0" | ||||||
|         }, |         }, | ||||||
|         "pluggy": { |         "pluggy": { | ||||||
| @ -1561,6 +1658,7 @@ | |||||||
|                 "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", |                 "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", | ||||||
|                 "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" |                 "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==0.13.1" |             "version": "==0.13.1" | ||||||
|         }, |         }, | ||||||
|         "py": { |         "py": { | ||||||
| @ -1568,6 +1666,7 @@ | |||||||
|                 "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", |                 "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", | ||||||
|                 "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" |                 "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==1.10.0" |             "version": "==1.10.0" | ||||||
|         }, |         }, | ||||||
|         "pylint": { |         "pylint": { | ||||||
| @ -1598,6 +1697,7 @@ | |||||||
|                 "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", |                 "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", | ||||||
|                 "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" |                 "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==2.4.7" |             "version": "==2.4.7" | ||||||
|         }, |         }, | ||||||
|         "pytest": { |         "pytest": { | ||||||
| @ -1702,6 +1802,7 @@ | |||||||
|                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", |                 "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", | ||||||
|                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" |                 "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", | ||||||
|             "version": "==2.25.1" |             "version": "==2.25.1" | ||||||
|         }, |         }, | ||||||
|         "requests-mock": { |         "requests-mock": { | ||||||
| @ -1725,6 +1826,7 @@ | |||||||
|                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", |                 "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", | ||||||
|                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" |                 "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==1.16.0" |             "version": "==1.16.0" | ||||||
|         }, |         }, | ||||||
|         "smmap": { |         "smmap": { | ||||||
| @ -1732,6 +1834,7 @@ | |||||||
|                 "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182", |                 "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182", | ||||||
|                 "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2" |                 "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.5'", | ||||||
|             "version": "==4.0.0" |             "version": "==4.0.0" | ||||||
|         }, |         }, | ||||||
|         "stevedore": { |         "stevedore": { | ||||||
| @ -1739,6 +1842,7 @@ | |||||||
|                 "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", |                 "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", | ||||||
|                 "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" |                 "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '3.6'", | ||||||
|             "version": "==3.3.0" |             "version": "==3.3.0" | ||||||
|         }, |         }, | ||||||
|         "toml": { |         "toml": { | ||||||
| @ -1746,6 +1850,7 @@ | |||||||
|                 "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", |                 "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", | ||||||
|                 "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" |                 "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" | ||||||
|             ], |             ], | ||||||
|  |             "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", | ||||||
|             "version": "==0.10.2" |             "version": "==0.10.2" | ||||||
|         }, |         }, | ||||||
|         "urllib3": { |         "urllib3": { | ||||||
| @ -1753,11 +1858,11 @@ | |||||||
|                 "secure" |                 "secure" | ||||||
|             ], |             ], | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|                 "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", |                 "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", | ||||||
|                 "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" |                 "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" | ||||||
|             ], |             ], | ||||||
|             "index": "pypi", |             "index": "pypi", | ||||||
|             "version": "==1.26.5" |             "version": "==1.26.6" | ||||||
|         }, |         }, | ||||||
|         "wrapt": { |         "wrapt": { | ||||||
|             "hashes": [ |             "hashes": [ | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ authentik is an open-source Identity Provider focused on flexibility and versati | |||||||
|  |  | ||||||
| For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/) | For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/) | ||||||
|  |  | ||||||
| For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://goauthentik.io/docs/installation/kubernetes/) | For bigger setups, there is a Helm Chart [here])(https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/) | ||||||
|  |  | ||||||
| ## Screenshots | ## Screenshots | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,3 +1,3 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "2021.6.1-rc4" | __version__ = "2021.6.3" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  | |||||||
| @ -10,3 +10,25 @@ class AuthentikAPIConfig(AppConfig): | |||||||
|     label = "authentik_api" |     label = "authentik_api" | ||||||
|     mountpoint = "api/" |     mountpoint = "api/" | ||||||
|     verbose_name = "authentik API" |     verbose_name = "authentik API" | ||||||
|  |  | ||||||
|  |     def ready(self) -> None: | ||||||
|  |         from drf_spectacular.extensions import OpenApiAuthenticationExtension | ||||||
|  |  | ||||||
|  |         from authentik.api.authentication import TokenAuthentication | ||||||
|  |  | ||||||
|  |         # Class is defined here as it needs to be created early enough that drf-spectacular will | ||||||
|  |         # find it, but also won't cause any import issues | ||||||
|  |         # pylint: disable=unused-variable | ||||||
|  |         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", | ||||||
|  |                 } | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ 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 | ||||||
| @ -20,7 +19,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]: | |||||||
|     auth_credentials = raw_header.decode() |     auth_credentials = raw_header.decode() | ||||||
|     if auth_credentials == "" or " " not in auth_credentials: |     if auth_credentials == "" or " " not in auth_credentials: | ||||||
|         return None |         return None | ||||||
|     auth_type, auth_credentials = auth_credentials.split() |     auth_type, _, auth_credentials = auth_credentials.partition(" ") | ||||||
|     if auth_type.lower() not in ["basic", "bearer"]: |     if auth_type.lower() not in ["basic", "bearer"]: | ||||||
|         LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) |         LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) | ||||||
|         raise AuthenticationFailed("Unsupported authentication type") |         raise AuthenticationFailed("Unsupported authentication type") | ||||||
| @ -56,18 +55,3 @@ class TokenAuthentication(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", |  | ||||||
|         } |  | ||||||
|  | |||||||
| @ -11,13 +11,7 @@ from drf_spectacular.utils import ( | |||||||
|     inline_serializer, |     inline_serializer, | ||||||
| ) | ) | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import ( | from rest_framework.fields import BooleanField, CharField, FileField, ReadOnlyField | ||||||
|     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 | ||||||
| @ -107,15 +101,19 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         return applications |         return applications | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         request=inline_serializer( |         parameters=[ | ||||||
|             "CheckAccessRequest", fields={"for_user": IntegerField(required=False)} |             OpenApiParameter( | ||||||
|         ), |                 name="for_user", | ||||||
|  |                 location=OpenApiParameter.QUERY, | ||||||
|  |                 type=OpenApiTypes.INT, | ||||||
|  |             ) | ||||||
|  |         ], | ||||||
|         responses={ |         responses={ | ||||||
|             200: PolicyTestResultSerializer(), |             200: PolicyTestResultSerializer(), | ||||||
|             404: OpenApiResponse(description="for_user user not found"), |             404: OpenApiResponse(description="for_user user not found"), | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, methods=["POST"]) |     @action(detail=True, methods=["GET"]) | ||||||
|     # 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""" | ||||||
| @ -204,7 +202,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         """Set application icon""" |         """Set application icon""" | ||||||
|         app: Application = self.get_object() |         app: Application = self.get_object() | ||||||
|         icon = request.FILES.get("file", None) |         icon = request.FILES.get("file", None) | ||||||
|         clear = request.data.get("clear", False) |         clear = request.data.get("clear", "false").lower() == "true" | ||||||
|         if clear: |         if clear: | ||||||
|             # .delete() saves the model by default |             # .delete() saves the model by default | ||||||
|             app.meta_icon.delete() |             app.meta_icon.delete() | ||||||
|  | |||||||
| @ -3,23 +3,33 @@ from traceback import format_tb | |||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  | from guardian.utils import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import PropertyMapping, User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
|  | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
| class PropertyMappingEvaluator(BaseEvaluator): | class PropertyMappingEvaluator(BaseEvaluator): | ||||||
|     """Custom Evalautor that adds some different context variables.""" |     """Custom Evalautor that adds some different context variables.""" | ||||||
|  |  | ||||||
|     def set_context( |     def set_context( | ||||||
|         self, user: Optional[User], request: Optional[HttpRequest], **kwargs |         self, | ||||||
|  |         user: Optional[User], | ||||||
|  |         request: Optional[HttpRequest], | ||||||
|  |         mapping: PropertyMapping, | ||||||
|  |         **kwargs, | ||||||
|     ): |     ): | ||||||
|         """Update context with context from PropertyMapping's evaluate""" |         """Update context with context from PropertyMapping's evaluate""" | ||||||
|  |         req = PolicyRequest(user=get_anonymous_user()) | ||||||
|  |         req.obj = mapping | ||||||
|         if user: |         if user: | ||||||
|  |             req.user = user | ||||||
|             self._context["user"] = user |             self._context["user"] = user | ||||||
|         if request: |         if request: | ||||||
|             self._context["request"] = request |             req.http_request = request | ||||||
|  |         self._context["request"] = req | ||||||
|         self._context.update(**kwargs) |         self._context.update(**kwargs) | ||||||
|  |  | ||||||
|     def handle_error(self, exc: Exception, expression_source: str): |     def handle_error(self, exc: Exception, expression_source: str): | ||||||
| @ -30,9 +40,8 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|             expression=expression_source, |             expression=expression_source, | ||||||
|             message=error_string, |             message=error_string, | ||||||
|         ) |         ) | ||||||
|         if "user" in self._context: |  | ||||||
|             event.set_user(self._context["user"]) |  | ||||||
|         if "request" in self._context: |         if "request" in self._context: | ||||||
|             event.from_http(self._context["request"]) |             req: PolicyRequest = self._context["request"] | ||||||
|  |             event.from_http(req.http_request, req.user) | ||||||
|             return |             return | ||||||
|         event.save() |         event.save() | ||||||
|  | |||||||
| @ -26,6 +26,8 @@ class ImpersonateMiddleware: | |||||||
|  |  | ||||||
|         if SESSION_IMPERSONATE_USER in request.session: |         if SESSION_IMPERSONATE_USER in request.session: | ||||||
|             request.user = request.session[SESSION_IMPERSONATE_USER] |             request.user = request.session[SESSION_IMPERSONATE_USER] | ||||||
|  |             # Ensure that the user is active, otherwise nothing will work | ||||||
|  |             request.user.is_active = True | ||||||
|  |  | ||||||
|         return self.get_response(request) |         return self.get_response(request) | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from urllib.parse import urlencode | |||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| import django.db.models.options as options | import django.db.models.options as options | ||||||
|  | from deepmerge import always_merger | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.models import AbstractUser | from django.contrib.auth.models import AbstractUser | ||||||
| from django.contrib.auth.models import UserManager as DjangoUserManager | from django.contrib.auth.models import UserManager as DjangoUserManager | ||||||
| @ -114,8 +115,8 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|         including the users attributes""" |         including the users attributes""" | ||||||
|         final_attributes = {} |         final_attributes = {} | ||||||
|         for group in self.ak_groups.all().order_by("name"): |         for group in self.ak_groups.all().order_by("name"): | ||||||
|             final_attributes.update(group.attributes) |             always_merger.merge(final_attributes, group.attributes) | ||||||
|         final_attributes.update(self.attributes) |         always_merger.merge(final_attributes, self.attributes) | ||||||
|         return final_attributes |         return final_attributes | ||||||
|  |  | ||||||
|     @cached_property |     @cached_property | ||||||
| @ -142,21 +143,25 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|     @property |     @property | ||||||
|     def avatar(self) -> str: |     def avatar(self) -> str: | ||||||
|         """Get avatar, depending on authentik.avatar setting""" |         """Get avatar, depending on authentik.avatar setting""" | ||||||
|         mode = CONFIG.raw.get("authentik").get("avatars") |         mode: str = CONFIG.y("avatars", "none") | ||||||
|         if mode == "none": |         if mode == "none": | ||||||
|             return DEFAULT_AVATAR |             return DEFAULT_AVATAR | ||||||
|  |         # gravatar uses md5 for their URLs, so md5 can't be avoided | ||||||
|  |         mail_hash = md5(self.email.encode("utf-8")).hexdigest()  # nosec | ||||||
|         if mode == "gravatar": |         if mode == "gravatar": | ||||||
|             parameters = [ |             parameters = [ | ||||||
|                 ("s", "158"), |                 ("s", "158"), | ||||||
|                 ("r", "g"), |                 ("r", "g"), | ||||||
|             ] |             ] | ||||||
|             # gravatar uses md5 for their URLs, so md5 can't be avoided |  | ||||||
|             mail_hash = md5(self.email.encode("utf-8")).hexdigest()  # nosec |  | ||||||
|             gravatar_url = ( |             gravatar_url = ( | ||||||
|                 f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" |                 f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" | ||||||
|             ) |             ) | ||||||
|             return escape(gravatar_url) |             return escape(gravatar_url) | ||||||
|         raise ValueError(f"Invalid avatar mode {mode}") |         return mode % { | ||||||
|  |             "username": self.username, | ||||||
|  |             "mail_hash": mail_hash, | ||||||
|  |             "upn": self.attributes.get("upn", ""), | ||||||
|  |         } | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -460,7 +465,7 @@ class PropertyMapping(SerializerModel, ManagedModel): | |||||||
|         from authentik.core.expression import PropertyMappingEvaluator |         from authentik.core.expression import PropertyMappingEvaluator | ||||||
|  |  | ||||||
|         evaluator = PropertyMappingEvaluator() |         evaluator = PropertyMappingEvaluator() | ||||||
|         evaluator.set_context(user, request, **kwargs) |         evaluator.set_context(user, request, self, **kwargs) | ||||||
|         try: |         try: | ||||||
|             return evaluator.evaluate(self.expression) |             return evaluator.evaluate(self.expression) | ||||||
|         except (ValueError, SyntaxError) as exc: |         except (ValueError, SyntaxError) as exc: | ||||||
| @ -494,8 +499,12 @@ class AuthenticatedSession(ExpiringModel): | |||||||
|     last_used = models.DateTimeField(auto_now=True) |     last_used = models.DateTimeField(auto_now=True) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_request(request: HttpRequest, user: User) -> "AuthenticatedSession": |     def from_request( | ||||||
|  |         request: HttpRequest, user: User | ||||||
|  |     ) -> Optional["AuthenticatedSession"]: | ||||||
|         """Create a new session from a http request""" |         """Create a new session from a http request""" | ||||||
|  |         if not hasattr(request, "session") or not request.session.session_key: | ||||||
|  |             return None | ||||||
|         return AuthenticatedSession( |         return AuthenticatedSession( | ||||||
|             session_key=request.session.session_key, |             session_key=request.session.session_key, | ||||||
|             user=user, |             user=user, | ||||||
|  | |||||||
| @ -1,11 +1,12 @@ | |||||||
| """authentik core signals""" | """authentik core signals""" | ||||||
| from typing import TYPE_CHECKING | from typing import TYPE_CHECKING, Type | ||||||
|  |  | ||||||
| from django.contrib.auth.signals import user_logged_in, user_logged_out | from django.contrib.auth.signals import user_logged_in, user_logged_out | ||||||
|  | from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
| 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 import Model | ||||||
| from django.db.models.signals import post_save | from django.db.models.signals import post_save, pre_delete | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from prometheus_client import Gauge | from prometheus_client import Gauge | ||||||
| @ -18,7 +19,7 @@ GAUGE_MODELS = Gauge( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from authentik.core.models import User |     from authentik.core.models import AuthenticatedSession, User | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| @ -48,7 +49,9 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): | |||||||
|     """Create an AuthenticatedSession from request""" |     """Create an AuthenticatedSession from request""" | ||||||
|     from authentik.core.models import AuthenticatedSession |     from authentik.core.models import AuthenticatedSession | ||||||
|  |  | ||||||
|     AuthenticatedSession.from_request(request, user).save() |     session = AuthenticatedSession.from_request(request, user) | ||||||
|  |     if session: | ||||||
|  |         session.save() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_logged_out) | @receiver(user_logged_out) | ||||||
| @ -60,3 +63,17 @@ def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): | |||||||
|     AuthenticatedSession.objects.filter( |     AuthenticatedSession.objects.filter( | ||||||
|         session_key=request.session.session_key |         session_key=request.session.session_key | ||||||
|     ).delete() |     ).delete() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(pre_delete) | ||||||
|  | def authenticated_session_delete( | ||||||
|  |     sender: Type[Model], instance: "AuthenticatedSession", **_ | ||||||
|  | ): | ||||||
|  |     """Delete session when authenticated session is deleted""" | ||||||
|  |     from authentik.core.models import AuthenticatedSession | ||||||
|  |  | ||||||
|  |     if sender != AuthenticatedSession: | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     cache_key = f"{KEY_PREFIX}{instance.session_key}" | ||||||
|  |     cache.delete(cache_key) | ||||||
|  | |||||||
| @ -33,6 +33,7 @@ from authentik.flows.planner import ( | |||||||
| from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||||
| from authentik.lib.utils.urls import redirect_with_qs | from authentik.lib.utils.urls import redirect_with_qs | ||||||
| from authentik.policies.utils import delete_none_keys | from authentik.policies.utils import delete_none_keys | ||||||
|  | from authentik.stages.password import BACKEND_DJANGO | ||||||
| from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||||
|  |  | ||||||
| @ -182,6 +183,8 @@ class SourceFlowManager: | |||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def get_stages_to_append(self, flow: Flow) -> list[Stage]: |     def get_stages_to_append(self, flow: Flow) -> list[Stage]: | ||||||
|         """Hook to override stages which are appended to the flow""" |         """Hook to override stages which are appended to the flow""" | ||||||
|  |         if not self.source.enrollment_flow: | ||||||
|  |             return [] | ||||||
|         if flow.slug == self.source.enrollment_flow.slug: |         if flow.slug == self.source.enrollment_flow.slug: | ||||||
|             return [ |             return [ | ||||||
|                 in_memory_stage(PostUserEnrollmentStage), |                 in_memory_stage(PostUserEnrollmentStage), | ||||||
| @ -198,7 +201,7 @@ class SourceFlowManager: | |||||||
|         kwargs.update( |         kwargs.update( | ||||||
|             { |             { | ||||||
|                 # Since we authenticate the user by their token, they have no backend set |                 # Since we authenticate the user by their token, they have no backend set | ||||||
|                 PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", |                 PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO, | ||||||
|                 PLAN_CONTEXT_SSO: True, |                 PLAN_CONTEXT_SSO: True, | ||||||
|                 PLAN_CONTEXT_SOURCE: self.source, |                 PLAN_CONTEXT_SOURCE: self.source, | ||||||
|                 PLAN_CONTEXT_REDIRECT: final_redirect, |                 PLAN_CONTEXT_REDIRECT: final_redirect, | ||||||
| @ -210,7 +213,7 @@ class SourceFlowManager: | |||||||
|         planner = FlowPlanner(flow) |         planner = FlowPlanner(flow) | ||||||
|         plan = planner.plan(self.request, kwargs) |         plan = planner.plan(self.request, kwargs) | ||||||
|         for stage in self.get_stages_to_append(flow): |         for stage in self.get_stages_to_append(flow): | ||||||
|             plan.append(stage) |             plan.append_stage(stage=stage) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -3,16 +3,6 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| {% block head %} |  | ||||||
| {{ block.super }} |  | ||||||
| <style> |  | ||||||
| .pf-c-background-image::before { |  | ||||||
|     background-image: url("{% static 'dist/assets/images/flow_background.jpg' %}"); |  | ||||||
|     background-position: center; |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| {% endblock %} |  | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
| {% trans 'End session' %} - {{ tenant.branding_title }} | {% trans 'End session' %} - {{ tenant.branding_title }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -11,6 +11,11 @@ | |||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script> | <script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script> | ||||||
|  | <style> | ||||||
|  | .pf-c-background-image::before { | ||||||
|  |     --ak-flow-background: url("{{ flow.background_url }}"); | ||||||
|  | } | ||||||
|  | </style> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
|  | |||||||
| @ -7,6 +7,14 @@ | |||||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}"> | <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}"> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block head %} | ||||||
|  | <style> | ||||||
|  | .pf-c-background-image::before { | ||||||
|  |     --ak-flow-background: url("/static/dist/assets/images/flow_background.jpg"); | ||||||
|  | } | ||||||
|  | </style> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <div class="pf-c-background-image"> | <div class="pf-c-background-image"> | ||||||
|     <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> |     <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> | ||||||
|  | |||||||
| @ -26,7 +26,7 @@ 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.post( |         response = self.client.get( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:application-check-access", |                 "authentik_api:application-check-access", | ||||||
|                 kwargs={"slug": self.allowed.slug}, |                 kwargs={"slug": self.allowed.slug}, | ||||||
| @ -36,7 +36,7 @@ class TestApplicationsAPI(APITestCase): | |||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), {"messages": [], "passing": True} |             force_str(response.content), {"messages": [], "passing": True} | ||||||
|         ) |         ) | ||||||
|         response = self.client.post( |         response = self.client.get( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:application-check-access", |                 "authentik_api:application-check-access", | ||||||
|                 kwargs={"slug": self.denied.slug}, |                 kwargs={"slug": self.denied.slug}, | ||||||
|  | |||||||
| @ -17,6 +17,9 @@ class TestImpersonation(TestCase): | |||||||
|  |  | ||||||
|     def test_impersonate_simple(self): |     def test_impersonate_simple(self): | ||||||
|         """test simple impersonation and un-impersonation""" |         """test simple impersonation and un-impersonation""" | ||||||
|  |         # test with an inactive user to ensure that still works | ||||||
|  |         self.other_user.is_active = False | ||||||
|  |         self.other_user.save() | ||||||
|         self.client.force_login(self.akadmin) |         self.client.force_login(self.akadmin) | ||||||
|  |  | ||||||
|         self.client.get( |         self.client.get( | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from rest_framework.fields import CharField, DictField | from rest_framework.fields import CharField | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.flows.challenge import Challenge | from authentik.flows.challenge import Challenge | ||||||
| @ -22,18 +22,10 @@ class UILoginButton: | |||||||
|     icon_url: Optional[str] = None |     icon_url: Optional[str] = None | ||||||
|  |  | ||||||
|  |  | ||||||
| class UILoginButtonSerializer(PassiveSerializer): |  | ||||||
|     """Serializer for Login buttons of sources""" |  | ||||||
|  |  | ||||||
|     name = CharField() |  | ||||||
|     challenge = DictField() |  | ||||||
|     icon_url = CharField(required=False, allow_null=True) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserSettingSerializer(PassiveSerializer): | class UserSettingSerializer(PassiveSerializer): | ||||||
|     """Serializer for User settings for stages and sources""" |     """Serializer for User settings for stages and sources""" | ||||||
|  |  | ||||||
|     object_uid = CharField() |     object_uid = CharField() | ||||||
|     component = CharField() |     component = CharField() | ||||||
|     title = CharField() |     title = CharField() | ||||||
|     configure_url = CharField() |     configure_url = CharField(required=False) | ||||||
|  | |||||||
| @ -55,11 +55,16 @@ class CertificateKeyPair(CreatedUpdatedModel): | |||||||
|     def private_key(self) -> Optional[RSAPrivateKey]: |     def private_key(self) -> Optional[RSAPrivateKey]: | ||||||
|         """Get python cryptography PrivateKey instance""" |         """Get python cryptography PrivateKey instance""" | ||||||
|         if not self._private_key and self._private_key != "": |         if not self._private_key and self._private_key != "": | ||||||
|  |             try: | ||||||
|                 self._private_key = load_pem_private_key( |                 self._private_key = load_pem_private_key( | ||||||
|                 str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])), |                     str.encode( | ||||||
|  |                         "\n".join([x.strip() for x in self.key_data.split("\n")]) | ||||||
|  |                     ), | ||||||
|                     password=None, |                     password=None, | ||||||
|                     backend=default_backend(), |                     backend=default_backend(), | ||||||
|                 ) |                 ) | ||||||
|  |             except ValueError: | ||||||
|  |                 return None | ||||||
|         return self._private_key |         return self._private_key | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|  | |||||||
| @ -6,11 +6,11 @@ from drf_spectacular.types import OpenApiTypes | |||||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema | from drf_spectacular.utils import OpenApiParameter, extend_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, DictField, IntegerField | from rest_framework.fields import DictField, IntegerField | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer | from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| @ -19,11 +19,6 @@ from authentik.events.models import Event, EventAction | |||||||
| class EventSerializer(ModelSerializer): | class EventSerializer(ModelSerializer): | ||||||
|     """Event Serializer""" |     """Event Serializer""" | ||||||
|  |  | ||||||
|     # Since we only use this serializer for read-only operations, |  | ||||||
|     # no checking of the action is done here. |  | ||||||
|     # This allows clients to check wildcards, prefixes and custom types |  | ||||||
|     action = CharField() |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Event |         model = Event | ||||||
| @ -36,6 +31,7 @@ class EventSerializer(ModelSerializer): | |||||||
|             "client_ip", |             "client_ip", | ||||||
|             "created", |             "created", | ||||||
|             "expires", |             "expires", | ||||||
|  |             "tenant", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -76,6 +72,11 @@ class EventsFilter(django_filters.FilterSet): | |||||||
|         field_name="action", |         field_name="action", | ||||||
|         lookup_expr="icontains", |         lookup_expr="icontains", | ||||||
|     ) |     ) | ||||||
|  |     tenant_name = django_filters.CharFilter( | ||||||
|  |         field_name="tenant", | ||||||
|  |         lookup_expr="name", | ||||||
|  |         label="Tenant name", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def filter_context_model_pk(self, queryset, name, value): |     def filter_context_model_pk(self, queryset, name, value): | ||||||
| @ -90,7 +91,7 @@ class EventsFilter(django_filters.FilterSet): | |||||||
|         fields = ["action", "client_ip", "username"] |         fields = ["action", "client_ip", "username"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EventViewSet(ReadOnlyModelViewSet): | class EventViewSet(ModelViewSet): | ||||||
|     """Event Read-Only Viewset""" |     """Event Read-Only Viewset""" | ||||||
|  |  | ||||||
|     queryset = Event.objects.all() |     queryset = Event.objects.all() | ||||||
|  | |||||||
| @ -40,9 +40,9 @@ class GeoIPReader: | |||||||
|             return |             return | ||||||
|         try: |         try: | ||||||
|             reader = Reader(path) |             reader = Reader(path) | ||||||
|             LOGGER.info("Loaded GeoIP database") |  | ||||||
|             self.__reader = reader |             self.__reader = reader | ||||||
|             self.__last_mtime = stat(path).st_mtime |             self.__last_mtime = stat(path).st_mtime | ||||||
|  |             LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime) | ||||||
|         except OSError as exc: |         except OSError as exc: | ||||||
|             LOGGER.warning("Failed to load GeoIP database", exc=exc) |             LOGGER.warning("Failed to load GeoIP database", exc=exc) | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
| from functools import partial | from functools import partial | ||||||
| from typing import Callable | from typing import Callable | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.signals import post_save, pre_delete | from django.db.models.signals import post_save, pre_delete | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| @ -12,6 +13,8 @@ from authentik.core.models import User | |||||||
| from authentik.events.models import Event, EventAction, Notification | from authentik.events.models import Event, EventAction, Notification | ||||||
| from authentik.events.signals import EventNewThread | from authentik.events.signals import EventNewThread | ||||||
| from authentik.events.utils import model_to_dict | from authentik.events.utils import model_to_dict | ||||||
|  | from authentik.lib.sentry import before_send | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuditMiddleware: | class AuditMiddleware: | ||||||
| @ -54,10 +57,20 @@ class AuditMiddleware: | |||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def process_exception(self, request: HttpRequest, exception: Exception): |     def process_exception(self, request: HttpRequest, exception: Exception): | ||||||
|         """Unregister handlers in case of exception""" |         """Disconnect handlers in case of exception""" | ||||||
|         post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) |         post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) | ||||||
|         pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) |         pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) | ||||||
|  |  | ||||||
|  |         if settings.DEBUG: | ||||||
|  |             return | ||||||
|  |         if before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||||
|  |             thread = EventNewThread( | ||||||
|  |                 EventAction.SYSTEM_EXCEPTION, | ||||||
|  |                 request, | ||||||
|  |                 message=exception_to_string(exception), | ||||||
|  |             ) | ||||||
|  |             thread.run() | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def post_save_handler( |     def post_save_handler( | ||||||
|  | |||||||
							
								
								
									
										55
									
								
								authentik/events/migrations/0016_add_tenant.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								authentik/events/migrations/0016_add_tenant.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-06-14 15:33 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  | import authentik.events.models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_events", "0015_alter_event_action"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="event", | ||||||
|  |             name="tenant", | ||||||
|  |             field=models.JSONField( | ||||||
|  |                 blank=True, default=authentik.events.models.default_tenant | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         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"), | ||||||
|  |                     ("system_exception", "System Exception"), | ||||||
|  |                     ("configuration_error", "Configuration Error"), | ||||||
|  |                     ("model_created", "Model Created"), | ||||||
|  |                     ("model_updated", "Model Updated"), | ||||||
|  |                     ("model_deleted", "Model Deleted"), | ||||||
|  |                     ("email_sent", "Email Sent"), | ||||||
|  |                     ("update_available", "Update Available"), | ||||||
|  |                     ("custom_", "Custom Prefix"), | ||||||
|  |                 ] | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -21,11 +21,12 @@ from authentik.core.middleware import ( | |||||||
| ) | ) | ||||||
| from authentik.core.models import ExpiringModel, Group, User | from authentik.core.models import ExpiringModel, Group, User | ||||||
| from authentik.events.geo import GEOIP_READER | from authentik.events.geo import GEOIP_READER | ||||||
| from authentik.events.utils import cleanse_dict, get_user, sanitize_dict | from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.policies.models import PolicyBindingModel | from authentik.policies.models import PolicyBindingModel | ||||||
| from authentik.stages.email.utils import TemplateEmailMessage | from authentik.stages.email.utils import TemplateEmailMessage | ||||||
|  | from authentik.tenants.utils import DEFAULT_TENANT | ||||||
|  |  | ||||||
| LOGGER = get_logger("authentik.events") | LOGGER = get_logger("authentik.events") | ||||||
| GAUGE_EVENTS = Gauge( | GAUGE_EVENTS = Gauge( | ||||||
| @ -40,6 +41,11 @@ def default_event_duration(): | |||||||
|     return now() + timedelta(days=365) |     return now() + timedelta(days=365) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def default_tenant(): | ||||||
|  |     """Get a default value for tenant""" | ||||||
|  |     return sanitize_dict(model_to_dict(DEFAULT_TENANT)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotificationTransportError(SentryIgnoredException): | class NotificationTransportError(SentryIgnoredException): | ||||||
|     """Error raised when a notification fails to be delivered""" |     """Error raised when a notification fails to be delivered""" | ||||||
|  |  | ||||||
| @ -71,6 +77,7 @@ class EventAction(models.TextChoices): | |||||||
|  |  | ||||||
|     SYSTEM_TASK_EXECUTION = "system_task_execution" |     SYSTEM_TASK_EXECUTION = "system_task_execution" | ||||||
|     SYSTEM_TASK_EXCEPTION = "system_task_exception" |     SYSTEM_TASK_EXCEPTION = "system_task_exception" | ||||||
|  |     SYSTEM_EXCEPTION = "system_exception" | ||||||
|  |  | ||||||
|     CONFIGURATION_ERROR = "configuration_error" |     CONFIGURATION_ERROR = "configuration_error" | ||||||
|  |  | ||||||
| @ -94,6 +101,7 @@ class Event(ExpiringModel): | |||||||
|     context = models.JSONField(default=dict, blank=True) |     context = models.JSONField(default=dict, blank=True) | ||||||
|     client_ip = models.GenericIPAddressField(null=True) |     client_ip = models.GenericIPAddressField(null=True) | ||||||
|     created = models.DateTimeField(auto_now_add=True) |     created = models.DateTimeField(auto_now_add=True) | ||||||
|  |     tenant = models.JSONField(default=default_tenant, blank=True) | ||||||
|  |  | ||||||
|     # Shadow the expires attribute from ExpiringModel to override the default duration |     # Shadow the expires attribute from ExpiringModel to override the default duration | ||||||
|     expires = models.DateTimeField(default=default_event_duration) |     expires = models.DateTimeField(default=default_event_duration) | ||||||
| @ -132,6 +140,13 @@ class Event(ExpiringModel): | |||||||
|         """Add data from a Django-HttpRequest, allowing the creation of |         """Add data from a Django-HttpRequest, allowing the creation of | ||||||
|         Events independently from requests. |         Events independently from requests. | ||||||
|         `user` arguments optionally overrides user from requests.""" |         `user` arguments optionally overrides user from requests.""" | ||||||
|  |         if request: | ||||||
|  |             self.context["http_request"] = { | ||||||
|  |                 "path": request.get_full_path(), | ||||||
|  |                 "method": request.method, | ||||||
|  |             } | ||||||
|  |         if hasattr(request, "tenant"): | ||||||
|  |             self.tenant = sanitize_dict(model_to_dict(request.tenant)) | ||||||
|         if hasattr(request, "user"): |         if hasattr(request, "user"): | ||||||
|             original_user = None |             original_user = None | ||||||
|             if hasattr(request, "session"): |             if hasattr(request, "session"): | ||||||
|  | |||||||
| @ -105,7 +105,11 @@ def notification_transport( | |||||||
|     """Send notification over specified transport""" |     """Send notification over specified transport""" | ||||||
|     self.save_on_success = False |     self.save_on_success = False | ||||||
|     try: |     try: | ||||||
|         notification: Notification = Notification.objects.get(pk=notification_pk) |         notification: Notification = Notification.objects.filter( | ||||||
|  |             pk=notification_pk | ||||||
|  |         ).first() | ||||||
|  |         if not notification: | ||||||
|  |             return | ||||||
|         transport: NotificationTransport = NotificationTransport.objects.get( |         transport: NotificationTransport = NotificationTransport.objects.get( | ||||||
|             pk=transport_pk |             pk=transport_pk | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -25,6 +25,7 @@ class FlowStageBindingSerializer(ModelSerializer): | |||||||
|             "re_evaluate_policies", |             "re_evaluate_policies", | ||||||
|             "order", |             "order", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|  |             "invalid_response_action", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -301,10 +301,14 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|         """Set Flow background""" |         """Set Flow background""" | ||||||
|         flow: Flow = self.get_object() |         flow: Flow = self.get_object() | ||||||
|         background = request.FILES.get("file", None) |         background = request.FILES.get("file", None) | ||||||
|         clear = request.data.get("clear", False) |         clear = request.data.get("clear", "false").lower() == "true" | ||||||
|         if clear: |         if clear: | ||||||
|  |             if flow.background_url.startswith("/media"): | ||||||
|                 # .delete() saves the model by default |                 # .delete() saves the model by default | ||||||
|                 flow.background.delete() |                 flow.background.delete() | ||||||
|  |             else: | ||||||
|  |                 flow.background = None | ||||||
|  |                 flow.save() | ||||||
|             return Response({}) |             return Response({}) | ||||||
|         if background: |         if background: | ||||||
|             flow.background = background |             flow.background = background | ||||||
|  | |||||||
| @ -93,7 +93,7 @@ class StageViewSet( | |||||||
|             if not user_settings: |             if not user_settings: | ||||||
|                 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") and stage.configure_flow: | ||||||
|                 user_settings.initial_data["configure_url"] = reverse( |                 user_settings.initial_data["configure_url"] = reverse( | ||||||
|                     "authentik_flows:configure", |                     "authentik_flows:configure", | ||||||
|                     kwargs={"stage_uuid": stage.pk}, |                     kwargs={"stage_uuid": stage.pk}, | ||||||
|  | |||||||
| @ -5,8 +5,7 @@ from typing import TYPE_CHECKING, Optional | |||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.flows.models import FlowStageBinding | ||||||
| from authentik.flows.models import Stage |  | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
|  |  | ||||||
| @ -22,11 +21,14 @@ class StageMarker: | |||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def process( |     def process( | ||||||
|         self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] |         self, | ||||||
|     ) -> Optional[Stage]: |         plan: "FlowPlan", | ||||||
|  |         binding: FlowStageBinding, | ||||||
|  |         http_request: HttpRequest, | ||||||
|  |     ) -> Optional[FlowStageBinding]: | ||||||
|         """Process callback for this marker. This should be overridden by sub-classes. |         """Process callback for this marker. This should be overridden by sub-classes. | ||||||
|         If a stage should be removed, return None.""" |         If a stage should be removed, return None.""" | ||||||
|         return stage |         return binding | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
| @ -34,24 +36,34 @@ class ReevaluateMarker(StageMarker): | |||||||
|     """Reevaluate Marker, forces stage's policies to be evaluated again.""" |     """Reevaluate Marker, forces stage's policies to be evaluated again.""" | ||||||
|  |  | ||||||
|     binding: PolicyBinding |     binding: PolicyBinding | ||||||
|     user: User |  | ||||||
|  |  | ||||||
|     def process( |     def process( | ||||||
|         self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] |         self, | ||||||
|     ) -> Optional[Stage]: |         plan: "FlowPlan", | ||||||
|  |         binding: FlowStageBinding, | ||||||
|  |         http_request: HttpRequest, | ||||||
|  |     ) -> Optional[FlowStageBinding]: | ||||||
|         """Re-evaluate policies bound to stage, and if they fail, remove from plan""" |         """Re-evaluate policies bound to stage, and if they fail, remove from plan""" | ||||||
|         engine = PolicyEngine(self.binding, self.user) |         from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
|  |  | ||||||
|  |         LOGGER.debug( | ||||||
|  |             "f(plan_inst)[re-eval marker]: running re-evaluation", | ||||||
|  |             binding=binding, | ||||||
|  |             policy_binding=self.binding, | ||||||
|  |         ) | ||||||
|  |         engine = PolicyEngine( | ||||||
|  |             self.binding, plan.context.get(PLAN_CONTEXT_PENDING_USER, http_request.user) | ||||||
|  |         ) | ||||||
|         engine.use_cache = False |         engine.use_cache = False | ||||||
|         if http_request: |  | ||||||
|         engine.request.set_http_request(http_request) |         engine.request.set_http_request(http_request) | ||||||
|         engine.request.context = plan.context |         engine.request.context = plan.context | ||||||
|         engine.build() |         engine.build() | ||||||
|         result = engine.result |         result = engine.result | ||||||
|         if result.passing: |         if result.passing: | ||||||
|             return stage |             return binding | ||||||
|         LOGGER.warning( |         LOGGER.warning( | ||||||
|             "f(plan_inst)[re-eval marker]: stage failed re-evaluation", |             "f(plan_inst)[re-eval marker]: binding failed re-evaluation", | ||||||
|             stage=stage, |             binding=binding, | ||||||
|             messages=result.messages, |             messages=result.messages, | ||||||
|         ) |         ) | ||||||
|         return None |         return None | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | |||||||
|  |  | ||||||
| from authentik.flows.models import FlowDesignation | from authentik.flows.models import FlowDesignation | ||||||
| from authentik.stages.identification.models import UserFields | from authentik.stages.identification.models import UserFields | ||||||
|  | from authentik.stages.password import BACKEND_DJANGO, BACKEND_LDAP | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_authentication_flow( | def create_default_authentication_flow( | ||||||
| @ -31,7 +32,7 @@ def create_default_authentication_flow( | |||||||
|  |  | ||||||
|     password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create( |     password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create( | ||||||
|         name="default-authentication-password", |         name="default-authentication-password", | ||||||
|         defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]}, |         defaults={"backends": [BACKEND_DJANGO, BACKEND_LDAP]}, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( |     login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( | ||||||
|  | |||||||
| @ -15,9 +15,6 @@ PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently run | |||||||
| # by injecting "pending_user" | # by injecting "pending_user" | ||||||
| akadmin = ak_user_by(username="akadmin") | akadmin = ak_user_by(username="akadmin") | ||||||
| context["pending_user"] = akadmin | context["pending_user"] = akadmin | ||||||
| # We're also setting the backend for the user, so we can |  | ||||||
| # directly login without having to identify again |  | ||||||
| context["user_backend"] = "django.contrib.auth.backends.ModelBackend" |  | ||||||
| return True""" | return True""" | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -138,7 +135,7 @@ class Migration(migrations.Migration): | |||||||
|  |  | ||||||
|     dependencies = [ |     dependencies = [ | ||||||
|         ("authentik_flows", "0017_auto_20210329_1334"), |         ("authentik_flows", "0017_auto_20210329_1334"), | ||||||
|         ("authentik_stages_user_write", "__latest__"), |         ("authentik_stages_user_write", "0002_auto_20200918_1653"), | ||||||
|         ("authentik_stages_user_login", "__latest__"), |         ("authentik_stages_user_login", "__latest__"), | ||||||
|         ("authentik_stages_password", "0002_passwordstage_change_flow"), |         ("authentik_stages_password", "0002_passwordstage_change_flow"), | ||||||
|         ("authentik_policies", "0001_initial"), |         ("authentik_policies", "0001_initial"), | ||||||
|  | |||||||
| @ -0,0 +1,22 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-06-27 16:20 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_flows", "0020_flow_compatibility_mode"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="flowstagebinding", | ||||||
|  |             name="invalid_response_action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 choices=[("retry", "Retry"), ("continue", "Continue")], | ||||||
|  |                 default="retry", | ||||||
|  |                 help_text="Configure how the flow executor should handle an invalid response to a challenge. RETRY returns the error message and a similar challenge to the executor while CONTINUE continues with the next stage.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -27,6 +27,14 @@ class NotConfiguredAction(models.TextChoices): | |||||||
|     CONFIGURE = "configure" |     CONFIGURE = "configure" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class InvalidResponseAction(models.TextChoices): | ||||||
|  |     """Configure how the flow executor should handle invalid responses to challenges""" | ||||||
|  |  | ||||||
|  |     RETRY = "retry" | ||||||
|  |     RESTART = "restart" | ||||||
|  |     RESTART_WITH_CONTEXT = "restart_with_context" | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowDesignation(models.TextChoices): | class FlowDesignation(models.TextChoices): | ||||||
|     """Designation of what a Flow should be used for. At a later point, this |     """Designation of what a Flow should be used for. At a later point, this | ||||||
|     should be replaced by a database entry.""" |     should be replaced by a database entry.""" | ||||||
| @ -201,6 +209,17 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel): | |||||||
|         help_text=_("Evaluate policies when the Stage is present to the user."), |         help_text=_("Evaluate policies when the Stage is present to the user."), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     invalid_response_action = models.TextField( | ||||||
|  |         choices=InvalidResponseAction.choices, | ||||||
|  |         default=InvalidResponseAction.RETRY, | ||||||
|  |         help_text=_( | ||||||
|  |             "Configure how the flow executor should handle an invalid response to a " | ||||||
|  |             "challenge. RETRY returns the error message and a similar challenge to the " | ||||||
|  |             "executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT " | ||||||
|  |             "restarts the flow while keeping the current context." | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     order = models.IntegerField() |     order = models.IntegerField() | ||||||
|  |  | ||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ from authentik.events.models import cleanse_dict | |||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| 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.lib.config import CONFIG | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.root.monitoring import UpdatingGauge | from authentik.root.monitoring import UpdatingGauge | ||||||
|  |  | ||||||
| @ -33,6 +34,7 @@ HIST_FLOWS_PLAN_TIME = Histogram( | |||||||
|     "Duration to build a plan for a flow", |     "Duration to build a plan for a flow", | ||||||
|     ["flow_slug"], |     ["flow_slug"], | ||||||
| ) | ) | ||||||
|  | CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def cache_key(flow: Flow, user: Optional[User] = None) -> str: | def cache_key(flow: Flow, user: Optional[User] = None) -> str: | ||||||
| @ -50,33 +52,41 @@ class FlowPlan: | |||||||
|  |  | ||||||
|     flow_pk: str |     flow_pk: str | ||||||
|  |  | ||||||
|     stages: list[Stage] = field(default_factory=list) |     bindings: list[FlowStageBinding] = field(default_factory=list) | ||||||
|     context: dict[str, Any] = field(default_factory=dict) |     context: dict[str, Any] = field(default_factory=dict) | ||||||
|     markers: list[StageMarker] = field(default_factory=list) |     markers: list[StageMarker] = field(default_factory=list) | ||||||
|  |  | ||||||
|     def append(self, stage: Stage, marker: Optional[StageMarker] = None): |     def append_stage(self, stage: Stage, marker: Optional[StageMarker] = None): | ||||||
|         """Append `stage` to all stages, optionall with stage marker""" |         """Append `stage` to all stages, optionall with stage marker""" | ||||||
|         self.stages.append(stage) |         return self.append(FlowStageBinding(stage=stage), marker) | ||||||
|  |  | ||||||
|  |     def append(self, binding: FlowStageBinding, marker: Optional[StageMarker] = None): | ||||||
|  |         """Append `stage` to all stages, optionall with stage marker""" | ||||||
|  |         self.bindings.append(binding) | ||||||
|         self.markers.append(marker or StageMarker()) |         self.markers.append(marker or StageMarker()) | ||||||
|  |  | ||||||
|     def insert(self, stage: Stage, marker: Optional[StageMarker] = None): |     def insert_stage(self, stage: Stage, marker: Optional[StageMarker] = None): | ||||||
|         """Insert stage into plan, as immediate next stage""" |         """Insert stage into plan, as immediate next stage""" | ||||||
|         self.stages.insert(1, stage) |         self.bindings.insert(1, FlowStageBinding(stage=stage, order=0)) | ||||||
|         self.markers.insert(1, marker or StageMarker()) |         self.markers.insert(1, marker or StageMarker()) | ||||||
|  |  | ||||||
|     def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]: |     def next(self, http_request: Optional[HttpRequest]) -> Optional[FlowStageBinding]: | ||||||
|         """Return next pending stage from the bottom of the list""" |         """Return next pending stage from the bottom of the list""" | ||||||
|         if not self.has_stages: |         if not self.has_stages: | ||||||
|             return None |             return None | ||||||
|         stage = self.stages[0] |         binding = self.bindings[0] | ||||||
|         marker = self.markers[0] |         marker = self.markers[0] | ||||||
|  |  | ||||||
|         if marker.__class__ is not StageMarker: |         if marker.__class__ is not StageMarker: | ||||||
|             LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker) |             LOGGER.debug( | ||||||
|         marked_stage = marker.process(self, stage, http_request) |                 "f(plan_inst): stage has marker", binding=binding, marker=marker | ||||||
|  |             ) | ||||||
|  |         marked_stage = marker.process(self, binding, http_request) | ||||||
|         if not marked_stage: |         if not marked_stage: | ||||||
|             LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage) |             LOGGER.debug( | ||||||
|             self.stages.remove(stage) |                 "f(plan_inst): marker returned none, next stage", binding=binding | ||||||
|  |             ) | ||||||
|  |             self.bindings.remove(binding) | ||||||
|             self.markers.remove(marker) |             self.markers.remove(marker) | ||||||
|             if not self.has_stages: |             if not self.has_stages: | ||||||
|                 return None |                 return None | ||||||
| @ -87,12 +97,12 @@ class FlowPlan: | |||||||
|     def pop(self): |     def pop(self): | ||||||
|         """Pop next pending stage from bottom of list""" |         """Pop next pending stage from bottom of list""" | ||||||
|         self.markers.pop(0) |         self.markers.pop(0) | ||||||
|         self.stages.pop(0) |         self.bindings.pop(0) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def has_stages(self) -> bool: |     def has_stages(self) -> bool: | ||||||
|         """Check if there are any stages left in this plan""" |         """Check if there are any stages left in this plan""" | ||||||
|         return len(self.markers) + len(self.stages) > 0 |         return len(self.markers) + len(self.bindings) > 0 | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowPlanner: | class FlowPlanner: | ||||||
| @ -157,9 +167,9 @@ class FlowPlanner: | |||||||
|                 "f(plan): building plan", |                 "f(plan): building plan", | ||||||
|             ) |             ) | ||||||
|             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, CACHE_TIMEOUT) | ||||||
|             GAUGE_FLOWS_CACHED.update() |             GAUGE_FLOWS_CACHED.update() | ||||||
|             if not plan.stages and not self.allow_empty_flows: |             if not plan.bindings and not self.allow_empty_flows: | ||||||
|                 raise EmptyFlowException() |                 raise EmptyFlowException() | ||||||
|             return plan |             return plan | ||||||
|  |  | ||||||
| @ -214,9 +224,9 @@ class FlowPlanner: | |||||||
|                         "f(plan): stage has re-evaluate marker", |                         "f(plan): stage has re-evaluate marker", | ||||||
|                         stage=binding.stage, |                         stage=binding.stage, | ||||||
|                     ) |                     ) | ||||||
|                     marker = ReevaluateMarker(binding=binding, user=user) |                     marker = ReevaluateMarker(binding=binding) | ||||||
|                 if stage: |                 if stage: | ||||||
|                     plan.append(stage, marker) |                     plan.append(binding, marker) | ||||||
|             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) |             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(plan): finished building", |             "f(plan): finished building", | ||||||
|  | |||||||
| @ -16,29 +16,14 @@ from authentik.flows.challenge import ( | |||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
|  | from authentik.flows.models import InvalidResponseAction | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.views import FlowExecutorView | from authentik.flows.views import FlowExecutorView | ||||||
| from authentik.lib.sentry import SentryIgnoredException |  | ||||||
|  |  | ||||||
| PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class InvalidChallengeError(SentryIgnoredException): |  | ||||||
|     """Error raised when a challenge from a stage is not valid""" |  | ||||||
|  |  | ||||||
|     def __init__(self, errors, stage_view: View, challenge: Challenge) -> None: |  | ||||||
|         super().__init__() |  | ||||||
|         self.errors = errors |  | ||||||
|         self.stage_view = stage_view |  | ||||||
|         self.challenge = challenge |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         return ( |  | ||||||
|             f"Invalid challenge from {self.stage_view}: {self.errors}\n{self.challenge}" |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class StageView(View): | class StageView(View): | ||||||
|     """Abstract Stage, inherits TemplateView but can be combined with FormView""" |     """Abstract Stage, inherits TemplateView but can be combined with FormView""" | ||||||
|  |  | ||||||
| @ -85,7 +70,13 @@ class ChallengeStageView(StageView): | |||||||
|         """Return a challenge for the frontend to solve""" |         """Return a challenge for the frontend to solve""" | ||||||
|         challenge = self._get_challenge(*args, **kwargs) |         challenge = self._get_challenge(*args, **kwargs) | ||||||
|         if not challenge.is_valid(): |         if not challenge.is_valid(): | ||||||
|             LOGGER.warning(challenge.errors, stage_view=self, challenge=challenge) |             LOGGER.warning( | ||||||
|  |                 "f(ch): Invalid challenge", | ||||||
|  |                 binding=self.executor.current_binding, | ||||||
|  |                 errors=challenge.errors, | ||||||
|  |                 stage_view=self, | ||||||
|  |                 challenge=challenge, | ||||||
|  |             ) | ||||||
|         return HttpChallengeResponse(challenge) |         return HttpChallengeResponse(challenge) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
| @ -93,6 +84,21 @@ class ChallengeStageView(StageView): | |||||||
|         """Handle challenge response""" |         """Handle challenge response""" | ||||||
|         challenge: ChallengeResponse = self.get_response_instance(data=request.data) |         challenge: ChallengeResponse = self.get_response_instance(data=request.data) | ||||||
|         if not challenge.is_valid(): |         if not challenge.is_valid(): | ||||||
|  |             if self.executor.current_binding.invalid_response_action in [ | ||||||
|  |                 InvalidResponseAction.RESTART, | ||||||
|  |                 InvalidResponseAction.RESTART_WITH_CONTEXT, | ||||||
|  |             ]: | ||||||
|  |                 keep_context = ( | ||||||
|  |                     self.executor.current_binding.invalid_response_action | ||||||
|  |                     == InvalidResponseAction.RESTART_WITH_CONTEXT | ||||||
|  |                 ) | ||||||
|  |                 LOGGER.debug( | ||||||
|  |                     "f(ch): Invalid response, restarting flow", | ||||||
|  |                     binding=self.executor.current_binding, | ||||||
|  |                     stage_view=self, | ||||||
|  |                     keep_context=keep_context, | ||||||
|  |                 ) | ||||||
|  |                 return self.executor.restart_flow(keep_context) | ||||||
|             return self.challenge_invalid(challenge) |             return self.challenge_invalid(challenge) | ||||||
|         return self.challenge_valid(challenge) |         return self.challenge_valid(challenge) | ||||||
|  |  | ||||||
| @ -142,5 +148,10 @@ class ChallengeStageView(StageView): | |||||||
|                 ) |                 ) | ||||||
|         challenge_response.initial_data["response_errors"] = full_errors |         challenge_response.initial_data["response_errors"] = full_errors | ||||||
|         if not challenge_response.is_valid(): |         if not challenge_response.is_valid(): | ||||||
|             LOGGER.warning(challenge_response.errors) |             LOGGER.warning( | ||||||
|  |                 "f(ch): invalid challenge response", | ||||||
|  |                 binding=self.executor.current_binding, | ||||||
|  |                 errors=challenge_response.errors, | ||||||
|  |                 stage_view=self, | ||||||
|  |             ) | ||||||
|         return HttpChallengeResponse(challenge_response) |         return HttpChallengeResponse(challenge_response) | ||||||
|  | |||||||
| @ -182,8 +182,8 @@ class TestFlowPlanner(TestCase): | |||||||
|             planner = FlowPlanner(flow) |             planner = FlowPlanner(flow) | ||||||
|             plan = planner.plan(request) |             plan = planner.plan(request) | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
|  | |||||||
| @ -11,15 +11,23 @@ from authentik.core.models import User | |||||||
| from authentik.flows.challenge import ChallengeTypes | from authentik.flows.challenge import ChallengeTypes | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | from authentik.flows.models import ( | ||||||
|  |     Flow, | ||||||
|  |     FlowDesignation, | ||||||
|  |     FlowStageBinding, | ||||||
|  |     InvalidResponseAction, | ||||||
|  | ) | ||||||
| from authentik.flows.planner import FlowPlan, FlowPlanner | from authentik.flows.planner import FlowPlan, FlowPlanner | ||||||
| from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView | from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView | ||||||
| from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
|  | from authentik.policies.reputation.models import ReputationPolicy | ||||||
| from authentik.policies.types import PolicyResult | from authentik.policies.types import PolicyResult | ||||||
|  | from authentik.stages.deny.models import DenyStage | ||||||
| from authentik.stages.dummy.models import DummyStage | from authentik.stages.dummy.models import DummyStage | ||||||
|  | from authentik.stages.identification.models import IdentificationStage, UserFields | ||||||
|  |  | ||||||
| POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) | POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) | ||||||
| POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | ||||||
| @ -52,8 +60,9 @@ class TestFlowExecutor(TestCase): | |||||||
|             designation=FlowDesignation.AUTHENTICATION, |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|         ) |         ) | ||||||
|         stage = DummyStage.objects.create(name="dummy") |         stage = DummyStage.objects.create(name="dummy") | ||||||
|  |         binding = FlowStageBinding(target=flow, stage=stage, order=0) | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=flow.pk.hex + "a", stages=[stage], markers=[StageMarker()] |             flow_pk=flow.pk.hex + "a", bindings=[binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
| @ -163,7 +172,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         # Check that two stages are in plan |         # Check that two stages are in plan | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] |         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|         self.assertEqual(len(plan.stages), 2) |         self.assertEqual(len(plan.bindings), 2) | ||||||
|         # Second request, submit form, one stage left |         # Second request, submit form, one stage left | ||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         # Second request redirects to the same URL |         # Second request redirects to the same URL | ||||||
| @ -172,7 +181,7 @@ class TestFlowExecutor(TestCase): | |||||||
|         # Check that two stages are in plan |         # Check that two stages are in plan | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] |         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||||
|         self.assertEqual(len(plan.stages), 1) |         self.assertEqual(len(plan.bindings), 1) | ||||||
|  |  | ||||||
|     @patch( |     @patch( | ||||||
|         "authentik.flows.views.to_stage_response", |         "authentik.flows.views.to_stage_response", | ||||||
| @ -213,8 +222,8 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -267,9 +276,9 @@ class TestFlowExecutor(TestCase): | |||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|             self.assertEqual(plan.stages[2], binding3.stage) |             self.assertEqual(plan.bindings[2], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -281,8 +290,8 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding2.stage) |             self.assertEqual(plan.bindings[0], binding2) | ||||||
|             self.assertEqual(plan.stages[1], binding3.stage) |             self.assertEqual(plan.bindings[1], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], StageMarker) |             self.assertIsInstance(plan.markers[1], StageMarker) | ||||||
| @ -338,9 +347,9 @@ class TestFlowExecutor(TestCase): | |||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|             self.assertEqual(plan.stages[2], binding3.stage) |             self.assertEqual(plan.bindings[2], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -352,8 +361,8 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding2.stage) |             self.assertEqual(plan.bindings[0], binding2) | ||||||
|             self.assertEqual(plan.stages[1], binding3.stage) |             self.assertEqual(plan.bindings[1], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], StageMarker) |             self.assertIsInstance(plan.markers[1], StageMarker) | ||||||
| @ -364,7 +373,7 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding3.stage) |             self.assertEqual(plan.bindings[0], binding3) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|  |  | ||||||
| @ -438,10 +447,10 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
|             self.assertEqual(plan.stages[0], binding.stage) |             self.assertEqual(plan.bindings[0], binding) | ||||||
|             self.assertEqual(plan.stages[1], binding2.stage) |             self.assertEqual(plan.bindings[1], binding2) | ||||||
|             self.assertEqual(plan.stages[2], binding3.stage) |             self.assertEqual(plan.bindings[2], binding3) | ||||||
|             self.assertEqual(plan.stages[3], binding4.stage) |             self.assertEqual(plan.bindings[3], binding4) | ||||||
|  |  | ||||||
|             self.assertIsInstance(plan.markers[0], StageMarker) |             self.assertIsInstance(plan.markers[0], StageMarker) | ||||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) |             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||||
| @ -512,3 +521,78 @@ class TestFlowExecutor(TestCase): | |||||||
|  |  | ||||||
|         stage_view = StageView(executor) |         stage_view = StageView(executor) | ||||||
|         self.assertEqual(ident, stage_view.get_pending_user(for_display=True).username) |         self.assertEqual(ident, stage_view.get_pending_user(for_display=True).username) | ||||||
|  |  | ||||||
|  |     def test_invalid_restart(self): | ||||||
|  |         """Test flow that restarts on invalid entry""" | ||||||
|  |         flow = Flow.objects.create( | ||||||
|  |             name="restart-on-invalid", | ||||||
|  |             slug="restart-on-invalid", | ||||||
|  |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|  |         ) | ||||||
|  |         # Stage 0 is a deny stage that is added dynamically | ||||||
|  |         # when the reputation policy says so | ||||||
|  |         deny_stage = DenyStage.objects.create(name="deny") | ||||||
|  |         reputation_policy = ReputationPolicy.objects.create( | ||||||
|  |             name="reputation", threshold=-1, check_ip=False | ||||||
|  |         ) | ||||||
|  |         deny_binding = FlowStageBinding.objects.create( | ||||||
|  |             target=flow, | ||||||
|  |             stage=deny_stage, | ||||||
|  |             order=0, | ||||||
|  |             evaluate_on_plan=False, | ||||||
|  |             re_evaluate_policies=True, | ||||||
|  |         ) | ||||||
|  |         PolicyBinding.objects.create( | ||||||
|  |             policy=reputation_policy, target=deny_binding, order=0 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Stage 1 is an identification stage | ||||||
|  |         ident_stage = IdentificationStage.objects.create( | ||||||
|  |             name="ident", | ||||||
|  |             user_fields=[UserFields.E_MAIL], | ||||||
|  |         ) | ||||||
|  |         FlowStageBinding.objects.create( | ||||||
|  |             target=flow, | ||||||
|  |             stage=ident_stage, | ||||||
|  |             order=1, | ||||||
|  |             invalid_response_action=InvalidResponseAction.RESTART_WITH_CONTEXT, | ||||||
|  |         ) | ||||||
|  |         exec_url = reverse( | ||||||
|  |             "authentik_api:flow-executor", kwargs={"flow_slug": flow.slug} | ||||||
|  |         ) | ||||||
|  |         # First request, run the planner | ||||||
|  |         response = self.client.get(exec_url) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             force_str(response.content), | ||||||
|  |             { | ||||||
|  |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|  |                 "component": "ak-stage-identification", | ||||||
|  |                 "flow_info": { | ||||||
|  |                     "background": flow.background_url, | ||||||
|  |                     "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|  |                     "title": "", | ||||||
|  |                 }, | ||||||
|  |                 "password_fields": False, | ||||||
|  |                 "primary_action": "Log in", | ||||||
|  |                 "sources": [], | ||||||
|  |                 "user_fields": [UserFields.E_MAIL], | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         response = self.client.post( | ||||||
|  |             exec_url, {"uid_field": "invalid-string"}, follow=True | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             force_str(response.content), | ||||||
|  |             { | ||||||
|  |                 "component": "ak-stage-access-denied", | ||||||
|  |                 "error_message": None, | ||||||
|  |                 "flow_info": { | ||||||
|  |                     "background": flow.background_url, | ||||||
|  |                     "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|  |                     "title": "", | ||||||
|  |                 }, | ||||||
|  |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ from typing import Any, Optional | |||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.mixins import LoginRequiredMixin | from django.contrib.auth.mixins import LoginRequiredMixin | ||||||
|  | from django.core.cache import cache | ||||||
| from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| @ -37,13 +38,20 @@ from authentik.flows.challenge import ( | |||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage | from authentik.flows.models import ( | ||||||
|  |     ConfigurableStage, | ||||||
|  |     Flow, | ||||||
|  |     FlowDesignation, | ||||||
|  |     FlowStageBinding, | ||||||
|  |     Stage, | ||||||
|  | ) | ||||||
| from authentik.flows.planner import ( | from authentik.flows.planner import ( | ||||||
|     PLAN_CONTEXT_PENDING_USER, |     PLAN_CONTEXT_PENDING_USER, | ||||||
|     PLAN_CONTEXT_REDIRECT, |     PLAN_CONTEXT_REDIRECT, | ||||||
|     FlowPlan, |     FlowPlan, | ||||||
|     FlowPlanner, |     FlowPlanner, | ||||||
| ) | ) | ||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.lib.utils.reflection import all_subclasses, 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 | from authentik.tenants.models import Tenant | ||||||
| @ -93,6 +101,10 @@ def challenge_response_types(): | |||||||
|     return Inner() |     return Inner() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class InvalidStageError(SentryIgnoredException): | ||||||
|  |     """Error raised when a challenge from a stage is not valid""" | ||||||
|  |  | ||||||
|  |  | ||||||
| @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""" | ||||||
| @ -102,6 +114,7 @@ class FlowExecutorView(APIView): | |||||||
|     flow: Flow |     flow: Flow | ||||||
|  |  | ||||||
|     plan: Optional[FlowPlan] = None |     plan: Optional[FlowPlan] = None | ||||||
|  |     current_binding: FlowStageBinding | ||||||
|     current_stage: Stage |     current_stage: Stage | ||||||
|     current_stage_view: View |     current_stage_view: View | ||||||
|  |  | ||||||
| @ -154,27 +167,35 @@ class FlowExecutorView(APIView): | |||||||
|         request.session[SESSION_KEY_GET] = QueryDict(request.GET.get("query", "")) |         request.session[SESSION_KEY_GET] = QueryDict(request.GET.get("query", "")) | ||||||
|         # We don't save the Plan after getting the next stage |         # We don't save the Plan after getting the next stage | ||||||
|         # as it hasn't been successfully passed yet |         # as it hasn't been successfully passed yet | ||||||
|         next_stage = self.plan.next(self.request) |         next_binding = self.plan.next(self.request) | ||||||
|         if not next_stage: |         if not next_binding: | ||||||
|             self._logger.debug("f(exec): no more stages, flow is done.") |             self._logger.debug("f(exec): no more stages, flow is done.") | ||||||
|             return self._flow_done() |             return self._flow_done() | ||||||
|         self.current_stage = next_stage |         self.current_binding = next_binding | ||||||
|  |         self.current_stage = next_binding.stage | ||||||
|         self._logger.debug( |         self._logger.debug( | ||||||
|             "f(exec): Current stage", |             "f(exec): Current stage", | ||||||
|             current_stage=self.current_stage, |             current_stage=self.current_stage, | ||||||
|             flow_slug=self.flow.slug, |             flow_slug=self.flow.slug, | ||||||
|         ) |         ) | ||||||
|  |         try: | ||||||
|             stage_cls = self.current_stage.type |             stage_cls = self.current_stage.type | ||||||
|  |         except NotImplementedError as exc: | ||||||
|  |             self._logger.debug("Error getting stage type", exc=exc) | ||||||
|  |             return self.stage_invalid() | ||||||
|         self.current_stage_view = stage_cls(self) |         self.current_stage_view = stage_cls(self) | ||||||
|         self.current_stage_view.args = self.args |         self.current_stage_view.args = self.args | ||||||
|         self.current_stage_view.kwargs = self.kwargs |         self.current_stage_view.kwargs = self.kwargs | ||||||
|         self.current_stage_view.request = request |         self.current_stage_view.request = request | ||||||
|  |         try: | ||||||
|             return super().dispatch(request) |             return super().dispatch(request) | ||||||
|  |         except InvalidStageError as exc: | ||||||
|  |             return self.stage_invalid(str(exc)) | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: PolymorphicProxySerializer( |             200: PolymorphicProxySerializer( | ||||||
|                 component_name="FlowChallengeRequest", |                 component_name="ChallengeTypes", | ||||||
|                 serializers=challenge_types(), |                 serializers=challenge_types(), | ||||||
|                 resource_type_field_name="component", |                 resource_type_field_name="component", | ||||||
|             ), |             ), | ||||||
| @ -214,7 +235,7 @@ class FlowExecutorView(APIView): | |||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|             200: PolymorphicProxySerializer( |             200: PolymorphicProxySerializer( | ||||||
|                 component_name="FlowChallengeRequest", |                 component_name="ChallengeTypes", | ||||||
|                 serializers=challenge_types(), |                 serializers=challenge_types(), | ||||||
|                 resource_type_field_name="component", |                 resource_type_field_name="component", | ||||||
|             ), |             ), | ||||||
| @ -256,8 +277,31 @@ class FlowExecutorView(APIView): | |||||||
|         planner = FlowPlanner(self.flow) |         planner = FlowPlanner(self.flow) | ||||||
|         plan = planner.plan(self.request) |         plan = planner.plan(self.request) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         try: | ||||||
|  |             # Call the has_stages getter to check that | ||||||
|  |             # there are no issues with the class we might've gotten | ||||||
|  |             # from the cache. If there are errors, just delete all cached flows | ||||||
|  |             _ = plan.has_stages | ||||||
|  |         except Exception:  # pylint: disable=broad-except | ||||||
|  |             keys = cache.keys("flow_*") | ||||||
|  |             cache.delete_many(keys) | ||||||
|  |             return self._initiate_plan() | ||||||
|         return plan |         return plan | ||||||
|  |  | ||||||
|  |     def restart_flow(self, keep_context=False) -> HttpResponse: | ||||||
|  |         """Restart the currently active flow, optionally keeping the current context""" | ||||||
|  |         planner = FlowPlanner(self.flow) | ||||||
|  |         default_context = None | ||||||
|  |         if keep_context: | ||||||
|  |             default_context = self.plan.context | ||||||
|  |         plan = planner.plan(self.request, default_context) | ||||||
|  |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         kwargs = self.kwargs | ||||||
|  |         kwargs.update({"flow_slug": self.flow.slug}) | ||||||
|  |         return redirect_with_qs( | ||||||
|  |             "authentik_api:flow-executor", self.request.GET, **kwargs | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def _flow_done(self) -> HttpResponse: |     def _flow_done(self) -> HttpResponse: | ||||||
|         """User Successfully passed all stages""" |         """User Successfully passed all stages""" | ||||||
|         # Since this is wrapped by the ExecutorShell, the next argument is saved in the session |         # Since this is wrapped by the ExecutorShell, the next argument is saved in the session | ||||||
| @ -281,10 +325,10 @@ class FlowExecutorView(APIView): | |||||||
|         ) |         ) | ||||||
|         self.plan.pop() |         self.plan.pop() | ||||||
|         self.request.session[SESSION_KEY_PLAN] = self.plan |         self.request.session[SESSION_KEY_PLAN] = self.plan | ||||||
|         if self.plan.stages: |         if self.plan.bindings: | ||||||
|             self._logger.debug( |             self._logger.debug( | ||||||
|                 "f(exec): Continuing with next stage", |                 "f(exec): Continuing with next stage", | ||||||
|                 remaining=len(self.plan.stages), |                 remaining=len(self.plan.bindings), | ||||||
|             ) |             ) | ||||||
|             kwargs = self.kwargs |             kwargs = self.kwargs | ||||||
|             kwargs.update({"flow_slug": self.flow.slug}) |             kwargs.update({"flow_slug": self.flow.slug}) | ||||||
| @ -353,8 +397,11 @@ class FlowErrorResponse(TemplateResponse): | |||||||
|             context = {} |             context = {} | ||||||
|         context["error"] = self.error |         context["error"] = self.error | ||||||
|         if self._request.user and self._request.user.is_authenticated: |         if self._request.user and self._request.user.is_authenticated: | ||||||
|             if self._request.user.is_superuser or self._request.user.attributes.get( |             if ( | ||||||
|  |                 self._request.user.is_superuser | ||||||
|  |                 or self._request.user.group_attributes().get( | ||||||
|                     USER_ATTRIBUTE_DEBUG, False |                     USER_ATTRIBUTE_DEBUG, False | ||||||
|  |                 ) | ||||||
|             ): |             ): | ||||||
|                 context["tb"] = "".join(format_tb(self.error.__traceback__)) |                 context["tb"] = "".join(format_tb(self.error.__traceback__)) | ||||||
|         return context |         return context | ||||||
|  | |||||||
| @ -62,7 +62,7 @@ class ConfigLoader: | |||||||
|         output.update(kwargs) |         output.update(kwargs) | ||||||
|         print(dumps(output)) |         print(dumps(output)) | ||||||
|  |  | ||||||
|     def update(self, root, updatee): |     def update(self, root: dict[str, Any], updatee: dict[str, Any]) -> dict[str, Any]: | ||||||
|         """Recursively update dictionary""" |         """Recursively update dictionary""" | ||||||
|         for key, value in updatee.items(): |         for key, value in updatee.items(): | ||||||
|             if isinstance(value, Mapping): |             if isinstance(value, Mapping): | ||||||
| @ -73,7 +73,7 @@ class ConfigLoader: | |||||||
|                 root[key] = value |                 root[key] = value | ||||||
|         return root |         return root | ||||||
|  |  | ||||||
|     def parse_uri(self, value): |     def parse_uri(self, value: str) -> str: | ||||||
|         """Parse string values which start with a URI""" |         """Parse string values which start with a URI""" | ||||||
|         url = urlparse(value) |         url = urlparse(value) | ||||||
|         if url.scheme == "env": |         if url.scheme == "env": | ||||||
| @ -99,7 +99,10 @@ class ConfigLoader: | |||||||
|                     raise ImproperlyConfigured from exc |                     raise ImproperlyConfigured from exc | ||||||
|         except PermissionError as exc: |         except PermissionError as exc: | ||||||
|             self._log( |             self._log( | ||||||
|                 "warning", "Permission denied while reading file", path=path, error=exc |                 "warning", | ||||||
|  |                 "Permission denied while reading file", | ||||||
|  |                 path=path, | ||||||
|  |                 error=str(exc), | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|     def update_from_dict(self, update: dict): |     def update_from_dict(self, update: dict): | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ postgresql: | |||||||
| web: | web: | ||||||
|   listen: 0.0.0.0:9000 |   listen: 0.0.0.0:9000 | ||||||
|   listen_tls: 0.0.0.0:9443 |   listen_tls: 0.0.0.0:9443 | ||||||
|  |   load_local_files: false | ||||||
|  |  | ||||||
| redis: | redis: | ||||||
|   host: localhost |   host: localhost | ||||||
| @ -16,6 +17,10 @@ redis: | |||||||
|   cache_db: 0 |   cache_db: 0 | ||||||
|   message_queue_db: 1 |   message_queue_db: 1 | ||||||
|   ws_db: 2 |   ws_db: 2 | ||||||
|  |   cache_timeout: 300 | ||||||
|  |   cache_timeout_flows: 300 | ||||||
|  |   cache_timeout_policies: 300 | ||||||
|  |   cache_timeout_reputation: 300 | ||||||
|  |  | ||||||
| debug: false | debug: false | ||||||
|  |  | ||||||
| @ -45,10 +50,10 @@ outposts: | |||||||
|   # %(build_hash)s: Build hash if you're running a beta version |   # %(build_hash)s: Build hash if you're running a beta version | ||||||
|   docker_image_base: "ghcr.io/goauthentik/%(type)s:%(version)s" |   docker_image_base: "ghcr.io/goauthentik/%(type)s:%(version)s" | ||||||
|  |  | ||||||
| authentik: | avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar | ||||||
|   avatars: gravatar  # gravatar or none |  | ||||||
| geoip: "./GeoLite2-City.mmdb" | geoip: "./GeoLite2-City.mmdb" | ||||||
|   # Optionally add links to the footer on the login page |  | ||||||
|  | # Can't currently be configured via environment variables, only yaml | ||||||
| footer_links: | footer_links: | ||||||
|   - name: Documentation |   - name: Documentation | ||||||
|     href: https://goauthentik.io/docs/ |     href: https://goauthentik.io/docs/ | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import re | |||||||
| from textwrap import indent | from textwrap import indent | ||||||
| from typing import Any, Iterable, Optional | from typing import Any, Iterable, Optional | ||||||
|  |  | ||||||
|  | from django.core.exceptions import FieldError | ||||||
| from requests import Session | from requests import Session | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from sentry_sdk.hub import Hub | from sentry_sdk.hub import Hub | ||||||
| @ -29,10 +30,10 @@ class BaseEvaluator: | |||||||
|         # update website/docs/expressions/_objects.md |         # update website/docs/expressions/_objects.md | ||||||
|         # update website/docs/expressions/_functions.md |         # update website/docs/expressions/_functions.md | ||||||
|         self._globals = { |         self._globals = { | ||||||
|             "regex_match": BaseEvaluator.expr_filter_regex_match, |             "regex_match": BaseEvaluator.expr_regex_match, | ||||||
|             "regex_replace": BaseEvaluator.expr_filter_regex_replace, |             "regex_replace": BaseEvaluator.expr_regex_replace, | ||||||
|             "ak_is_group_member": BaseEvaluator.expr_func_is_group_member, |             "ak_is_group_member": BaseEvaluator.expr_is_group_member, | ||||||
|             "ak_user_by": BaseEvaluator.expr_func_user_by, |             "ak_user_by": BaseEvaluator.expr_user_by, | ||||||
|             "ak_logger": get_logger(), |             "ak_logger": get_logger(), | ||||||
|             "requests": Session(), |             "requests": Session(), | ||||||
|         } |         } | ||||||
| @ -40,25 +41,28 @@ class BaseEvaluator: | |||||||
|         self._filename = "BaseEvalautor" |         self._filename = "BaseEvalautor" | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_filter_regex_match(value: Any, regex: str) -> bool: |     def expr_regex_match(value: Any, regex: str) -> bool: | ||||||
|         """Expression Filter to run re.search""" |         """Expression Filter to run re.search""" | ||||||
|         return re.search(regex, value) is None |         return re.search(regex, value) is not None | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_filter_regex_replace(value: Any, regex: str, repl: str) -> str: |     def expr_regex_replace(value: Any, regex: str, repl: str) -> str: | ||||||
|         """Expression Filter to run re.sub""" |         """Expression Filter to run re.sub""" | ||||||
|         return re.sub(regex, repl, value) |         return re.sub(regex, repl, value) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_func_user_by(**filters) -> Optional[User]: |     def expr_user_by(**filters) -> Optional[User]: | ||||||
|         """Get user by filters""" |         """Get user by filters""" | ||||||
|  |         try: | ||||||
|             users = User.objects.filter(**filters) |             users = User.objects.filter(**filters) | ||||||
|             if users: |             if users: | ||||||
|                 return users.first() |                 return users.first() | ||||||
|             return None |             return None | ||||||
|  |         except FieldError: | ||||||
|  |             return None | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_func_is_group_member(user: User, **group_filters) -> bool: |     def expr_is_group_member(user: User, **group_filters) -> bool: | ||||||
|         """Check if `user` is member of group with name `group_name`""" |         """Check if `user` is member of group with name `group_name`""" | ||||||
|         return user.ak_groups.filter(**group_filters).exists() |         return user.ak_groups.filter(**group_filters).exists() | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										61
									
								
								authentik/lib/tests/test_config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								authentik/lib/tests/test_config.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,61 @@ | |||||||
|  | """Test config loader""" | ||||||
|  | from os import chmod, environ, unlink, write | ||||||
|  | from tempfile import mkstemp | ||||||
|  |  | ||||||
|  | from django.conf import ImproperlyConfigured | ||||||
|  | from django.test import TestCase | ||||||
|  |  | ||||||
|  | from authentik.lib.config import ENV_PREFIX, ConfigLoader | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestConfig(TestCase): | ||||||
|  |     """Test config loader""" | ||||||
|  |  | ||||||
|  |     def test_env(self): | ||||||
|  |         """Test simple instance""" | ||||||
|  |         config = ConfigLoader() | ||||||
|  |         environ[ENV_PREFIX + "_test__test"] = "bar" | ||||||
|  |         config.update_from_env() | ||||||
|  |         self.assertEqual(config.y("test.test"), "bar") | ||||||
|  |  | ||||||
|  |     def test_patch(self): | ||||||
|  |         """Test patch decorator""" | ||||||
|  |         config = ConfigLoader() | ||||||
|  |         config.y_set("foo.bar", "bar") | ||||||
|  |         self.assertEqual(config.y("foo.bar"), "bar") | ||||||
|  |         with config.patch("foo.bar", "baz"): | ||||||
|  |             self.assertEqual(config.y("foo.bar"), "baz") | ||||||
|  |         self.assertEqual(config.y("foo.bar"), "bar") | ||||||
|  |  | ||||||
|  |     def test_uri_env(self): | ||||||
|  |         """Test URI parsing (environment)""" | ||||||
|  |         config = ConfigLoader() | ||||||
|  |         environ["foo"] = "bar" | ||||||
|  |         self.assertEqual(config.parse_uri("env://foo"), "bar") | ||||||
|  |         self.assertEqual(config.parse_uri("env://fo?bar"), "bar") | ||||||
|  |  | ||||||
|  |     def test_uri_file(self): | ||||||
|  |         """Test URI parsing (file load)""" | ||||||
|  |         config = ConfigLoader() | ||||||
|  |         file, file_name = mkstemp() | ||||||
|  |         write(file, "foo".encode()) | ||||||
|  |         _, file2_name = mkstemp() | ||||||
|  |         chmod(file2_name, 0o000)  # Remove all permissions so we can't read the file | ||||||
|  |         self.assertEqual(config.parse_uri(f"file://{file_name}"), "foo") | ||||||
|  |         self.assertEqual(config.parse_uri(f"file://{file2_name}?def"), "def") | ||||||
|  |         unlink(file_name) | ||||||
|  |         unlink(file2_name) | ||||||
|  |  | ||||||
|  |     def test_file_update(self): | ||||||
|  |         """Test update_from_file""" | ||||||
|  |         config = ConfigLoader() | ||||||
|  |         file, file_name = mkstemp() | ||||||
|  |         write(file, "{".encode()) | ||||||
|  |         file2, file2_name = mkstemp() | ||||||
|  |         write(file2, "{".encode()) | ||||||
|  |         chmod(file2_name, 0o000)  # Remove all permissions so we can't read the file | ||||||
|  |         with self.assertRaises(ImproperlyConfigured): | ||||||
|  |             config.update_from_file(file_name) | ||||||
|  |         config.update_from_file(file2_name) | ||||||
|  |         unlink(file_name) | ||||||
|  |         unlink(file2_name) | ||||||
							
								
								
									
										32
									
								
								authentik/lib/tests/test_evaluator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								authentik/lib/tests/test_evaluator.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | """Test Evaluator base functions""" | ||||||
|  | from django.test import TestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
|  | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestEvaluator(TestCase): | ||||||
|  |     """Test Evaluator base functions""" | ||||||
|  |  | ||||||
|  |     def test_regex_match(self): | ||||||
|  |         """Test expr_regex_match""" | ||||||
|  |         self.assertFalse(BaseEvaluator.expr_regex_match("foo", "bar")) | ||||||
|  |         self.assertTrue(BaseEvaluator.expr_regex_match("foo", "foo")) | ||||||
|  |  | ||||||
|  |     def test_regex_replace(self): | ||||||
|  |         """Test expr_regex_replace""" | ||||||
|  |         self.assertEqual(BaseEvaluator.expr_regex_replace("foo", "o", "a"), "faa") | ||||||
|  |  | ||||||
|  |     def test_user_by(self): | ||||||
|  |         """Test expr_user_by""" | ||||||
|  |         self.assertIsNotNone(BaseEvaluator.expr_user_by(username="akadmin")) | ||||||
|  |         self.assertIsNone(BaseEvaluator.expr_user_by(username="bar")) | ||||||
|  |         self.assertIsNone(BaseEvaluator.expr_user_by(foo="bar")) | ||||||
|  |  | ||||||
|  |     def test_is_group_member(self): | ||||||
|  |         """Test expr_is_group_member""" | ||||||
|  |         self.assertFalse( | ||||||
|  |             BaseEvaluator.expr_is_group_member( | ||||||
|  |                 User.objects.get(username="akadmin"), name="test" | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
							
								
								
									
										10
									
								
								authentik/lib/utils/errors.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/lib/utils/errors.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | """error utils""" | ||||||
|  | from traceback import format_tb | ||||||
|  |  | ||||||
|  | TRACEBACK_HEADER = "Traceback (most recent call last):\n" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def exception_to_string(exc: Exception) -> str: | ||||||
|  |     """Convert exception to string stackrace""" | ||||||
|  |     # Either use passed original exception or whatever we have | ||||||
|  |     return TRACEBACK_HEADER + "".join(format_tb(exc.__traceback__)) + str(exc) | ||||||
| @ -33,7 +33,7 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: | |||||||
|         return None |         return None | ||||||
|     if OUTPOST_REMOTE_IP_HEADER not in request.META: |     if OUTPOST_REMOTE_IP_HEADER not in request.META: | ||||||
|         return None |         return None | ||||||
|     if request.user.attributes.get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): |     if request.user.group_attributes().get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): | ||||||
|         return None |         return None | ||||||
|     return request.META[OUTPOST_REMOTE_IP_HEADER] |     return request.META[OUTPOST_REMOTE_IP_HEADER] | ||||||
|  |  | ||||||
|  | |||||||
| @ -33,6 +33,13 @@ class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|  |  | ||||||
|     component = ReadOnlyField() |     component = ReadOnlyField() | ||||||
|  |  | ||||||
|  |     def get_component(self, obj: OutpostServiceConnection) -> str: | ||||||
|  |         """Get object type so that we know how to edit the object""" | ||||||
|  |         # pyright: reportGeneralTypeIssues=false | ||||||
|  |         if obj.__class__ == OutpostServiceConnection: | ||||||
|  |             return "" | ||||||
|  |         return obj.component | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = OutpostServiceConnection |         model = OutpostServiceConnection | ||||||
|  | |||||||
| @ -67,11 +67,6 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|         self.accept() |         self.accept() | ||||||
|         self.outpost = outpost.first() |         self.outpost = outpost.first() | ||||||
|         self.last_uid = self.channel_name |         self.last_uid = self.channel_name | ||||||
|         LOGGER.debug( |  | ||||||
|             "added outpost instace to cache", |  | ||||||
|             outpost=self.outpost, |  | ||||||
|             channel_name=self.channel_name, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def disconnect(self, close_code): |     def disconnect(self, close_code): | ||||||
| @ -108,6 +103,11 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|                 outpost=self.outpost.name, |                 outpost=self.outpost.name, | ||||||
|                 uid=self.last_uid, |                 uid=self.last_uid, | ||||||
|             ).inc() |             ).inc() | ||||||
|  |             LOGGER.debug( | ||||||
|  |                 "added outpost instace to cache", | ||||||
|  |                 outpost=self.outpost, | ||||||
|  |                 instance_uuid=self.last_uid, | ||||||
|  |             ) | ||||||
|             self.first_msg = True |             self.first_msg = True | ||||||
|  |  | ||||||
|         if msg.instruction == WebsocketMessageInstruction.HELLO: |         if msg.instruction == WebsocketMessageInstruction.HELLO: | ||||||
|  | |||||||
| @ -53,6 +53,27 @@ class DockerController(BaseController): | |||||||
|                 return True |                 return True | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
|  |     def _comp_ports(self, container: Container) -> bool: | ||||||
|  |         """Check that the container has the correct ports exposed. Return true if container needs | ||||||
|  |         to be rebuilt.""" | ||||||
|  |         # with TEST enabled, we use host-network | ||||||
|  |         if settings.TEST: | ||||||
|  |             return False | ||||||
|  |         # When the container isn't running, the API doesn't report any port mappings | ||||||
|  |         if container.status != "running": | ||||||
|  |             return False | ||||||
|  |         # {'6379/tcp': [{'HostIp': '127.0.0.1', 'HostPort': '6379'}]} | ||||||
|  |         for port in self.deployment_ports: | ||||||
|  |             key = f"{port.inner_port or port.port}/{port.protocol.lower()}" | ||||||
|  |             if key not in container.ports: | ||||||
|  |                 return True | ||||||
|  |             host_matching = False | ||||||
|  |             for host_port in container.ports[key]: | ||||||
|  |                 host_matching = host_port.get("HostPort") == port.port | ||||||
|  |             if not host_matching: | ||||||
|  |                 return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|     def _get_container(self) -> tuple[Container, bool]: |     def _get_container(self) -> tuple[Container, bool]: | ||||||
|         container_name = f"authentik-proxy-{self.outpost.uuid.hex}" |         container_name = f"authentik-proxy-{self.outpost.uuid.hex}" | ||||||
|         try: |         try: | ||||||
| @ -63,10 +84,10 @@ class DockerController(BaseController): | |||||||
|             self.client.images.pull(image_name) |             self.client.images.pull(image_name) | ||||||
|             container_args = { |             container_args = { | ||||||
|                 "image": image_name, |                 "image": image_name, | ||||||
|                 "name": f"authentik-proxy-{self.outpost.uuid.hex}", |                 "name": container_name, | ||||||
|                 "detach": True, |                 "detach": True, | ||||||
|                 "ports": { |                 "ports": { | ||||||
|                     f"{port.port}/{port.protocol.lower()}": port.inner_port or port.port |                     f"{port.inner_port or port.port}/{port.protocol.lower()}": port.port | ||||||
|                     for port in self.deployment_ports |                     for port in self.deployment_ports | ||||||
|                 }, |                 }, | ||||||
|                 "environment": self._get_env(), |                 "environment": self._get_env(), | ||||||
| @ -98,6 +119,11 @@ class DockerController(BaseController): | |||||||
|                     ) |                     ) | ||||||
|                     self.down() |                     self.down() | ||||||
|                     return self.up() |                     return self.up() | ||||||
|  |             # Check container's ports | ||||||
|  |             if self._comp_ports(container): | ||||||
|  |                 self.logger.info("Container has mis-matched ports, re-creating...") | ||||||
|  |                 self.down() | ||||||
|  |                 return self.up() | ||||||
|             # Check that container values match our values |             # Check that container values match our values | ||||||
|             if self._comp_env(container): |             if self._comp_env(container): | ||||||
|                 self.logger.info("Container has outdated config, re-creating...") |                 self.logger.info("Container has outdated config, re-creating...") | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ from uuid import uuid4 | |||||||
| from dacite import from_dict | from dacite import from_dict | ||||||
| from django.contrib.auth.models import Permission | from django.contrib.auth.models import Permission | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db import models, transaction | from django.db import IntegrityError, models, transaction | ||||||
| from django.db.models.base import Model | from django.db.models.base import Model | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from docker.client import DockerClient | from docker.client import DockerClient | ||||||
| @ -50,6 +50,8 @@ class ServiceConnectionInvalid(SentryIgnoredException): | |||||||
| class OutpostConfig: | class OutpostConfig: | ||||||
|     """Configuration an outpost uses to configure it self""" |     """Configuration an outpost uses to configure it self""" | ||||||
|  |  | ||||||
|  |     # update website/docs/outposts/outposts.md | ||||||
|  |  | ||||||
|     authentik_host: str |     authentik_host: str | ||||||
|     authentik_host_insecure: bool = False |     authentik_host_insecure: bool = False | ||||||
|  |  | ||||||
| @ -141,7 +143,9 @@ class OutpostServiceConnection(models.Model): | |||||||
|     @property |     @property | ||||||
|     def component(self) -> str: |     def component(self) -> str: | ||||||
|         """Return component used to edit this object""" |         """Return component used to edit this object""" | ||||||
|         raise NotImplementedError |         # This is called when creating an outpost with a service connection | ||||||
|  |         # since the response doesn't use the correct inheritance | ||||||
|  |         return "" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -380,13 +384,11 @@ class Outpost(models.Model): | |||||||
|         tokens = Token.filter_not_expired( |         tokens = Token.filter_not_expired( | ||||||
|             identifier=self.token_identifier, |             identifier=self.token_identifier, | ||||||
|             intent=TokenIntents.INTENT_API, |             intent=TokenIntents.INTENT_API, | ||||||
|  |             managed=managed, | ||||||
|         ) |         ) | ||||||
|         if tokens.exists(): |         if tokens.exists(): | ||||||
|             token = tokens.first() |             return tokens.first() | ||||||
|             if not token.managed: |         try: | ||||||
|                 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, | ||||||
| @ -395,10 +397,18 @@ class Outpost(models.Model): | |||||||
|                 expiring=False, |                 expiring=False, | ||||||
|                 managed=managed, |                 managed=managed, | ||||||
|             ) |             ) | ||||||
|  |         except IntegrityError: | ||||||
|  |             # Integrity error happens mostly when managed is re-used | ||||||
|  |             Token.objects.filter(managed=managed).delete() | ||||||
|  |             Token.objects.filter(identifier=self.token_identifier).delete() | ||||||
|  |             return self.token | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: |     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: | ||||||
|         """Get an iterator of all objects the user needs read access to""" |         """Get an iterator of all objects the user needs read access to""" | ||||||
|         objects: list[Union[models.Model, str]] = [self] |         objects: list[Union[models.Model, str]] = [ | ||||||
|  |             self, | ||||||
|  |             "authentik_events.add_event", | ||||||
|  |         ] | ||||||
|         for provider in ( |         for provider in ( | ||||||
|             Provider.objects.filter(outpost=self).select_related().select_subclasses() |             Provider.objects.filter(outpost=self).select_related().select_subclasses() | ||||||
|         ): |         ): | ||||||
|  | |||||||
| @ -37,7 +37,9 @@ class AccessDeniedResponse(TemplateResponse): | |||||||
|             if self._request.user and self._request.user.is_authenticated: |             if self._request.user and self._request.user.is_authenticated: | ||||||
|                 if ( |                 if ( | ||||||
|                     self._request.user.is_superuser |                     self._request.user.is_superuser | ||||||
|                     or self._request.user.attributes.get(USER_ATTRIBUTE_DEBUG, False) |                     or self._request.user.group_attributes().get( | ||||||
|  |                         USER_ATTRIBUTE_DEBUG, False | ||||||
|  |                     ) | ||||||
|                 ): |                 ): | ||||||
|                     context["policy_result"] = self.policy_result |                     context["policy_result"] = self.policy_result | ||||||
|         return context |         return context | ||||||
|  | |||||||
| @ -0,0 +1,48 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-06-14 15:32 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies_event_matcher", "0016_alter_eventmatcherpolicy_action"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="eventmatcherpolicy", | ||||||
|  |             name="action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 blank=True, | ||||||
|  |                 choices=[ | ||||||
|  |                     ("login", "Login"), | ||||||
|  |                     ("login_failed", "Login Failed"), | ||||||
|  |                     ("logout", "Logout"), | ||||||
|  |                     ("user_write", "User Write"), | ||||||
|  |                     ("suspicious_request", "Suspicious Request"), | ||||||
|  |                     ("password_set", "Password Set"), | ||||||
|  |                     ("secret_view", "Secret View"), | ||||||
|  |                     ("invitation_used", "Invite Used"), | ||||||
|  |                     ("authorize_application", "Authorize Application"), | ||||||
|  |                     ("source_linked", "Source Linked"), | ||||||
|  |                     ("impersonation_started", "Impersonation Started"), | ||||||
|  |                     ("impersonation_ended", "Impersonation Ended"), | ||||||
|  |                     ("policy_execution", "Policy Execution"), | ||||||
|  |                     ("policy_exception", "Policy Exception"), | ||||||
|  |                     ("property_mapping_exception", "Property Mapping Exception"), | ||||||
|  |                     ("system_task_execution", "System Task Execution"), | ||||||
|  |                     ("system_task_exception", "System Task Exception"), | ||||||
|  |                     ("system_exception", "System Exception"), | ||||||
|  |                     ("configuration_error", "Configuration Error"), | ||||||
|  |                     ("model_created", "Model Created"), | ||||||
|  |                     ("model_updated", "Model Updated"), | ||||||
|  |                     ("model_deleted", "Model Deleted"), | ||||||
|  |                     ("email_sent", "Email Sent"), | ||||||
|  |                     ("update_available", "Update Available"), | ||||||
|  |                     ("custom_", "Custom Prefix"), | ||||||
|  |                 ], | ||||||
|  |                 help_text="Match created events with this action type. When left empty, all action types will be matched.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -1,7 +1,6 @@ | |||||||
| """authentik policy task""" | """authentik policy task""" | ||||||
| from multiprocessing import get_context | from multiprocessing import get_context | ||||||
| from multiprocessing.connection import Connection | from multiprocessing.connection import Connection | ||||||
| from traceback import format_tb |  | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| @ -11,14 +10,16 @@ from sentry_sdk.tracing import Span | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  | from authentik.lib.config import CONFIG | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| TRACEBACK_HEADER = "Traceback (most recent call last):\n" |  | ||||||
|  |  | ||||||
| FORK_CTX = get_context("fork") | FORK_CTX = get_context("fork") | ||||||
|  | CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_policies")) | ||||||
| PROCESS_CLASS = FORK_CTX.Process | PROCESS_CLASS = FORK_CTX.Process | ||||||
| HIST_POLICIES_EXECUTION_TIME = Histogram( | HIST_POLICIES_EXECUTION_TIME = Histogram( | ||||||
|     "authentik_policies_execution_time", |     "authentik_policies_execution_time", | ||||||
| @ -106,11 +107,7 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|         except PolicyException as exc: |         except PolicyException as exc: | ||||||
|             # Either use passed original exception or whatever we have |             # Either use passed original exception or whatever we have | ||||||
|             src_exc = exc.src_exc if exc.src_exc else exc |             src_exc = exc.src_exc if exc.src_exc else exc | ||||||
|             error_string = ( |             error_string = exception_to_string(src_exc) | ||||||
|                 TRACEBACK_HEADER |  | ||||||
|                 + "".join(format_tb(src_exc.__traceback__)) |  | ||||||
|                 + str(src_exc) |  | ||||||
|             ) |  | ||||||
|             # Create policy exception event, only when we're not debugging |             # Create policy exception event, only when we're not debugging | ||||||
|             if not self.request.debug: |             if not self.request.debug: | ||||||
|                 self.create_event(EventAction.POLICY_EXCEPTION, message=error_string) |                 self.create_event(EventAction.POLICY_EXCEPTION, message=error_string) | ||||||
| @ -119,7 +116,7 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|         policy_result.source_binding = self.binding |         policy_result.source_binding = self.binding | ||||||
|         if not self.request.debug: |         if not self.request.debug: | ||||||
|             key = cache_key(self.binding, self.request) |             key = cache_key(self.binding, self.request) | ||||||
|             cache.set(key, policy_result) |             cache.set(key, policy_result, CACHE_TIMEOUT) | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|             "P_ENG(proc): finished and cached ", |             "P_ENG(proc): finished and cached ", | ||||||
|             policy=self.binding.policy, |             policy=self.binding.policy, | ||||||
|  | |||||||
| @ -3,11 +3,13 @@ from django.core.cache import cache | |||||||
| from django.db import models | from django.db import models | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from rest_framework.serializers import BaseSerializer | from rest_framework.serializers import BaseSerializer | ||||||
|  | from structlog import get_logger | ||||||
|  |  | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.policies.models import Policy | from authentik.policies.models import Policy | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
| CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_" | CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_" | ||||||
| CACHE_KEY_USER_PREFIX = "authentik_reputation_user_" | CACHE_KEY_USER_PREFIX = "authentik_reputation_user_" | ||||||
|  |  | ||||||
| @ -31,14 +33,21 @@ class ReputationPolicy(Policy): | |||||||
|  |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: |     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||||
|         remote_ip = get_client_ip(request.http_request) |         remote_ip = get_client_ip(request.http_request) | ||||||
|         passing = True |         passing = False | ||||||
|         if self.check_ip: |         if self.check_ip: | ||||||
|             score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) |             score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) | ||||||
|             passing = passing and score <= self.threshold |             passing += passing or score <= self.threshold | ||||||
|  |             LOGGER.debug("Score for IP", ip=remote_ip, score=score, passing=passing) | ||||||
|         if self.check_username: |         if self.check_username: | ||||||
|             score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) |             score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) | ||||||
|             passing = passing and score <= self.threshold |             passing += passing or score <= self.threshold | ||||||
|         return PolicyResult(passing) |             LOGGER.debug( | ||||||
|  |                 "Score for Username", | ||||||
|  |                 username=request.user.username, | ||||||
|  |                 score=score, | ||||||
|  |                 passing=passing, | ||||||
|  |             ) | ||||||
|  |         return PolicyResult(bool(passing)) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ from django.dispatch import receiver | |||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.policies.reputation.models import ( | from authentik.policies.reputation.models import ( | ||||||
|     CACHE_KEY_IP_PREFIX, |     CACHE_KEY_IP_PREFIX, | ||||||
| @ -13,6 +14,7 @@ from authentik.policies.reputation.models import ( | |||||||
| from authentik.stages.identification.signals import identification_failed | from authentik.stages.identification.signals import identification_failed | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_reputation")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def update_score(request: HttpRequest, username: str, amount: int): | def update_score(request: HttpRequest, username: str, amount: int): | ||||||
| @ -20,10 +22,10 @@ def update_score(request: HttpRequest, username: str, amount: int): | |||||||
|     remote_ip = get_client_ip(request) |     remote_ip = get_client_ip(request) | ||||||
|  |  | ||||||
|     # We only update the cache here, as its faster than writing to the DB |     # We only update the cache here, as its faster than writing to the DB | ||||||
|     cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) |     cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0, CACHE_TIMEOUT) | ||||||
|     cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) |     cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) | ||||||
|  |  | ||||||
|     cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0) |     cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT) | ||||||
|     cache.incr(CACHE_KEY_USER_PREFIX + username, amount) |     cache.incr(CACHE_KEY_USER_PREFIX + username, amount) | ||||||
|  |  | ||||||
|     LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) |     LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| """test reputation signals and policy""" | """test reputation signals and policy""" | ||||||
| from django.contrib.auth import authenticate | from django.contrib.auth import authenticate | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.test import TestCase | from django.test import RequestFactory, TestCase | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  | from authentik.lib.utils.http import DEFAULT_IP | ||||||
| from authentik.policies.reputation.models import ( | from authentik.policies.reputation.models import ( | ||||||
|     CACHE_KEY_IP_PREFIX, |     CACHE_KEY_IP_PREFIX, | ||||||
|     CACHE_KEY_USER_PREFIX, |     CACHE_KEY_USER_PREFIX, | ||||||
| @ -19,9 +20,12 @@ class TestReputationPolicy(TestCase): | |||||||
|     """test reputation signals and policy""" |     """test reputation signals and policy""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         self.test_ip = "255.255.255.255" |         self.request_factory = RequestFactory() | ||||||
|  |         self.request = self.request_factory.get("/") | ||||||
|  |         self.test_ip = "127.0.0.1" | ||||||
|         self.test_username = "test" |         self.test_username = "test" | ||||||
|         cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip) |         cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip) | ||||||
|  |         cache.delete(CACHE_KEY_IP_PREFIX + DEFAULT_IP) | ||||||
|         cache.delete(CACHE_KEY_USER_PREFIX + self.test_username) |         cache.delete(CACHE_KEY_USER_PREFIX + self.test_username) | ||||||
|         # We need a user for the one-to-one in userreputation |         # We need a user for the one-to-one in userreputation | ||||||
|         self.user = User.objects.create(username=self.test_username) |         self.user = User.objects.create(username=self.test_username) | ||||||
| @ -29,7 +33,9 @@ class TestReputationPolicy(TestCase): | |||||||
|     def test_ip_reputation(self): |     def test_ip_reputation(self): | ||||||
|         """test IP reputation""" |         """test IP reputation""" | ||||||
|         # Trigger negative reputation |         # Trigger negative reputation | ||||||
|         authenticate(None, username=self.test_username, password=self.test_username) |         authenticate( | ||||||
|  |             self.request, username=self.test_username, password=self.test_username | ||||||
|  |         ) | ||||||
|         # Test value in cache |         # Test value in cache | ||||||
|         self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1) |         self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1) | ||||||
|         # Save cache and check db values |         # Save cache and check db values | ||||||
| @ -39,7 +45,9 @@ class TestReputationPolicy(TestCase): | |||||||
|     def test_user_reputation(self): |     def test_user_reputation(self): | ||||||
|         """test User reputation""" |         """test User reputation""" | ||||||
|         # Trigger negative reputation |         # Trigger negative reputation | ||||||
|         authenticate(None, username=self.test_username, password=self.test_username) |         authenticate( | ||||||
|  |             self.request, username=self.test_username, password=self.test_username | ||||||
|  |         ) | ||||||
|         # Test value in cache |         # Test value in cache | ||||||
|         self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1) |         self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1) | ||||||
|         # Save cache and check db values |         # Save cache and check db values | ||||||
|  | |||||||
| @ -105,6 +105,7 @@ class PolicyAccessView(AccessMixin, View): | |||||||
|         policy_engine = PolicyEngine( |         policy_engine = PolicyEngine( | ||||||
|             self.application, user or self.request.user, self.request |             self.application, user or self.request.user, self.request | ||||||
|         ) |         ) | ||||||
|  |         policy_engine.use_cache = False | ||||||
|         policy_engine.build() |         policy_engine.build() | ||||||
|         result = policy_engine.result |         result = policy_engine.result | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ return {} | |||||||
| """ | """ | ||||||
| SCOPE_EMAIL_EXPRESSION = """ | SCOPE_EMAIL_EXPRESSION = """ | ||||||
| return { | return { | ||||||
|     "email": user.email, |     "email": request.user.email, | ||||||
|     "email_verified": True |     "email_verified": True | ||||||
| } | } | ||||||
| """ | """ | ||||||
| @ -17,14 +17,14 @@ SCOPE_PROFILE_EXPRESSION = """ | |||||||
| return { | return { | ||||||
|     # Because authentik only saves the user's full name, and has no concept of first and last names, |     # Because authentik only saves the user's full name, and has no concept of first and last names, | ||||||
|     # the full name is used as given name. |     # the full name is used as given name. | ||||||
|     # You can override this behaviour in custom mappings, i.e. `user.name.split(" ")` |     # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")` | ||||||
|     "name": user.name, |     "name": request.user.name, | ||||||
|     "given_name": user.name, |     "given_name": request.user.name, | ||||||
|     "family_name": "", |     "family_name": "", | ||||||
|     "preferred_username": user.username, |     "preferred_username": request.user.username, | ||||||
|     "nickname": user.username, |     "nickname": request.user.username, | ||||||
|     # groups is not part of the official userinfo schema, but is a quasi-standard |     # groups is not part of the official userinfo schema, but is a quasi-standard | ||||||
|     "groups": [group.name for group in user.ak_groups.all()], |     "groups": [group.name for group in request.user.ak_groups.all()], | ||||||
| } | } | ||||||
| """ | """ | ||||||
|  |  | ||||||
|  | |||||||
| @ -474,7 +474,7 @@ class RefreshToken(ExpiringModel, BaseGrantModel): | |||||||
|         now = int(time.time()) |         now = int(time.time()) | ||||||
|         iat_time = now |         iat_time = now | ||||||
|         exp_time = int( |         exp_time = int( | ||||||
|             now + timedelta_from_string(self.provider.token_validity).seconds |             now + timedelta_from_string(self.provider.token_validity).total_seconds() | ||||||
|         ) |         ) | ||||||
|         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time |         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time | ||||||
|         auth_events = Event.objects.filter( |         auth_events = Event.objects.filter( | ||||||
|  | |||||||
| @ -374,9 +374,9 @@ class OAuthFulfillmentStage(StageView): | |||||||
|             query_fragment["code"] = code.code |             query_fragment["code"] = code.code | ||||||
|  |  | ||||||
|         query_fragment["token_type"] = "bearer" |         query_fragment["token_type"] = "bearer" | ||||||
|         query_fragment["expires_in"] = timedelta_from_string( |         query_fragment["expires_in"] = int( | ||||||
|             self.provider.token_validity |             timedelta_from_string(self.provider.token_validity).total_seconds() | ||||||
|         ).seconds |         ) | ||||||
|         query_fragment["state"] = self.params.state if self.params.state else "" |         query_fragment["state"] = self.params.state if self.params.state else "" | ||||||
|  |  | ||||||
|         return query_fragment |         return query_fragment | ||||||
| @ -468,14 +468,14 @@ class AuthorizationFlowInitView(PolicyAccessView): | |||||||
|         # OpenID clients can specify a `prompt` parameter, and if its set to consent we |         # OpenID clients can specify a `prompt` parameter, and if its set to consent we | ||||||
|         # need to inject a consent stage |         # need to inject a consent stage | ||||||
|         if PROMPT_CONSNET in self.params.prompt: |         if PROMPT_CONSNET in self.params.prompt: | ||||||
|             if not any(isinstance(x, ConsentStageView) for x in plan.stages): |             if not any(isinstance(x.stage, ConsentStageView) for x in plan.bindings): | ||||||
|                 # Plan does not have any consent stage, so we add an in-memory one |                 # Plan does not have any consent stage, so we add an in-memory one | ||||||
|                 stage = ConsentStage( |                 stage = ConsentStage( | ||||||
|                     name="OAuth2 Provider In-memory consent stage", |                     name="OAuth2 Provider In-memory consent stage", | ||||||
|                     mode=ConsentMode.ALWAYS_REQUIRE, |                     mode=ConsentMode.ALWAYS_REQUIRE, | ||||||
|                 ) |                 ) | ||||||
|                 plan.append(stage) |                 plan.append_stage(stage) | ||||||
|         plan.append(in_memory_stage(OAuthFulfillmentStage)) |         plan.append_stage(in_memory_stage(OAuthFulfillmentStage)) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -215,9 +215,11 @@ class TokenView(View): | |||||||
|             "access_token": refresh_token.access_token, |             "access_token": refresh_token.access_token, | ||||||
|             "refresh_token": refresh_token.refresh_token, |             "refresh_token": refresh_token.refresh_token, | ||||||
|             "token_type": "bearer", |             "token_type": "bearer", | ||||||
|             "expires_in": timedelta_from_string( |             "expires_in": int( | ||||||
|  |                 timedelta_from_string( | ||||||
|                     self.params.provider.token_validity |                     self.params.provider.token_validity | ||||||
|             ).seconds, |                 ).total_seconds() | ||||||
|  |             ), | ||||||
|             "id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()), |             "id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()), | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @ -258,9 +260,11 @@ class TokenView(View): | |||||||
|             "access_token": refresh_token.access_token, |             "access_token": refresh_token.access_token, | ||||||
|             "refresh_token": refresh_token.refresh_token, |             "refresh_token": refresh_token.refresh_token, | ||||||
|             "token_type": "bearer", |             "token_type": "bearer", | ||||||
|             "expires_in": timedelta_from_string( |             "expires_in": int( | ||||||
|  |                 timedelta_from_string( | ||||||
|                     refresh_token.provider.token_validity |                     refresh_token.provider.token_validity | ||||||
|             ).seconds, |                 ).total_seconds() | ||||||
|  |             ), | ||||||
|             "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), |             "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """ProxyProvider API Views""" | """ProxyProvider API Views""" | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from drf_spectacular.utils import extend_schema_field | from drf_spectacular.utils import extend_schema_field, extend_schema_serializer | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import CharField, ListField, SerializerMethodField | from rest_framework.fields import CharField, ListField, SerializerMethodField | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| @ -85,6 +85,7 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet): | |||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @extend_schema_serializer(deprecate_fields=["forward_auth_mode"]) | ||||||
| class ProxyOutpostConfigSerializer(ModelSerializer): | class ProxyOutpostConfigSerializer(ModelSerializer): | ||||||
|     """Proxy provider serializer for outposts""" |     """Proxy provider serializer for outposts""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ SCOPE_AK_PROXY_EXPRESSION = """ | |||||||
| # which are used for example for the HTTP-Basic Authentication mapping. | # which are used for example for the HTTP-Basic Authentication mapping. | ||||||
| return { | return { | ||||||
|     "ak_proxy": { |     "ak_proxy": { | ||||||
|         "user_attributes": user.group_attributes() |         "user_attributes": request.user.group_attributes() | ||||||
|     } |     } | ||||||
| }""" | }""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ from authentik.managed.manager import EnsureExists, ObjectManager | |||||||
| from authentik.providers.saml.models import SAMLPropertyMapping | from authentik.providers.saml.models import SAMLPropertyMapping | ||||||
|  |  | ||||||
| GROUP_EXPRESSION = """ | GROUP_EXPRESSION = """ | ||||||
| for group in user.ak_groups.all(): | for group in request.user.ak_groups.all(): | ||||||
|     yield group.name |     yield group.name | ||||||
| """ | """ | ||||||
|  |  | ||||||
| @ -18,7 +18,7 @@ class SAMLProviderManager(ObjectManager): | |||||||
|                 "goauthentik.io/providers/saml/upn", |                 "goauthentik.io/providers/saml/upn", | ||||||
|                 name="authentik default SAML Mapping: UPN", |                 name="authentik default SAML Mapping: UPN", | ||||||
|                 saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", |                 saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", | ||||||
|                 expression="return user.attributes.get('upn', user.email)", |                 expression="return request.user.attributes.get('upn', request.user.email)", | ||||||
|                 friendly_name="", |                 friendly_name="", | ||||||
|             ), |             ), | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
| @ -26,7 +26,7 @@ class SAMLProviderManager(ObjectManager): | |||||||
|                 "goauthentik.io/providers/saml/name", |                 "goauthentik.io/providers/saml/name", | ||||||
|                 name="authentik default SAML Mapping: Name", |                 name="authentik default SAML Mapping: Name", | ||||||
|                 saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", |                 saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", | ||||||
|                 expression="return user.name", |                 expression="return request.user.name", | ||||||
|                 friendly_name="", |                 friendly_name="", | ||||||
|             ), |             ), | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
| @ -34,7 +34,7 @@ class SAMLProviderManager(ObjectManager): | |||||||
|                 "goauthentik.io/providers/saml/email", |                 "goauthentik.io/providers/saml/email", | ||||||
|                 name="authentik default SAML Mapping: Email", |                 name="authentik default SAML Mapping: Email", | ||||||
|                 saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", |                 saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", | ||||||
|                 expression="return user.email", |                 expression="return request.user.email", | ||||||
|                 friendly_name="", |                 friendly_name="", | ||||||
|             ), |             ), | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
| @ -42,7 +42,7 @@ class SAMLProviderManager(ObjectManager): | |||||||
|                 "goauthentik.io/providers/saml/username", |                 "goauthentik.io/providers/saml/username", | ||||||
|                 name="authentik default SAML Mapping: Username", |                 name="authentik default SAML Mapping: Username", | ||||||
|                 saml_name="http://schemas.goauthentik.io/2021/02/saml/username", |                 saml_name="http://schemas.goauthentik.io/2021/02/saml/username", | ||||||
|                 expression="return user.username", |                 expression="return request.user.username", | ||||||
|                 friendly_name="", |                 friendly_name="", | ||||||
|             ), |             ), | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
| @ -50,7 +50,7 @@ class SAMLProviderManager(ObjectManager): | |||||||
|                 "goauthentik.io/providers/saml/uid", |                 "goauthentik.io/providers/saml/uid", | ||||||
|                 name="authentik default SAML Mapping: User ID", |                 name="authentik default SAML Mapping: User ID", | ||||||
|                 saml_name="http://schemas.goauthentik.io/2021/02/saml/uid", |                 saml_name="http://schemas.goauthentik.io/2021/02/saml/uid", | ||||||
|                 expression="return user.pk", |                 expression="return request.user.pk", | ||||||
|                 friendly_name="", |                 friendly_name="", | ||||||
|             ), |             ), | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
| @ -68,7 +68,7 @@ class SAMLProviderManager(ObjectManager): | |||||||
|                 saml_name=( |                 saml_name=( | ||||||
|                     "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" |                     "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" | ||||||
|                 ), |                 ), | ||||||
|                 expression="return user.username", |                 expression="return request.user.username", | ||||||
|                 friendly_name="", |                 friendly_name="", | ||||||
|             ), |             ), | ||||||
|         ] |         ] | ||||||
|  | |||||||
| @ -24,6 +24,7 @@ from authentik.sources.saml.processors.constants import ( | |||||||
|     SAML_NAME_ID_FORMAT_EMAIL, |     SAML_NAME_ID_FORMAT_EMAIL, | ||||||
|     SAML_NAME_ID_FORMAT_PERSISTENT, |     SAML_NAME_ID_FORMAT_PERSISTENT, | ||||||
|     SAML_NAME_ID_FORMAT_TRANSIENT, |     SAML_NAME_ID_FORMAT_TRANSIENT, | ||||||
|  |     SAML_NAME_ID_FORMAT_UNSPECIFIED, | ||||||
|     SAML_NAME_ID_FORMAT_WINDOWS, |     SAML_NAME_ID_FORMAT_WINDOWS, | ||||||
|     SAML_NAME_ID_FORMAT_X509, |     SAML_NAME_ID_FORMAT_X509, | ||||||
|     SIGN_ALGORITHM_TRANSFORM_MAP, |     SIGN_ALGORITHM_TRANSFORM_MAP, | ||||||
| @ -165,7 +166,10 @@ class AssertionProcessor: | |||||||
|         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_EMAIL: |         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_EMAIL: | ||||||
|             name_id.text = self.http_request.user.email |             name_id.text = self.http_request.user.email | ||||||
|             return name_id |             return name_id | ||||||
|         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_PERSISTENT: |         if name_id.attrib["Format"] in [ | ||||||
|  |             SAML_NAME_ID_FORMAT_PERSISTENT, | ||||||
|  |             SAML_NAME_ID_FORMAT_UNSPECIFIED, | ||||||
|  |         ]: | ||||||
|             name_id.text = persistent |             name_id.text = persistent | ||||||
|             return name_id |             return name_id | ||||||
|         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509: |         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509: | ||||||
| @ -180,7 +184,7 @@ class AssertionProcessor: | |||||||
|             return name_id |             return name_id | ||||||
|         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: |         if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: | ||||||
|             # Use the hash of the user's session, which changes every session |             # Use the hash of the user's session, which changes every session | ||||||
|             session_key: str = self.http_request.user.session.session_key |             session_key: str = self.http_request.session.session_key | ||||||
|             name_id.text = sha256(session_key.encode()).hexdigest() |             name_id.text = sha256(session_key.encode()).hexdigest() | ||||||
|             return name_id |             return name_id | ||||||
|         raise UnsupportedNameIDFormat( |         raise UnsupportedNameIDFormat( | ||||||
|  | |||||||
| @ -120,7 +120,7 @@ class ServiceProviderMetadataParser: | |||||||
|                 ) |                 ) | ||||||
|                 ctx.key = key |                 ctx.key = key | ||||||
|                 ctx.verify(signature_node) |                 ctx.verify(signature_node) | ||||||
|             except xmlsec.VerificationError as exc: |             except xmlsec.Error as exc: | ||||||
|                 raise ValueError("Failed to verify Metadata signature") from exc |                 raise ValueError("Failed to verify Metadata signature") from exc | ||||||
|  |  | ||||||
|     def parse(self, raw_xml: str) -> ServiceProviderMetadata: |     def parse(self, raw_xml: str) -> ServiceProviderMetadata: | ||||||
|  | |||||||
| @ -20,10 +20,11 @@ from authentik.sources.saml.processors.constants import ( | |||||||
|     RSA_SHA256, |     RSA_SHA256, | ||||||
|     RSA_SHA384, |     RSA_SHA384, | ||||||
|     RSA_SHA512, |     RSA_SHA512, | ||||||
|     SAML_NAME_ID_FORMAT_EMAIL, |     SAML_NAME_ID_FORMAT_UNSPECIFIED, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | ERROR_CANNOT_DECODE_REQUEST = "Cannot decode SAML request." | ||||||
| ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = ( | ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = ( | ||||||
|     "Verification Certificate configured, but request is not signed." |     "Verification Certificate configured, but request is not signed." | ||||||
| ) | ) | ||||||
| @ -42,7 +43,7 @@ class AuthNRequest: | |||||||
|  |  | ||||||
|     relay_state: Optional[str] = None |     relay_state: Optional[str] = None | ||||||
|  |  | ||||||
|     name_id_policy: str = SAML_NAME_ID_FORMAT_EMAIL |     name_id_policy: str = SAML_NAME_ID_FORMAT_UNSPECIFIED | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthNRequestParser: | class AuthNRequestParser: | ||||||
| @ -69,16 +70,21 @@ class AuthNRequestParser: | |||||||
|         auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state) |         auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state) | ||||||
|  |  | ||||||
|         # Check if AuthnRequest has a NameID Policy object |         # Check if AuthnRequest has a NameID Policy object | ||||||
|         name_id_policies = root.findall(f"{{{NS_SAML_PROTOCOL}}}:NameIDPolicy") |         name_id_policies = root.findall(f"{{{NS_SAML_PROTOCOL}}}NameIDPolicy") | ||||||
|         if len(name_id_policies) > 0: |         if len(name_id_policies) > 0: | ||||||
|             name_id_policy = name_id_policies[0] |             name_id_policy = name_id_policies[0] | ||||||
|             auth_n_request.name_id_policy = name_id_policy.attrib["Format"] |             auth_n_request.name_id_policy = name_id_policy.attrib.get( | ||||||
|  |                 "Format", SAML_NAME_ID_FORMAT_UNSPECIFIED | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         return auth_n_request |         return auth_n_request | ||||||
|  |  | ||||||
|     def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest: |     def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest: | ||||||
|         """Validate and parse raw request with enveloped signautre.""" |         """Validate and parse raw request with enveloped signautre.""" | ||||||
|  |         try: | ||||||
|             decoded_xml = b64decode(saml_request.encode()).decode() |             decoded_xml = b64decode(saml_request.encode()).decode() | ||||||
|  |         except UnicodeDecodeError: | ||||||
|  |             raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) | ||||||
|  |  | ||||||
|         verifier = self.provider.verification_kp |         verifier = self.provider.verification_kp | ||||||
|  |  | ||||||
| @ -108,7 +114,7 @@ class AuthNRequestParser: | |||||||
|                 ) |                 ) | ||||||
|                 ctx.key = key |                 ctx.key = key | ||||||
|                 ctx.verify(signature_node) |                 ctx.verify(signature_node) | ||||||
|             except xmlsec.VerificationError as exc: |             except xmlsec.Error as exc: | ||||||
|                 raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc |                 raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc | ||||||
|  |  | ||||||
|         return self._parse_xml(decoded_xml, relay_state) |         return self._parse_xml(decoded_xml, relay_state) | ||||||
| @ -121,7 +127,10 @@ class AuthNRequestParser: | |||||||
|         sig_alg: Optional[str] = None, |         sig_alg: Optional[str] = None, | ||||||
|     ) -> AuthNRequest: |     ) -> AuthNRequest: | ||||||
|         """Validate and parse raw request with detached signature""" |         """Validate and parse raw request with detached signature""" | ||||||
|  |         try: | ||||||
|             decoded_xml = decode_base64_and_inflate(saml_request) |             decoded_xml = decode_base64_and_inflate(saml_request) | ||||||
|  |         except UnicodeDecodeError: | ||||||
|  |             raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) | ||||||
|  |  | ||||||
|         verifier = self.provider.verification_kp |         verifier = self.provider.verification_kp | ||||||
|  |  | ||||||
| @ -160,7 +169,7 @@ class AuthNRequestParser: | |||||||
|                     sign_algorithm_transform, |                     sign_algorithm_transform, | ||||||
|                     b64decode(signature), |                     b64decode(signature), | ||||||
|                 ) |                 ) | ||||||
|             except xmlsec.VerificationError as exc: |             except xmlsec.Error as exc: | ||||||
|                 raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc |                 raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc | ||||||
|         return self._parse_xml(decoded_xml, relay_state) |         return self._parse_xml(decoded_xml, relay_state) | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,18 +2,19 @@ | |||||||
| from base64 import b64encode | from base64 import b64encode | ||||||
|  |  | ||||||
| from django.contrib.sessions.middleware import SessionMiddleware | from django.contrib.sessions.middleware import SessionMiddleware | ||||||
| from django.http.request import HttpRequest, QueryDict | from django.http.request import QueryDict | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
| from guardian.utils import get_anonymous_user | from guardian.utils import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
|  | from authentik.flows.tests.test_planner import dummy_get_response | ||||||
| from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider | from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider | ||||||
| from authentik.providers.saml.processors.assertion import AssertionProcessor | from authentik.providers.saml.processors.assertion import AssertionProcessor | ||||||
| from authentik.providers.saml.processors.request_parser import AuthNRequestParser | from authentik.providers.saml.processors.request_parser import AuthNRequestParser | ||||||
| from authentik.sources.saml.exceptions import MismatchedRequestID | from authentik.sources.saml.exceptions import MismatchedRequestID | ||||||
| from authentik.sources.saml.models import SAMLSource | from authentik.sources.saml.models import SAMLSource | ||||||
| from authentik.sources.saml.processors.constants import SAML_NAME_ID_FORMAT_EMAIL | from authentik.sources.saml.processors.constants import SAML_NAME_ID_FORMAT_UNSPECIFIED | ||||||
| from authentik.sources.saml.processors.request import ( | from authentik.sources.saml.processors.request import ( | ||||||
|     SESSION_REQUEST_ID, |     SESSION_REQUEST_ID, | ||||||
|     RequestProcessor, |     RequestProcessor, | ||||||
| @ -58,11 +59,6 @@ qNAZMq1DqpibfCBg | |||||||
| -----END CERTIFICATE-----""" | -----END CERTIFICATE-----""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def dummy_get_response(request: HttpRequest):  # pragma: no cover |  | ||||||
|     """Dummy get_response for SessionMiddleware""" |  | ||||||
|     return None |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestAuthNRequest(TestCase): | class TestAuthNRequest(TestCase): | ||||||
|     """Test AuthN Request generator and parser""" |     """Test AuthN Request generator and parser""" | ||||||
|  |  | ||||||
| @ -210,5 +206,5 @@ class TestAuthNRequest(TestCase): | |||||||
|             REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG |             REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab") |         self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab") | ||||||
|         self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL) |         self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_UNSPECIFIED) | ||||||
|         self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE) |         self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE) | ||||||
|  | |||||||
| @ -17,6 +17,7 @@ from authentik.providers.saml.models import SAMLBindings, SAMLProvider | |||||||
| from authentik.providers.saml.processors.assertion import AssertionProcessor | from authentik.providers.saml.processors.assertion import AssertionProcessor | ||||||
| from authentik.providers.saml.processors.request_parser import AuthNRequest | from authentik.providers.saml.processors.request_parser import AuthNRequest | ||||||
| from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64 | from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64 | ||||||
|  | from authentik.sources.saml.exceptions import SAMLException | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| URL_VALIDATOR = URLValidator(schemes=("http", "https")) | URL_VALIDATOR = URLValidator(schemes=("http", "https")) | ||||||
| @ -56,22 +57,30 @@ class SAMLFlowFinalView(ChallengeStageView): | |||||||
|         provider: SAMLProvider = get_object_or_404( |         provider: SAMLProvider = get_object_or_404( | ||||||
|             SAMLProvider, pk=application.provider_id |             SAMLProvider, pk=application.provider_id | ||||||
|         ) |         ) | ||||||
|         # Log Application Authorization |  | ||||||
|         Event.new( |  | ||||||
|             EventAction.AUTHORIZE_APPLICATION, |  | ||||||
|             authorized_application=application, |  | ||||||
|             flow=self.executor.plan.flow_pk, |  | ||||||
|         ).from_http(self.request) |  | ||||||
|  |  | ||||||
|         if SESSION_KEY_AUTH_N_REQUEST not in self.request.session: |         if SESSION_KEY_AUTH_N_REQUEST not in self.request.session: | ||||||
|             return self.executor.stage_invalid() |             return self.executor.stage_invalid() | ||||||
|  |  | ||||||
|         auth_n_request: AuthNRequest = self.request.session.pop( |         auth_n_request: AuthNRequest = self.request.session.pop( | ||||||
|             SESSION_KEY_AUTH_N_REQUEST |             SESSION_KEY_AUTH_N_REQUEST | ||||||
|         ) |         ) | ||||||
|  |         try: | ||||||
|             response = AssertionProcessor( |             response = AssertionProcessor( | ||||||
|                 provider, request, auth_n_request |                 provider, request, auth_n_request | ||||||
|             ).build_response() |             ).build_response() | ||||||
|  |         except SAMLException as exc: | ||||||
|  |             Event.new( | ||||||
|  |                 EventAction.CONFIGURATION_ERROR, | ||||||
|  |                 message=f"Failed to process SAML assertion: {str(exc)}", | ||||||
|  |                 provider=provider, | ||||||
|  |             ).from_http(self.request) | ||||||
|  |             return self.executor.stage_invalid() | ||||||
|  |  | ||||||
|  |         # Log Application Authorization | ||||||
|  |         Event.new( | ||||||
|  |             EventAction.AUTHORIZE_APPLICATION, | ||||||
|  |             authorized_application=application, | ||||||
|  |             flow=self.executor.plan.flow_pk, | ||||||
|  |         ).from_http(self.request) | ||||||
|  |  | ||||||
|         if provider.sp_binding == SAMLBindings.POST: |         if provider.sp_binding == SAMLBindings.POST: | ||||||
|             form_attrs = { |             form_attrs = { | ||||||
|  | |||||||
| @ -79,7 +79,7 @@ class SAMLSSOView(PolicyAccessView): | |||||||
|                 PLAN_CONTEXT_CONSENT_PERMISSIONS: [], |                 PLAN_CONTEXT_CONSENT_PERMISSIONS: [], | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         plan.append(in_memory_stage(SAMLFlowFinalView)) |         plan.append_stage(in_memory_stage(SAMLFlowFinalView)) | ||||||
|         request.session[SESSION_KEY_PLAN] = plan |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -44,7 +44,7 @@ class Command(BaseCommand): | |||||||
|             user=user, |             user=user, | ||||||
|             intent=TokenIntents.INTENT_RECOVERY, |             intent=TokenIntents.INTENT_RECOVERY, | ||||||
|             description=f"Recovery Token generated by {getuser()} on {_now}", |             description=f"Recovery Token generated by {getuser()} on {_now}", | ||||||
|             identifier=f"ak-recovery-{user}", |             identifier=f"ak-recovery-{user}-{_now}", | ||||||
|         ) |         ) | ||||||
|         self.stdout.write( |         self.stdout.write( | ||||||
|             ( |             ( | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ from django.utils.translation import gettext as _ | |||||||
| from django.views import View | from django.views import View | ||||||
|  |  | ||||||
| from authentik.core.models import Token, TokenIntents | from authentik.core.models import Token, TokenIntents | ||||||
|  | from authentik.stages.password import BACKEND_DJANGO | ||||||
|  |  | ||||||
|  |  | ||||||
| class UseTokenView(View): | class UseTokenView(View): | ||||||
| @ -18,7 +19,7 @@ class UseTokenView(View): | |||||||
|         if not tokens.exists(): |         if not tokens.exists(): | ||||||
|             raise Http404 |             raise Http404 | ||||||
|         token = tokens.first() |         token = tokens.first() | ||||||
|         login(request, token.user, backend="django.contrib.auth.backends.ModelBackend") |         login(request, token.user, backend=BACKEND_DJANGO) | ||||||
|         token.delete() |         token.delete() | ||||||
|         messages.warning(request, _("Used recovery-link to authenticate.")) |         messages.warning(request, _("Used recovery-link to authenticate.")) | ||||||
|         return redirect("authentik_core:if-admin") |         return redirect("authentik_core:if-admin") | ||||||
|  | |||||||
| @ -15,6 +15,7 @@ import logging | |||||||
| import os | import os | ||||||
| import sys | import sys | ||||||
| from json import dumps | from json import dumps | ||||||
|  | from tempfile import gettempdir | ||||||
| from time import time | from time import time | ||||||
|  |  | ||||||
| import structlog | import structlog | ||||||
| @ -152,6 +153,7 @@ SPECTACULAR_SETTINGS = { | |||||||
|         "url": "https://github.com/goauthentik/authentik/blob/master/LICENSE", |         "url": "https://github.com/goauthentik/authentik/blob/master/LICENSE", | ||||||
|     }, |     }, | ||||||
|     "ENUM_NAME_OVERRIDES": { |     "ENUM_NAME_OVERRIDES": { | ||||||
|  |         "EventActions": "authentik.events.models.EventAction", | ||||||
|         "ChallengeChoices": "authentik.flows.challenge.ChallengeTypes", |         "ChallengeChoices": "authentik.flows.challenge.ChallengeTypes", | ||||||
|         "FlowDesignationEnum": "authentik.flows.models.FlowDesignation", |         "FlowDesignationEnum": "authentik.flows.models.FlowDesignation", | ||||||
|         "PolicyEngineMode": "authentik.policies.models.PolicyEngineMode", |         "PolicyEngineMode": "authentik.policies.models.PolicyEngineMode", | ||||||
| @ -193,6 +195,7 @@ CACHES = { | |||||||
|             f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" |             f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" | ||||||
|             f"/{CONFIG.y('redis.cache_db')}" |             f"/{CONFIG.y('redis.cache_db')}" | ||||||
|         ), |         ), | ||||||
|  |         "TIMEOUT": int(CONFIG.y("redis.cache_timeout", 300)), | ||||||
|         "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, |         "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @ -341,7 +344,7 @@ DBBACKUP_FILENAME_TEMPLATE = "authentik-backup-{datetime}.sql" | |||||||
| DBBACKUP_CONNECTOR_MAPPING = { | DBBACKUP_CONNECTOR_MAPPING = { | ||||||
|     "django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector", |     "django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector", | ||||||
| } | } | ||||||
|  | DBBACKUP_TMP_DIR = gettempdir() if DEBUG else "/tmp"  # nosec | ||||||
| if CONFIG.y("postgresql.s3_backup"): | if CONFIG.y("postgresql.s3_backup"): | ||||||
|     DBBACKUP_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" |     DBBACKUP_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" | ||||||
|     DBBACKUP_STORAGE_OPTIONS = { |     DBBACKUP_STORAGE_OPTIONS = { | ||||||
| @ -375,7 +378,11 @@ if _ERROR_REPORTING: | |||||||
|         environment=CONFIG.y("error_reporting.environment", "customer"), |         environment=CONFIG.y("error_reporting.environment", "customer"), | ||||||
|         send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False), |         send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False), | ||||||
|     ) |     ) | ||||||
|     set_tag("authentik.build_hash", os.environ.get(ENV_GIT_HASH_KEY, "tagged")) |     # Default to empty string as that is what docker has | ||||||
|  |     build_hash = os.environ.get(ENV_GIT_HASH_KEY, "") | ||||||
|  |     if build_hash == "": | ||||||
|  |         build_hash = "tagged" | ||||||
|  |     set_tag("authentik.build_hash", build_hash) | ||||||
|     set_tag( |     set_tag( | ||||||
|         "authentik.env", "kubernetes" if "KUBERNETES_PORT" in os.environ else "compose" |         "authentik.env", "kubernetes" if "KUBERNETES_PORT" in os.environ else "compose" | ||||||
|     ) |     ) | ||||||
|  | |||||||
| @ -15,6 +15,10 @@ class PytestTestRunner:  # pragma: no cover | |||||||
|         settings.CELERY_TASK_ALWAYS_EAGER = True |         settings.CELERY_TASK_ALWAYS_EAGER = True | ||||||
|         CONFIG.y_set("authentik.avatars", "none") |         CONFIG.y_set("authentik.avatars", "none") | ||||||
|         CONFIG.y_set("authentik.geoip", "tests/GeoLite2-City-Test.mmdb") |         CONFIG.y_set("authentik.geoip", "tests/GeoLite2-City-Test.mmdb") | ||||||
|  |         CONFIG.y_set( | ||||||
|  |             "outposts.docker_image_base", | ||||||
|  |             "beryju.org/authentik/outpost-%(type)s:gh-master", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def run_tests(self, test_labels): |     def run_tests(self, test_labels): | ||||||
|         """Run pytest and return the exitcode. |         """Run pytest and return the exitcode. | ||||||
|  | |||||||
| @ -60,14 +60,21 @@ class LDAPPasswordChanger: | |||||||
|     def check_ad_password_complexity_enabled(self) -> bool: |     def check_ad_password_complexity_enabled(self) -> bool: | ||||||
|         """Check if DOMAIN_PASSWORD_COMPLEX is enabled""" |         """Check if DOMAIN_PASSWORD_COMPLEX is enabled""" | ||||||
|         root_dn = self.get_domain_root_dn() |         root_dn = self.get_domain_root_dn() | ||||||
|  |         try: | ||||||
|             root_attrs = self._source.connection.extend.standard.paged_search( |             root_attrs = self._source.connection.extend.standard.paged_search( | ||||||
|                 search_base=root_dn, |                 search_base=root_dn, | ||||||
|                 search_filter="(objectClass=*)", |                 search_filter="(objectClass=*)", | ||||||
|                 search_scope=ldap3.BASE, |                 search_scope=ldap3.BASE, | ||||||
|                 attributes=["pwdProperties"], |                 attributes=["pwdProperties"], | ||||||
|             ) |             ) | ||||||
|  |         except ldap3.core.exceptions.LDAPAttributeError: | ||||||
|  |             return False | ||||||
|         root_attrs = list(root_attrs)[0] |         root_attrs = list(root_attrs)[0] | ||||||
|         pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"]) |         raw_pwd_properties = root_attrs.get("attributes", {}).get("pwdProperties", None) | ||||||
|  |         if raw_pwd_properties is None: | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         pwd_properties = PwdProperties(raw_pwd_properties) | ||||||
|         if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties: |         if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties: | ||||||
|             return True |             return True | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,17 +2,21 @@ | |||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  |  | ||||||
|  |  | ||||||
| class MissingSAMLResponse(SentryIgnoredException): | class SAMLException(SentryIgnoredException): | ||||||
|  |     """Base SAML Exception""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MissingSAMLResponse(SAMLException): | ||||||
|     """Exception raised when request does not contain SAML Response.""" |     """Exception raised when request does not contain SAML Response.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class UnsupportedNameIDFormat(SentryIgnoredException): | class UnsupportedNameIDFormat(SAMLException): | ||||||
|     """Exception raised when SAML Response contains NameID Format not supported.""" |     """Exception raised when SAML Response contains NameID Format not supported.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class MismatchedRequestID(SentryIgnoredException): | class MismatchedRequestID(SAMLException): | ||||||
|     """Exception raised when the returned request ID doesn't match the saved ID.""" |     """Exception raised when the returned request ID doesn't match the saved ID.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class InvalidSignature(SentryIgnoredException): | class InvalidSignature(SAMLException): | ||||||
|     """Signature of XML Object is either missing or invalid""" |     """Signature of XML Object is either missing or invalid""" | ||||||
|  | |||||||
| @ -15,6 +15,9 @@ NS_MAP = { | |||||||
|  |  | ||||||
| SAML_NAME_ID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | SAML_NAME_ID_FORMAT_EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | ||||||
| SAML_NAME_ID_FORMAT_PERSISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" | SAML_NAME_ID_FORMAT_PERSISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" | ||||||
|  | SAML_NAME_ID_FORMAT_UNSPECIFIED = ( | ||||||
|  |     "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | ||||||
|  | ) | ||||||
| SAML_NAME_ID_FORMAT_X509 = "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName" | SAML_NAME_ID_FORMAT_X509 = "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName" | ||||||
| SAML_NAME_ID_FORMAT_WINDOWS = ( | SAML_NAME_ID_FORMAT_WINDOWS = ( | ||||||
|     "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName" |     "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName" | ||||||
|  | |||||||
| @ -39,7 +39,7 @@ from authentik.sources.saml.processors.constants import ( | |||||||
| from authentik.sources.saml.processors.request import SESSION_REQUEST_ID | from authentik.sources.saml.processors.request import SESSION_REQUEST_ID | ||||||
| from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||||
| from authentik.stages.user_login.stage import DEFAULT_BACKEND | from authentik.stages.user_login.stage import BACKEND_DJANGO | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
| @ -141,7 +141,7 @@ class ResponseProcessor: | |||||||
|             self._source.authentication_flow, |             self._source.authentication_flow, | ||||||
|             **{ |             **{ | ||||||
|                 PLAN_CONTEXT_PENDING_USER: user, |                 PLAN_CONTEXT_PENDING_USER: user, | ||||||
|                 PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND, |                 PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -204,7 +204,7 @@ class ResponseProcessor: | |||||||
|                 self._source.authentication_flow, |                 self._source.authentication_flow, | ||||||
|                 **{ |                 **{ | ||||||
|                     PLAN_CONTEXT_PENDING_USER: matching_users.first(), |                     PLAN_CONTEXT_PENDING_USER: matching_users.first(), | ||||||
|                     PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND, |                     PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO, | ||||||
|                     PLAN_CONTEXT_REDIRECT: final_redirect, |                     PLAN_CONTEXT_REDIRECT: final_redirect, | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import AuthenticatedSession, User | ||||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| @ -31,11 +31,13 @@ def clean_temporary_users(self: MonitoredTask): | |||||||
|             continue |             continue | ||||||
|         source = sources.first() |         source = sources.first() | ||||||
|         source_delta = timedelta_from_string(source.temporary_user_delete_after) |         source_delta = timedelta_from_string(source.temporary_user_delete_after) | ||||||
|         if _now - user.last_login >= source_delta: |         if ( | ||||||
|  |             _now - user.last_login >= source_delta | ||||||
|  |             and not AuthenticatedSession.objects.filter(user=user).exists() | ||||||
|  |         ): | ||||||
|             LOGGER.debug( |             LOGGER.debug( | ||||||
|                 "User is expired and will be deleted.", user=user, delta=source_delta |                 "User is expired and will be deleted.", user=user, delta=source_delta | ||||||
|             ) |             ) | ||||||
|             # TODO: Check if user is signed in anywhere? |  | ||||||
|             user.delete() |             user.delete() | ||||||
|             deleted_users += 1 |             deleted_users += 1 | ||||||
|     messages.append(f"Successfully deleted {deleted_users} users.") |     messages.append(f"Successfully deleted {deleted_users} users.") | ||||||
|  | |||||||
| @ -90,7 +90,7 @@ class InitiateView(View): | |||||||
|         planner.allow_empty_flows = True |         planner.allow_empty_flows = True | ||||||
|         plan = planner.plan(self.request, kwargs) |         plan = planner.plan(self.request, kwargs) | ||||||
|         for stage in stages_to_append: |         for stage in stages_to_append: | ||||||
|             plan.append(stage) |             plan.append_stage(stage) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ 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 ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet | from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerFilter, OwnerPermissions | from authentik.api.authorization import OwnerFilter, OwnerPermissions | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| @ -94,7 +94,7 @@ class DuoDeviceViewSet( | |||||||
|     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] |     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] | ||||||
|  |  | ||||||
|  |  | ||||||
| class DuoAdminDeviceViewSet(ReadOnlyModelViewSet): | class DuoAdminDeviceViewSet(ModelViewSet): | ||||||
|     """Viewset for Duo authenticator devices (for admins)""" |     """Viewset for Duo authenticator devices (for admins)""" | ||||||
|  |  | ||||||
|     permission_classes = [IsAdminUser] |     permission_classes = [IsAdminUser] | ||||||
|  | |||||||
| @ -1,44 +1,6 @@ | |||||||
| # Generated by Django 3.1.1 on 2020-09-25 14:32 | # Generated by Django 3.1.1 on 2020-09-25 14:32 | ||||||
|  |  | ||||||
| from django.apps.registry import Apps |  | ||||||
| from django.db import migrations | from django.db import migrations | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor |  | ||||||
|  |  | ||||||
| from authentik.flows.models import FlowDesignation |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): |  | ||||||
|     Flow = apps.get_model("authentik_flows", "Flow") |  | ||||||
|     FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") |  | ||||||
|  |  | ||||||
|     AuthenticatorDuoStage = apps.get_model( |  | ||||||
|         "authentik_stages_authenticator_duo", "AuthenticatorDuoStage" |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |  | ||||||
|  |  | ||||||
|     flow, _ = Flow.objects.using(db_alias).update_or_create( |  | ||||||
|         slug="default-authenticator-duo-setup", |  | ||||||
|         designation=FlowDesignation.STAGE_CONFIGURATION, |  | ||||||
|         defaults={ |  | ||||||
|             "name": "default-authenticator-duo-setup", |  | ||||||
|             "title": "Setup Duo", |  | ||||||
|         }, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     stage, _ = AuthenticatorDuoStage.objects.using(db_alias).update_or_create( |  | ||||||
|         name="default-authenticator-duo-setup" |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     FlowStageBinding.objects.using(db_alias).update_or_create( |  | ||||||
|         target=flow, stage=stage, defaults={"order": 0} |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     for stage in AuthenticatorDuoStage.objects.using(db_alias).filter( |  | ||||||
|         configure_flow=None |  | ||||||
|     ): |  | ||||||
|         stage.configure_flow = flow |  | ||||||
|         stage.save() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
| @ -50,6 +12,4 @@ class Migration(migrations.Migration): | |||||||
|         ), |         ), | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     operations = [ |     operations = [] | ||||||
|         migrations.RunPython(create_default_setup_flow), |  | ||||||
|     ] |  | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from django.http import HttpRequest, HttpResponse | |||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.events.models import Event, EventAction | ||||||
| from authentik.flows.challenge import ( | from authentik.flows.challenge import ( | ||||||
|     Challenge, |     Challenge, | ||||||
|     ChallengeResponse, |     ChallengeResponse, | ||||||
| @ -11,6 +12,7 @@ from authentik.flows.challenge import ( | |||||||
| ) | ) | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
|  | from authentik.flows.views import InvalidStageError | ||||||
| from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -42,7 +44,15 @@ class AuthenticatorDuoStageView(ChallengeStageView): | |||||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: |     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         user = self.get_pending_user() |         user = self.get_pending_user() | ||||||
|         stage: AuthenticatorDuoStage = self.executor.current_stage |         stage: AuthenticatorDuoStage = self.executor.current_stage | ||||||
|  |         try: | ||||||
|             enroll = stage.client.enroll(user.username) |             enroll = stage.client.enroll(user.username) | ||||||
|  |         except RuntimeError as exc: | ||||||
|  |             Event.new( | ||||||
|  |                 EventAction.CONFIGURATION_ERROR, | ||||||
|  |                 message=f"Failed to enroll user: {str(exc)}", | ||||||
|  |                 user=user, | ||||||
|  |             ).from_http(self.request, user) | ||||||
|  |             raise InvalidStageError(str(exc)) from exc | ||||||
|         user_id = enroll["user_id"] |         user_id = enroll["user_id"] | ||||||
|         self.request.session[SESSION_KEY_DUO_USER_ID] = user_id |         self.request.session[SESSION_KEY_DUO_USER_ID] = user_id | ||||||
|         self.request.session[SESSION_KEY_DUO_ACTIVATION_CODE] = enroll[ |         self.request.session[SESSION_KEY_DUO_ACTIVATION_CODE] = enroll[ | ||||||
| @ -53,7 +63,7 @@ class AuthenticatorDuoStageView(ChallengeStageView): | |||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|                 "activation_barcode": enroll["activation_barcode"], |                 "activation_barcode": enroll["activation_barcode"], | ||||||
|                 "activation_code": enroll["activation_code"], |                 "activation_code": enroll["activation_code"], | ||||||
|                 "stage_uuid": stage.stage_uuid, |                 "stage_uuid": str(stage.stage_uuid), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -3,4 +3,4 @@ | |||||||
| INSTALLED_APPS = [ | INSTALLED_APPS = [ | ||||||
|     "django_otp.plugins.otp_totp", |     "django_otp.plugins.otp_totp", | ||||||
| ] | ] | ||||||
| OTP_TOTP_ISSUER = "authentik" | OTP_TOTP_ISSUER = "__to_replace__" | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """TOTP Setup stage""" | """TOTP Setup stage""" | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
|  | from django.utils.text import slugify | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from django_otp.plugins.otp_totp.models import TOTPDevice | from django_otp.plugins.otp_totp.models import TOTPDevice | ||||||
| from rest_framework.fields import CharField, IntegerField | from rest_framework.fields import CharField, IntegerField | ||||||
| @ -16,6 +17,7 @@ from authentik.flows.challenge import ( | |||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage | from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage | ||||||
|  | from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| SESSION_TOTP_DEVICE = "totp_device" | SESSION_TOTP_DEVICE = "totp_device" | ||||||
| @ -54,7 +56,9 @@ class AuthenticatorTOTPStageView(ChallengeStageView): | |||||||
|         return AuthenticatorTOTPChallenge( |         return AuthenticatorTOTPChallenge( | ||||||
|             data={ |             data={ | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|                 "config_url": device.config_url, |                 "config_url": device.config_url.replace( | ||||||
|  |                     OTP_TOTP_ISSUER, slugify(self.request.tenant.branding_title) | ||||||
|  |                 ), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ from authentik.flows.challenge import ( | |||||||
|     ChallengeTypes, |     ChallengeTypes, | ||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| from authentik.flows.models import NotConfiguredAction | from authentik.flows.models import NotConfiguredAction, Stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.stages.authenticator_validate.challenge import ( | from authentik.stages.authenticator_validate.challenge import ( | ||||||
| @ -143,9 +143,12 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|                 return self.executor.stage_invalid() |                 return self.executor.stage_invalid() | ||||||
|             if stage.not_configured_action == NotConfiguredAction.CONFIGURE: |             if stage.not_configured_action == NotConfiguredAction.CONFIGURE: | ||||||
|                 LOGGER.debug("Authenticator not configured, sending user to configure") |                 LOGGER.debug("Authenticator not configured, sending user to configure") | ||||||
|  |                 # Because the foreign key to stage.configuration_stage points to | ||||||
|  |                 # a base stage class, we need to do another lookup | ||||||
|  |                 stage = Stage.objects.get_subclass(pk=stage.configuration_stage.pk) | ||||||
|                 # plan.insert inserts at 1 index, so when stage_ok pops 0, |                 # plan.insert inserts at 1 index, so when stage_ok pops 0, | ||||||
|                 # the configuration stage is next |                 # the configuration stage is next | ||||||
|                 self.executor.plan.insert(stage.configuration_stage) |                 self.executor.plan.insert_stage(stage) | ||||||
|                 return self.executor.stage_ok() |                 return self.executor.stage_ok() | ||||||
|         return super().get(request, *args, **kwargs) |         return super().get(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										176
									
								
								authentik/stages/authenticator_validate/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								authentik/stages/authenticator_validate/tests.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,176 @@ | |||||||
|  | """Test validator stage""" | ||||||
|  | from unittest.mock import MagicMock, patch | ||||||
|  |  | ||||||
|  | from django.contrib.sessions.middleware import SessionMiddleware | ||||||
|  | from django.test import TestCase | ||||||
|  | from django.test.client import RequestFactory | ||||||
|  | from django.urls.base import reverse | ||||||
|  | from django.utils.encoding import force_str | ||||||
|  | from django_otp.plugins.otp_totp.models import TOTPDevice | ||||||
|  | from rest_framework.exceptions import ValidationError | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
|  | from authentik.flows.challenge import ChallengeTypes | ||||||
|  | from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction | ||||||
|  | from authentik.flows.tests.test_planner import dummy_get_response | ||||||
|  | from authentik.providers.oauth2.generators import ( | ||||||
|  |     generate_client_id, | ||||||
|  |     generate_client_secret, | ||||||
|  | ) | ||||||
|  | from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | ||||||
|  | from authentik.stages.authenticator_validate.api import ( | ||||||
|  |     AuthenticatorValidateStageSerializer, | ||||||
|  | ) | ||||||
|  | from authentik.stages.authenticator_validate.challenge import ( | ||||||
|  |     get_challenge_for_device, | ||||||
|  |     validate_challenge_code, | ||||||
|  |     validate_challenge_duo, | ||||||
|  |     validate_challenge_webauthn, | ||||||
|  | ) | ||||||
|  | from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage | ||||||
|  | from authentik.stages.authenticator_webauthn.models import WebAuthnDevice | ||||||
|  | from authentik.stages.identification.models import IdentificationStage, UserFields | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AuthenticatorValidateStageTests(TestCase): | ||||||
|  |     """Test validator stage""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.user = User.objects.get(username="akadmin") | ||||||
|  |         self.request_factory = RequestFactory() | ||||||
|  |  | ||||||
|  |     def test_not_configured_action(self): | ||||||
|  |         """Test not_configured_action""" | ||||||
|  |         conf_stage = IdentificationStage.objects.create( | ||||||
|  |             name="conf", | ||||||
|  |             user_fields=[ | ||||||
|  |                 UserFields.USERNAME, | ||||||
|  |             ], | ||||||
|  |         ) | ||||||
|  |         stage = AuthenticatorValidateStage.objects.create( | ||||||
|  |             name="foo", | ||||||
|  |             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||||
|  |             configuration_stage=conf_stage, | ||||||
|  |         ) | ||||||
|  |         flow = Flow.objects.create(name="test", slug="test", title="test") | ||||||
|  |         FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) | ||||||
|  |         FlowStageBinding.objects.create(target=flow, stage=stage, order=1) | ||||||
|  |  | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |             {"uid_field": "akadmin"}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |             follow=True, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             force_str(response.content), | ||||||
|  |             { | ||||||
|  |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|  |                 "component": "ak-stage-identification", | ||||||
|  |                 "password_fields": False, | ||||||
|  |                 "primary_action": "Log in", | ||||||
|  |                 "flow_info": { | ||||||
|  |                     "background": flow.background_url, | ||||||
|  |                     "cancel_url": reverse("authentik_flows:cancel"), | ||||||
|  |                     "title": flow.title, | ||||||
|  |                 }, | ||||||
|  |                 "user_fields": ["username"], | ||||||
|  |                 "sources": [], | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_stage_validation(self): | ||||||
|  |         """Test serializer validation""" | ||||||
|  |         self.client.force_login(self.user) | ||||||
|  |         serializer = AuthenticatorValidateStageSerializer( | ||||||
|  |             data={"name": "foo", "not_configured_action": NotConfiguredAction.CONFIGURE} | ||||||
|  |         ) | ||||||
|  |         self.assertFalse(serializer.is_valid()) | ||||||
|  |         self.assertIn("not_configured_action", serializer.errors) | ||||||
|  |  | ||||||
|  |     def test_device_challenge_totp(self): | ||||||
|  |         """Test device challenge""" | ||||||
|  |         request = self.request_factory.get("/") | ||||||
|  |         totp_device = TOTPDevice.objects.create( | ||||||
|  |             user=self.user, confirmed=True, digits=6 | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(get_challenge_for_device(request, totp_device), {}) | ||||||
|  |         with self.assertRaises(ValidationError): | ||||||
|  |             validate_challenge_code("1234", request, self.user) | ||||||
|  |  | ||||||
|  |     def test_device_challenge_webauthn(self): | ||||||
|  |         """Test webauthn""" | ||||||
|  |         request = self.request_factory.get("/") | ||||||
|  |         request.user = self.user | ||||||
|  |         middleware = SessionMiddleware(dummy_get_response) | ||||||
|  |         middleware.process_request(request) | ||||||
|  |         request.session.save() | ||||||
|  |  | ||||||
|  |         webauthn_device = WebAuthnDevice.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             public_key="qwerqwerqre", | ||||||
|  |             credential_id="foobarbaz", | ||||||
|  |             sign_count=0, | ||||||
|  |             rp_id="foo", | ||||||
|  |         ) | ||||||
|  |         challenge = get_challenge_for_device(request, webauthn_device) | ||||||
|  |         del challenge["challenge"] | ||||||
|  |         self.assertEqual( | ||||||
|  |             challenge, | ||||||
|  |             { | ||||||
|  |                 "allowCredentials": [ | ||||||
|  |                     { | ||||||
|  |                         "id": "foobarbaz", | ||||||
|  |                         "transports": ["usb", "nfc", "ble", "internal"], | ||||||
|  |                         "type": "public-key", | ||||||
|  |                     } | ||||||
|  |                 ], | ||||||
|  |                 "rpId": "foo", | ||||||
|  |                 "timeout": 60000, | ||||||
|  |                 "userVerification": "discouraged", | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         with self.assertRaises(ValidationError): | ||||||
|  |             validate_challenge_webauthn({}, request, self.user) | ||||||
|  |  | ||||||
|  |     def test_device_challenge_duo(self): | ||||||
|  |         """Test duo""" | ||||||
|  |         request = self.request_factory.get("/") | ||||||
|  |         stage = AuthenticatorDuoStage.objects.create( | ||||||
|  |             name="test", | ||||||
|  |             client_id=generate_client_id(), | ||||||
|  |             client_secret=generate_client_secret(), | ||||||
|  |             api_hostname="", | ||||||
|  |         ) | ||||||
|  |         duo_device = DuoDevice.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             stage=stage, | ||||||
|  |         ) | ||||||
|  |         duo_mock = MagicMock( | ||||||
|  |             auth=MagicMock( | ||||||
|  |                 return_value={ | ||||||
|  |                     "result": "allow", | ||||||
|  |                     "status": "allow", | ||||||
|  |                     "status_msg": "Success. Logging you in...", | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         failed_duo_mock = MagicMock(auth=MagicMock(return_value={"result": "deny"})) | ||||||
|  |         with patch( | ||||||
|  |             "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", | ||||||
|  |             duo_mock, | ||||||
|  |         ): | ||||||
|  |             self.assertEqual( | ||||||
|  |                 duo_device.pk, validate_challenge_duo(duo_device.pk, request, self.user) | ||||||
|  |             ) | ||||||
|  |         with patch( | ||||||
|  |             "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", | ||||||
|  |             failed_duo_mock, | ||||||
|  |         ): | ||||||
|  |             with self.assertRaises(ValidationError): | ||||||
|  |                 validate_challenge_duo(duo_device.pk, request, self.user) | ||||||
| @ -28,8 +28,6 @@ from authentik.stages.authenticator_webauthn.utils import ( | |||||||
|     get_rp_id, |     get_rp_id, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| RP_NAME = "authentik" |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| SESSION_KEY_WEBAUTHN_AUTHENTICATED = ( | SESSION_KEY_WEBAUTHN_AUTHENTICATED = ( | ||||||
| @ -119,7 +117,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | |||||||
|         user = self.get_pending_user() |         user = self.get_pending_user() | ||||||
|         make_credential_options = WebAuthnMakeCredentialOptions( |         make_credential_options = WebAuthnMakeCredentialOptions( | ||||||
|             challenge, |             challenge, | ||||||
|             RP_NAME, |             self.request.tenant.branding_title, | ||||||
|             get_rp_id(self.request), |             get_rp_id(self.request), | ||||||
|             user.uid, |             user.uid, | ||||||
|             user.username, |             user.username, | ||||||
|  | |||||||
| @ -36,12 +36,14 @@ class TestCaptchaStage(TestCase): | |||||||
|             public_key=RECAPTCHA_PUBLIC_KEY, |             public_key=RECAPTCHA_PUBLIC_KEY, | ||||||
|             private_key=RECAPTCHA_PRIVATE_KEY, |             private_key=RECAPTCHA_PRIVATE_KEY, | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) |         self.binding = FlowStageBinding.objects.create( | ||||||
|  |             target=self.flow, stage=self.stage, order=2 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_valid(self): |     def test_valid(self): | ||||||
|         """Test valid captcha""" |         """Test valid captcha""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|  | |||||||
| @ -39,9 +39,11 @@ class TestConsentStage(TestCase): | |||||||
|         stage = ConsentStage.objects.create( |         stage = ConsentStage.objects.create( | ||||||
|             name="consent", mode=ConsentMode.ALWAYS_REQUIRE |             name="consent", mode=ConsentMode.ALWAYS_REQUIRE | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=flow, stage=stage, order=2) |         binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) | ||||||
|  |  | ||||||
|         plan = FlowPlan(flow_pk=flow.pk.hex, stages=[stage], markers=[StageMarker()]) |         plan = FlowPlan( | ||||||
|  |             flow_pk=flow.pk.hex, bindings=[binding], markers=[StageMarker()] | ||||||
|  |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|         session.save() |         session.save() | ||||||
| @ -69,11 +71,11 @@ class TestConsentStage(TestCase): | |||||||
|             designation=FlowDesignation.AUTHENTICATION, |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|         ) |         ) | ||||||
|         stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) |         stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) | ||||||
|         FlowStageBinding.objects.create(target=flow, stage=stage, order=2) |         binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) | ||||||
|  |  | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=flow.pk.hex, |             flow_pk=flow.pk.hex, | ||||||
|             stages=[stage], |             bindings=[binding], | ||||||
|             markers=[StageMarker()], |             markers=[StageMarker()], | ||||||
|             context={PLAN_CONTEXT_APPLICATION: self.application}, |             context={PLAN_CONTEXT_APPLICATION: self.application}, | ||||||
|         ) |         ) | ||||||
| @ -110,11 +112,11 @@ class TestConsentStage(TestCase): | |||||||
|         stage = ConsentStage.objects.create( |         stage = ConsentStage.objects.create( | ||||||
|             name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" |             name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=flow, stage=stage, order=2) |         binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2) | ||||||
|  |  | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=flow.pk.hex, |             flow_pk=flow.pk.hex, | ||||||
|             stages=[stage], |             bindings=[binding], | ||||||
|             markers=[StageMarker()], |             markers=[StageMarker()], | ||||||
|             context={PLAN_CONTEXT_APPLICATION: self.application}, |             context={PLAN_CONTEXT_APPLICATION: self.application}, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -26,12 +26,14 @@ class TestUserDenyStage(TestCase): | |||||||
|             designation=FlowDesignation.AUTHENTICATION, |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|         ) |         ) | ||||||
|         self.stage = DenyStage.objects.create(name="logout") |         self.stage = DenyStage.objects.create(name="logout") | ||||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) |         self.binding = FlowStageBinding.objects.create( | ||||||
|  |             target=self.flow, stage=self.stage, order=2 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_valid_password(self): |     def test_valid_password(self): | ||||||
|         """Test with a valid pending user and backend""" |         """Test with a valid pending user and backend""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|  | |||||||
| @ -34,12 +34,14 @@ class TestEmailStageSending(TestCase): | |||||||
|         self.stage = EmailStage.objects.create( |         self.stage = EmailStage.objects.create( | ||||||
|             name="email", |             name="email", | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) |         self.binding = FlowStageBinding.objects.create( | ||||||
|  |             target=self.flow, stage=self.stage, order=2 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_pending_user(self): |     def test_pending_user(self): | ||||||
|         """Test with pending user""" |         """Test with pending user""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
| @ -67,7 +69,7 @@ class TestEmailStageSending(TestCase): | |||||||
|     def test_send_error(self): |     def test_send_error(self): | ||||||
|         """Test error during sending (sending will be retried)""" |         """Test error during sending (sending will be retried)""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|  | |||||||
| @ -35,12 +35,14 @@ class TestEmailStage(TestCase): | |||||||
|         self.stage = EmailStage.objects.create( |         self.stage = EmailStage.objects.create( | ||||||
|             name="email", |             name="email", | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) |         self.binding = FlowStageBinding.objects.create( | ||||||
|  |             target=self.flow, stage=self.stage, order=2 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_rendering(self): |     def test_rendering(self): | ||||||
|         """Test with pending user""" |         """Test with pending user""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
| @ -56,7 +58,7 @@ class TestEmailStage(TestCase): | |||||||
|     def test_without_user(self): |     def test_without_user(self): | ||||||
|         """Test without pending user""" |         """Test without pending user""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
| @ -71,7 +73,7 @@ class TestEmailStage(TestCase): | |||||||
|     def test_pending_user(self): |     def test_pending_user(self): | ||||||
|         """Test with pending user""" |         """Test with pending user""" | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user |         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
| @ -102,7 +104,7 @@ class TestEmailStage(TestCase): | |||||||
|         # Make sure token exists |         # Make sure token exists | ||||||
|         self.test_pending_user() |         self.test_pending_user() | ||||||
|         plan = FlowPlan( |         plan = FlowPlan( | ||||||
|             flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] |             flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()] | ||||||
|         ) |         ) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
|         session[SESSION_KEY_PLAN] = plan |         session[SESSION_KEY_PLAN] = plan | ||||||
|  | |||||||
| @ -0,0 +1,31 @@ | |||||||
|  | # Generated by Django 3.2.4 on 2021-06-14 15:32 | ||||||
|  |  | ||||||
|  | import django.contrib.postgres.fields | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_stages_identification", "0010_identificationstage_password_stage"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="identificationstage", | ||||||
|  |             name="user_fields", | ||||||
|  |             field=django.contrib.postgres.fields.ArrayField( | ||||||
|  |                 base_field=models.CharField( | ||||||
|  |                     choices=[ | ||||||
|  |                         ("email", "E Mail"), | ||||||
|  |                         ("username", "Username"), | ||||||
|  |                         ("upn", "Upn"), | ||||||
|  |                     ], | ||||||
|  |                     max_length=100, | ||||||
|  |                 ), | ||||||
|  |                 blank=True, | ||||||
|  |                 help_text="Fields of the user object to match against. (Hold shift to select multiple options)", | ||||||
|  |                 size=None, | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -17,6 +17,7 @@ class UserFields(models.TextChoices): | |||||||
|  |  | ||||||
|     E_MAIL = "email" |     E_MAIL = "email" | ||||||
|     USERNAME = "username" |     USERNAME = "username" | ||||||
|  |     UPN = "upn" | ||||||
|  |  | ||||||
|  |  | ||||||
| class IdentificationStage(Stage): | class IdentificationStage(Stage): | ||||||
|  | |||||||
| @ -8,19 +8,20 @@ from django.db.models import Q | |||||||
| from django.http import HttpResponse | from django.http import HttpResponse | ||||||
| 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 rest_framework.fields import BooleanField, CharField, ListField | from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field | ||||||
|  | from rest_framework.fields import BooleanField, CharField, DictField, ListField | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import Application, Source, User | from authentik.core.models import Application, Source, User | ||||||
| from authentik.core.types import UILoginButtonSerializer |  | ||||||
| from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes | from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
| from authentik.flows.stage import ( | from authentik.flows.stage import ( | ||||||
|     PLAN_CONTEXT_PENDING_USER_IDENTIFIER, |     PLAN_CONTEXT_PENDING_USER_IDENTIFIER, | ||||||
|     ChallengeStageView, |     ChallengeStageView, | ||||||
| ) | ) | ||||||
| from authentik.flows.views import SESSION_KEY_APPLICATION_PRE | from authentik.flows.views import SESSION_KEY_APPLICATION_PRE, challenge_types | ||||||
| from authentik.stages.identification.models import IdentificationStage | from authentik.stages.identification.models import IdentificationStage | ||||||
| from authentik.stages.identification.signals import identification_failed | from authentik.stages.identification.signals import identification_failed | ||||||
| from authentik.stages.password.stage import authenticate | from authentik.stages.password.stage import authenticate | ||||||
| @ -28,6 +29,26 @@ from authentik.stages.password.stage import authenticate | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @extend_schema_field( | ||||||
|  |     PolymorphicProxySerializer( | ||||||
|  |         component_name="ChallengeTypes", | ||||||
|  |         serializers=challenge_types(), | ||||||
|  |         resource_type_field_name="component", | ||||||
|  |     ) | ||||||
|  | ) | ||||||
|  | class ChallengeDictWrapper(DictField): | ||||||
|  |     """Wrapper around DictField that annotates itself as challenge proxy""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LoginSourceSerializer(PassiveSerializer): | ||||||
|  |     """Serializer for Login buttons of sources""" | ||||||
|  |  | ||||||
|  |     name = CharField() | ||||||
|  |     icon_url = CharField(required=False, allow_null=True) | ||||||
|  |  | ||||||
|  |     challenge = ChallengeDictWrapper() | ||||||
|  |  | ||||||
|  |  | ||||||
| class IdentificationChallenge(Challenge): | class IdentificationChallenge(Challenge): | ||||||
|     """Identification challenges with all UI elements""" |     """Identification challenges with all UI elements""" | ||||||
|  |  | ||||||
| @ -38,7 +59,7 @@ class IdentificationChallenge(Challenge): | |||||||
|     enroll_url = CharField(required=False) |     enroll_url = CharField(required=False) | ||||||
|     recovery_url = CharField(required=False) |     recovery_url = CharField(required=False) | ||||||
|     primary_action = CharField() |     primary_action = CharField() | ||||||
|     sources = UILoginButtonSerializer(many=True, required=False) |     sources = LoginSourceSerializer(many=True, required=False) | ||||||
|  |  | ||||||
|     component = CharField(default="ak-stage-identification") |     component = CharField(default="ak-stage-identification") | ||||||
|  |  | ||||||
| @ -64,6 +85,18 @@ class IdentificationChallengeResponse(ChallengeResponse): | |||||||
|             identification_failed.send( |             identification_failed.send( | ||||||
|                 sender=self, request=self.stage.request, uid_field=uid_field |                 sender=self, request=self.stage.request, uid_field=uid_field | ||||||
|             ) |             ) | ||||||
|  |             # We set the pending_user even on failure so it's part of the context, even | ||||||
|  |             # when the input is invalid | ||||||
|  |             # This is so its part of the current flow plan, and on flow restart can be kept, and | ||||||
|  |             # policies can be applied. | ||||||
|  |             self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User( | ||||||
|  |                 username=uid_field, | ||||||
|  |                 email=uid_field, | ||||||
|  |             ) | ||||||
|  |             if not current_stage.show_matched_user: | ||||||
|  |                 self.stage.executor.plan.context[ | ||||||
|  |                     PLAN_CONTEXT_PENDING_USER_IDENTIFIER | ||||||
|  |                 ] = uid_field | ||||||
|             raise ValidationError("Failed to authenticate.") |             raise ValidationError("Failed to authenticate.") | ||||||
|         self.pre_user = pre_user |         self.pre_user = pre_user | ||||||
|         if not current_stage.password_stage: |         if not current_stage.password_stage: | ||||||
| @ -96,7 +129,11 @@ class IdentificationStageView(ChallengeStageView): | |||||||
|         current_stage: IdentificationStage = self.executor.current_stage |         current_stage: IdentificationStage = self.executor.current_stage | ||||||
|         query = Q() |         query = Q() | ||||||
|         for search_field in current_stage.user_fields: |         for search_field in current_stage.user_fields: | ||||||
|             model_field = search_field |             model_field = { | ||||||
|  |                 "email": "email", | ||||||
|  |                 "username": "username", | ||||||
|  |                 "upn": "attributes__upn", | ||||||
|  |             }[search_field] | ||||||
|             if current_stage.case_insensitive_matching: |             if current_stage.case_insensitive_matching: | ||||||
|                 model_field += "__iexact" |                 model_field += "__iexact" | ||||||
|             else: |             else: | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	