Compare commits
	
		
			417 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8e19fb3a8c | |||
| 0448dcf655 | |||
| b8f74ab9e7 | |||
| 501ce5cebb | |||
| b896ca7ef6 | |||
| d497db3010 | |||
| 24f95fdeaa | |||
| d1c4818724 | |||
| 9f736a9d99 | |||
| 49cce6a968 | |||
| 713337130b | |||
| 0a73e7ac9f | |||
| 3344af72c2 | |||
| 41eb44137e | |||
| 94a9667d86 | |||
| 8b56a7defb | |||
| 5a4b9b4239 | |||
| f37308461c | |||
| 9721098178 | |||
| 0ca5e67dad | |||
| da94564d5e | |||
| 1f33237659 | |||
| 62e5979c13 | |||
| 8a1e18e087 | |||
| a951daddce | |||
| 690f6d444a | |||
| b733930745 | |||
| f316a3000b | |||
| ddae9dc6e1 | |||
| 0348d6558a | |||
| 6a497b32f6 | |||
| 47acc0ea90 | |||
| 967c952a4a | |||
| b648d159dd | |||
| aecd9387d9 | |||
| 6e8a5e1426 | |||
| 607899be56 | |||
| 5a92a8639a | |||
| 4cd629b5fc | |||
| 6020736430 | |||
| 14a4047bdd | |||
| 23c1e22a04 | |||
| 2a2ae4bc4f | |||
| 5f4812e1d0 | |||
| 3ab475d916 | |||
| 453d64eea5 | |||
| 17d33f4b19 | |||
| c39a5933e1 | |||
| a9636b5727 | |||
| 5e3f44dd87 | |||
| 1c64616ebd | |||
| 23273f53cc | |||
| d11ce0a86e | |||
| 766ceda57a | |||
| eb633c607e | |||
| c72d56d02d | |||
| e758c434ea | |||
| 90e3ae9457 | |||
| 0e825ffcfd | |||
| 8a19c71f62 | |||
| 5a7eff041a | |||
| 552459834a | |||
| cc6325bf6a | |||
| 9597ea9e1f | |||
| 69b5670659 | |||
| 56fd436e5d | |||
| b7558ae28c | |||
| ea60c389be | |||
| f6042f29f6 | |||
| 983882f5a0 | |||
| a6d3fd92df | |||
| 96f39904b8 | |||
| ee347aa7ef | |||
| 6437334e67 | |||
| 2f57d7f427 | |||
| db07f564aa | |||
| d1479a1b16 | |||
| 4d80e207da | |||
| e7be7ac9b4 | |||
| e0954c0f89 | |||
| 7ae061909c | |||
| 45a806f46b | |||
| feb6b07657 | |||
| 1d98582d29 | |||
| 06663edba2 | |||
| de0d1dc94d | |||
| 1652ea25e4 | |||
| d794e3055c | |||
| a92c68ac85 | |||
| dd41789230 | |||
| 022401b60e | |||
| ef218ff1ff | |||
| f933bf2f40 | |||
| 4fc761adea | |||
| d11c214d32 | |||
| ffbbe5ca5f | |||
| 8582091219 | |||
| 28c8eb3ee6 | |||
| 3a00a5ac3d | |||
| 20035e0f1b | |||
| 67021b0e7c | |||
| c5a2831665 | |||
| 768f073e49 | |||
| 504338ea66 | |||
| a8c04f96d2 | |||
| 340faf5341 | |||
| a76c39aff9 | |||
| bb728a53cc | |||
| 5c28a7dd44 | |||
| e1efb47543 | |||
| e50a296a18 | |||
| e211265c30 | |||
| 1f143a24db | |||
| 48f490b810 | |||
| aed382de0c | |||
| 8ecf40a58b | |||
| aca3c75e17 | |||
| f28509608b | |||
| ff6c508de7 | |||
| 7319ea2dcf | |||
| 6a4efaecb0 | |||
| 29b0eae43f | |||
| 9f3e742fb1 | |||
| c8e09fea33 | |||
| 437e932471 | |||
| ce07d71d23 | |||
| 9815c591e0 | |||
| db7a3ab630 | |||
| 3fa772c32e | |||
| 6c9dc7a15b | |||
| ece0429ea8 | |||
| d56ddb16b1 | |||
| b6267fdf28 | |||
| 1f190a9255 | |||
| 1f0fc0a6a2 | |||
| 3ba678851e | |||
| 0869ef3d0d | |||
| 91100ce1e2 | |||
| a65ce47736 | |||
| def17bbc1e | |||
| eb7da8f414 | |||
| 9201fc1834 | |||
| 5385feb428 | |||
| c6f29d9eb4 | |||
| db557401aa | |||
| c824af5bc3 | |||
| 1faba11a57 | |||
| f0c72e8536 | |||
| 91f91b08e5 | |||
| 8faa909c32 | |||
| 49142fa80b | |||
| 2a6fccd22a | |||
| 1d10afa209 | |||
| 4b7c3c38cd | |||
| 440cacbafe | |||
| b33bff92ee | |||
| caed306346 | |||
| d0eb6af7e9 | |||
| ec5ed67f6c | |||
| 59b899ddff | |||
| 85784f796c | |||
| 4c0e19cbea | |||
| b42eb9464f | |||
| 6559fdee15 | |||
| 3455bf3d27 | |||
| 0d96e68c1e | |||
| 29d3db5112 | |||
| cdf88e4477 | |||
| 7caac1d0c7 | |||
| 45364d6553 | |||
| 2298eb124f | |||
| 6dff1f8e5e | |||
| a944701f3a | |||
| 23866fe459 | |||
| 0a83b04419 | |||
| e6ecdf8b1d | |||
| 2d48fe42f4 | |||
| 5894ccdaf2 | |||
| 79bec6f6b2 | |||
| 9610f96c11 | |||
| 36a326cd81 | |||
| c0c222a0b8 | |||
| e17f7020e6 | |||
| 6d9579d3e6 | |||
| 9f15ee8cb8 | |||
| e892ed14da | |||
| 093a67525a | |||
| 1c62a3db6e | |||
| c4b4c7134d | |||
| 82cb6d41b8 | |||
| 423380d987 | |||
| 175d97fdcf | |||
| 5dbbf970b0 | |||
| 1541d477af | |||
| d745331654 | |||
| defbdc5068 | |||
| 350f0d8365 | |||
| b5c93fb3e3 | |||
| 5be45ebf8e | |||
| ad8fe9fe81 | |||
| c2f7edaa42 | |||
| 6821402fef | |||
| 8dbb0bd2c6 | |||
| 24a21c1761 | |||
| 0cad56ec73 | |||
| 4d8021c403 | |||
| 6573cbb16c | |||
| bdf76bb4b7 | |||
| 74ce9cc6fd | |||
| 070a6d866e | |||
| 5e2d647a6c | |||
| 7beebe030d | |||
| 66f4a31b4c | |||
| beddd6a460 | |||
| faec866581 | |||
| effed50cc1 | |||
| 38ad6096ad | |||
| bd53042553 | |||
| 039d896dee | |||
| ff2baf502b | |||
| 3b182ca223 | |||
| 8da8890a8e | |||
| 23023ec727 | |||
| 7d84a71a01 | |||
| 192001f193 | |||
| 63734682d2 | |||
| a0cd2d55f8 | |||
| a72c7adfc0 | |||
| e88e02ec85 | |||
| f7661c8bbd | |||
| 9add8479ca | |||
| 4c39e08dd4 | |||
| 44ce2ebece | |||
| f5a8859d00 | |||
| 9ef0e8bc5f | |||
| 60eeafd111 | |||
| 6f3d6efa22 | |||
| 8d3275817b | |||
| ca40d31dac | |||
| 438aac8879 | |||
| 2dfa6c2c82 | |||
| c11435780d | |||
| ee54328589 | |||
| 817d538b8f | |||
| 210775776f | |||
| 2a4ce75bc4 | |||
| b26111fb42 | |||
| e30103aa9f | |||
| dc9203789e | |||
| d70ce2776f | |||
| ad7d65e903 | |||
| 67d54c5209 | |||
| bb244b8338 | |||
| fa04883ac1 | |||
| 6739ded5a9 | |||
| 9a7e5d934e | |||
| 6dc6d19d2d | |||
| 36cbc44ed6 | |||
| 0c591a50e3 | |||
| 7ee655a318 | |||
| 8447e9b9c2 | |||
| 09f92e5bad | |||
| f9a419107a | |||
| 8f0572d11e | |||
| 7ebf793953 | |||
| 63783ee77b | |||
| eba339ba27 | |||
| 0adb5a79f6 | |||
| fa81adf254 | |||
| 558c7bba2a | |||
| 8cd1a42fb9 | |||
| 8cf0e78aa0 | |||
| 3f69a57013 | |||
| f7f12cab10 | |||
| cacaa378c8 | |||
| 33fe85eb96 | |||
| a9744cbf48 | |||
| b91d8a676c | |||
| f19cd1c003 | |||
| 65341cecd0 | |||
| c0cb891078 | |||
| fc1c1a849a | |||
| 5a81ae956f | |||
| 0cac034512 | |||
| 5666995a15 | |||
| 8d3059e4f3 | |||
| a90dc34494 | |||
| 2c6d82593e | |||
| 34bcc2df1a | |||
| c00f2907ea | |||
| b4d528a789 | |||
| d9172cb296 | |||
| bee36cde59 | |||
| d4e7d9d64a | |||
| 7b0265207a | |||
| 7c076579fd | |||
| 7171706d7f | |||
| 9cd46ecbeb | |||
| 5f09ba675d | |||
| 630b926e2a | |||
| 9c6be60ad9 | |||
| a0397fdcf4 | |||
| 59e13e8026 | |||
| 374b51e956 | |||
| 8faa1bf865 | |||
| fc75867218 | |||
| 6d94c2c925 | |||
| eb51dd1379 | |||
| 13a4559c37 | |||
| 4fcf7285d7 | |||
| 0ba9f25155 | |||
| 453c751c7f | |||
| d1eaaef254 | |||
| 3eb466ff4b | |||
| 9f2529c886 | |||
| fb25b28976 | |||
| 612163b82f | |||
| 3c43690a96 | |||
| dd74565c7b | |||
| fb69f67f47 | |||
| 18b48684eb | |||
| 098b0aef6e | |||
| 4ed8171130 | |||
| 335131affc | |||
| bba17a8a67 | |||
| 082df0ec51 | |||
| 1883402b3d | |||
| 88a8b7d2fa | |||
| 987f03c4be | |||
| 1b3aacfa1d | |||
| a03dde8a90 | |||
| 5f04a187ea | |||
| 2b68363452 | |||
| 3a994ab2a4 | |||
| d7713357f4 | |||
| e7c03fdb14 | |||
| 6105956847 | |||
| 89028f175a | |||
| f121098957 | |||
| 4ff32af343 | |||
| 972868c15c | |||
| 0bc57f571b | |||
| 9de5b6f93e | |||
| acf1ded1d4 | |||
| a286f999e2 | |||
| 4b6c1da51d | |||
| a81d5a3d41 | |||
| 4d17111233 | |||
| 64cb9812e0 | |||
| ed037b2e3a | |||
| d2be6a8e3a | |||
| a9667eb0f4 | |||
| 7f3988f3c9 | |||
| 4c095a6f2a | |||
| c10b5c3c8c | |||
| 9d920580a1 | |||
| 34ef4af799 | |||
| 5da47b69dd | |||
| 0e0dd2437b | |||
| e42386b150 | |||
| f21f81022e | |||
| e73a468921 | |||
| c0ac053380 | |||
| 4e670295d1 | |||
| 8d7d8d613c | |||
| 4d632a8679 | |||
| ef219198d4 | |||
| cc744dc581 | |||
| 47006fc9d2 | |||
| ada53362d5 | |||
| a03e48c5ce | |||
| 816b0c7d83 | |||
| a6398f46da | |||
| 56babb2649 | |||
| 0edf4296c4 | |||
| b8fdda50ec | |||
| d25a051eae | |||
| 4a9b788703 | |||
| d4ef321ac2 | |||
| 80c1dbdfbb | |||
| b0af062d74 | |||
| b4e75218f5 | |||
| ab1840dd66 | |||
| 482491e93c | |||
| 2ca991ba3d | |||
| b20c384f5a | |||
| 9ce8edbcd6 | |||
| cb5b2148a3 | |||
| d5702c6282 | |||
| 61a876b582 | |||
| 8c9748e4a0 | |||
| 6460245d5e | |||
| b7979ad48e | |||
| cbd95848e7 | |||
| 4704de937a | |||
| 394d8e99a4 | |||
| a26f25ccd6 | |||
| 94257e0f50 | |||
| b2a42a68a4 | |||
| 7895d59da3 | |||
| b54c60d7af | |||
| 6bab3bf68e | |||
| fdc09c658a | |||
| a690a02f99 | |||
| 0e912fd647 | |||
| 27af330932 | |||
| 7187d28905 | |||
| ca832b6090 | |||
| 53bd6bf06e | |||
| 813f271bdd | |||
| 63dc8fe7dc | |||
| 383f4e4dcf | |||
| 2896652fef | |||
| cfe2648b62 | |||
| 8d49705c87 | |||
| c99e6d8f2c | |||
| 0996bb500c | 
| @ -1,30 +1,18 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2022.5.2 | current_version = 2022.7.2 | ||||||
| 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+) | ||||||
| serialize =  | serialize = {major}.{minor}.{patch} | ||||||
| 	{major}.{minor}.{patch}-{release} |  | ||||||
| 	{major}.{minor}.{patch} |  | ||||||
| message = release: {new_version} | message = release: {new_version} | ||||||
| tag_name = version/{new_version} | tag_name = version/{new_version} | ||||||
|  |  | ||||||
| [bumpversion:part:release] |  | ||||||
| optional_value = stable |  | ||||||
| first_value = beta |  | ||||||
| values =  |  | ||||||
| 	alpha |  | ||||||
| 	beta |  | ||||||
| 	stable |  | ||||||
|  |  | ||||||
| [bumpversion:file:pyproject.toml] | [bumpversion:file:pyproject.toml] | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:schema.yml] | [bumpversion:file:schema.yml] | ||||||
|  |  | ||||||
| [bumpversion:file:.github/workflows/release-publish.yml] |  | ||||||
|  |  | ||||||
| [bumpversion:file:authentik/__init__.py] | [bumpversion:file:authentik/__init__.py] | ||||||
|  |  | ||||||
| [bumpversion:file:internal/constants/constants.go] | [bumpversion:file:internal/constants/constants.go] | ||||||
|  | |||||||
| @ -17,6 +17,12 @@ outputs: | |||||||
|   sha: |   sha: | ||||||
|     description: "sha" |     description: "sha" | ||||||
|     value: ${{ steps.ev.outputs.sha }} |     value: ${{ steps.ev.outputs.sha }} | ||||||
|  |   version: | ||||||
|  |     description: "version" | ||||||
|  |     value: ${{ steps.ev.outputs.version }} | ||||||
|  |   versionFamily: | ||||||
|  |     description: "versionFamily" | ||||||
|  |     value: ${{ steps.ev.outputs.versionFamily }} | ||||||
| 
 | 
 | ||||||
| runs: | runs: | ||||||
|   using: "composite" |   using: "composite" | ||||||
| @ -47,3 +53,11 @@ runs: | |||||||
|         print("##[set-output name=timestamp]%s" % int(time())) |         print("##[set-output name=timestamp]%s" % int(time())) | ||||||
|         print("##[set-output name=sha]%s" % os.environ[sha]) |         print("##[set-output name=sha]%s" % os.environ[sha]) | ||||||
|         print("##[set-output name=shouldBuild]%s" % should_build) |         print("##[set-output name=shouldBuild]%s" % should_build) | ||||||
|  | 
 | ||||||
|  |         import configparser | ||||||
|  |         parser = configparser.ConfigParser() | ||||||
|  |         parser.read(".bumpversion.cfg") | ||||||
|  |         version = parser.get("bumpversion", "current_version") | ||||||
|  |         version_family = ".".join(version.split(".")[:-1]) | ||||||
|  |         print("##[set-output name=version]%s" % version) | ||||||
|  |         print("##[set-output name=versionFamily]%s" % version_family) | ||||||
							
								
								
									
										48
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										48
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,50 +1,62 @@ | |||||||
| version: 2 | version: 2 | ||||||
| updates: | updates: | ||||||
| - package-ecosystem: "github-actions" |     - package-ecosystem: "github-actions" | ||||||
|       directory: "/" |       directory: "/" | ||||||
|       schedule: |       schedule: | ||||||
|           interval: daily |           interval: daily | ||||||
|           time: "04:00" |           time: "04:00" | ||||||
|       open-pull-requests-limit: 10 |       open-pull-requests-limit: 10 | ||||||
|   assignees: |       reviewers: | ||||||
|   - BeryJu |           - "@goauthentik/core" | ||||||
| - package-ecosystem: gomod |       commit-message: | ||||||
|  |           prefix: "ci:" | ||||||
|  |     - package-ecosystem: gomod | ||||||
|       directory: "/" |       directory: "/" | ||||||
|       schedule: |       schedule: | ||||||
|           interval: daily |           interval: daily | ||||||
|           time: "04:00" |           time: "04:00" | ||||||
|       open-pull-requests-limit: 10 |       open-pull-requests-limit: 10 | ||||||
|   assignees: |       reviewers: | ||||||
|   - BeryJu |           - "@goauthentik/core" | ||||||
| - package-ecosystem: npm |       commit-message: | ||||||
|  |           prefix: "core:" | ||||||
|  |     - package-ecosystem: npm | ||||||
|       directory: "/web" |       directory: "/web" | ||||||
|       schedule: |       schedule: | ||||||
|           interval: daily |           interval: daily | ||||||
|           time: "04:00" |           time: "04:00" | ||||||
|       open-pull-requests-limit: 10 |       open-pull-requests-limit: 10 | ||||||
|   assignees: |       reviewers: | ||||||
|   - BeryJu |           - "@goauthentik/core" | ||||||
| - package-ecosystem: npm |       commit-message: | ||||||
|  |           prefix: "web:" | ||||||
|  |     - package-ecosystem: npm | ||||||
|       directory: "/website" |       directory: "/website" | ||||||
|       schedule: |       schedule: | ||||||
|           interval: daily |           interval: daily | ||||||
|           time: "04:00" |           time: "04:00" | ||||||
|       open-pull-requests-limit: 10 |       open-pull-requests-limit: 10 | ||||||
|   assignees: |       reviewers: | ||||||
|   - BeryJu |           - "@goauthentik/core" | ||||||
| - package-ecosystem: pip |       commit-message: | ||||||
|  |           prefix: "website:" | ||||||
|  |     - package-ecosystem: pip | ||||||
|       directory: "/" |       directory: "/" | ||||||
|       schedule: |       schedule: | ||||||
|           interval: daily |           interval: daily | ||||||
|           time: "04:00" |           time: "04:00" | ||||||
|       open-pull-requests-limit: 10 |       open-pull-requests-limit: 10 | ||||||
|   assignees: |       reviewers: | ||||||
|   - BeryJu |           - "@goauthentik/core" | ||||||
| - package-ecosystem: docker |       commit-message: | ||||||
|  |           prefix: "core:" | ||||||
|  |     - package-ecosystem: docker | ||||||
|       directory: "/" |       directory: "/" | ||||||
|       schedule: |       schedule: | ||||||
|           interval: daily |           interval: daily | ||||||
|           time: "04:00" |           time: "04:00" | ||||||
|       open-pull-requests-limit: 10 |       open-pull-requests-limit: 10 | ||||||
|   assignees: |       reviewers: | ||||||
|   - BeryJu |           - "@goauthentik/core" | ||||||
|  |       commit-message: | ||||||
|  |           prefix: "core:" | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/pull_request_template.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/pull_request_template.md
									
									
									
									
										vendored
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| <!-- | <!-- | ||||||
| 👋 Hello there! Welcome. | 👋 Hello there! Welcome. | ||||||
|  |  | ||||||
| Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/master/CONTRIBUTING.md#how-can-i-contribute). | Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/main/CONTRIBUTING.md#how-can-i-contribute). | ||||||
| --> | --> | ||||||
|  |  | ||||||
| # Details | # Details | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -10,6 +10,7 @@ exemptLabels: | |||||||
|   - enhancement |   - enhancement | ||||||
|   - bug/confirmed |   - bug/confirmed | ||||||
|   - enhancement/confirmed |   - enhancement/confirmed | ||||||
|  |   - question | ||||||
| # 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 | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,14 +3,14 @@ name: authentik-ci-main | |||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|       - next |       - next | ||||||
|       - version-* |       - version-* | ||||||
|     paths-ignore: |     paths-ignore: | ||||||
|       - website |       - website | ||||||
|   pull_request: |   pull_request: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|  |  | ||||||
| env: | env: | ||||||
|   POSTGRES_DB: authentik |   POSTGRES_DB: authentik | ||||||
| @ -106,7 +106,7 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           domain: ${{github.repository_owner}} |           domain: ${{github.repository_owner}} | ||||||
|       - name: Create k8s Kind Cluster |       - name: Create k8s Kind Cluster | ||||||
|         uses: helm/kind-action@v1.2.0 |         uses: helm/kind-action@v1.3.0 | ||||||
|       - name: run integration |       - name: run integration | ||||||
|         run: | |         run: | | ||||||
|           poetry run make test-integration |           poetry run make test-integration | ||||||
| @ -133,12 +133,13 @@ jobs: | |||||||
|         uses: actions/cache@v3 |         uses: actions/cache@v3 | ||||||
|         with: |         with: | ||||||
|           path: web/dist |           path: web/dist | ||||||
|           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }} |           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }} | ||||||
|       - name: prepare web ui |       - name: prepare web ui | ||||||
|         if: steps.cache-web.outputs.cache-hit != 'true' |         if: steps.cache-web.outputs.cache-hit != 'true' | ||||||
|         working-directory: web |         working-directory: web | ||||||
|         run: | |         run: | | ||||||
|           npm ci |           npm ci | ||||||
|  |           make -C .. gen-client-web | ||||||
|           npm run build |           npm run build | ||||||
|       - name: run e2e |       - name: run e2e | ||||||
|         run: | |         run: | | ||||||
| @ -166,12 +167,13 @@ jobs: | |||||||
|         uses: actions/cache@v3 |         uses: actions/cache@v3 | ||||||
|         with: |         with: | ||||||
|           path: web/dist |           path: web/dist | ||||||
|           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }} |           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }} | ||||||
|       - name: prepare web ui |       - name: prepare web ui | ||||||
|         if: steps.cache-web.outputs.cache-hit != 'true' |         if: steps.cache-web.outputs.cache-hit != 'true' | ||||||
|         working-directory: web/ |         working-directory: web/ | ||||||
|         run: | |         run: | | ||||||
|           npm ci |           npm ci | ||||||
|  |           make -C .. gen-client-web | ||||||
|           npm run build |           npm run build | ||||||
|       - name: run e2e |       - name: run e2e | ||||||
|         run: | |         run: | | ||||||
| @ -211,10 +213,10 @@ jobs: | |||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v2 |         uses: docker/setup-buildx-action@v2 | ||||||
|       - name: prepare variables |       - name: prepare variables | ||||||
|  |         uses: ./.github/actions/docker-push-variables | ||||||
|         id: ev |         id: ev | ||||||
|         env: |         env: | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|         uses: ./.github/actions/docker-setup |  | ||||||
|       - name: Login to Container Registry |       - name: Login to Container Registry | ||||||
|         uses: docker/login-action@v2 |         uses: docker/login-action@v2 | ||||||
|         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} |         if: ${{ steps.ev.outputs.shouldBuild == 'true' }} | ||||||
| @ -231,4 +233,5 @@ jobs: | |||||||
|             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }} |             ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }} | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|  |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|           platforms: ${{ matrix.arch }} |           platforms: ${{ matrix.arch }} | ||||||
|  | |||||||
							
								
								
									
										9
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,12 +3,12 @@ name: authentik-ci-outpost | |||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|       - next |       - next | ||||||
|       - version-* |       - version-* | ||||||
|   pull_request: |   pull_request: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   lint-golint: |   lint-golint: | ||||||
| @ -67,8 +67,8 @@ jobs: | |||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v2 |         uses: docker/setup-buildx-action@v2 | ||||||
|       - name: prepare variables |       - name: prepare variables | ||||||
|  |         uses: ./.github/actions/docker-push-variables | ||||||
|         id: ev |         id: ev | ||||||
|         uses: ./.github/actions/docker-setup |  | ||||||
|         env: |         env: | ||||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} |           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||||
|       - name: Login to Container Registry |       - name: Login to Container Registry | ||||||
| @ -91,6 +91,7 @@ jobs: | |||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           build-args: | |           build-args: | | ||||||
|             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} |             GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} | ||||||
|  |             VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} | ||||||
|           platforms: ${{ matrix.arch }} |           platforms: ${{ matrix.arch }} | ||||||
|   build-outpost-binary: |   build-outpost-binary: | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
| @ -110,7 +111,7 @@ jobs: | |||||||
|       - uses: actions/setup-go@v3 |       - uses: actions/setup-go@v3 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										19
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,19 +3,19 @@ name: authentik-ci-web | |||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|       - next |       - next | ||||||
|       - version-* |       - version-* | ||||||
|   pull_request: |   pull_request: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   lint-eslint: |   lint-eslint: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -31,7 +31,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -47,13 +47,18 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|           cache-dependency-path: web/package-lock.json |           cache-dependency-path: web/package-lock.json | ||||||
|       - working-directory: web/ |       - working-directory: web/ | ||||||
|         run: npm ci |         run: | | ||||||
|  |           npm ci | ||||||
|  |           # lit-analyse doesn't understand path rewrites, so make it | ||||||
|  |           # belive it's an actual module | ||||||
|  |           cd node_modules/@goauthentik | ||||||
|  |           ln -s ../../src/ web | ||||||
|       - name: Generate API |       - name: Generate API | ||||||
|         run: make gen-client-web |         run: make gen-client-web | ||||||
|       - name: lit-analyse |       - name: lit-analyse | ||||||
| @ -73,7 +78,7 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,19 +3,19 @@ name: authentik-ci-website | |||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|       - next |       - next | ||||||
|       - version-* |       - version-* | ||||||
|   pull_request: |   pull_request: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - main | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   lint-prettier: |   lint-prettier: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @ -2,10 +2,10 @@ name: "CodeQL" | |||||||
|  |  | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: [ master, '*', next, version* ] |     branches: [ main, '*', next, version* ] | ||||||
|   pull_request: |   pull_request: | ||||||
|     # The branches below must be a subset of the branches above |     # The branches below must be a subset of the branches above | ||||||
|     branches: [ master ] |     branches: [ main ] | ||||||
|   schedule: |   schedule: | ||||||
|     - cron: '30 6 * * 5' |     - cron: '30 6 * * 5' | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -19,4 +19,4 @@ jobs: | |||||||
|           org-name: goauthentik |           org-name: goauthentik | ||||||
|           untagged-only: false |           untagged-only: false | ||||||
|           token: ${{ secrets.GHCR_CLEANUP_TOKEN }} |           token: ${{ secrets.GHCR_CLEANUP_TOKEN }} | ||||||
|           skip-tags: gh-next,gh-master |           skip-tags: gh-next,gh-main | ||||||
|  | |||||||
							
								
								
									
										26
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -5,7 +5,6 @@ on: | |||||||
|     types: [published, created] |     types: [published, created] | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   # Build |  | ||||||
|   build-server: |   build-server: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
| @ -14,6 +13,9 @@ jobs: | |||||||
|         uses: docker/setup-qemu-action@v2.0.0 |         uses: docker/setup-qemu-action@v2.0.0 | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v2 |         uses: docker/setup-buildx-action@v2 | ||||||
|  |       - name: prepare variables | ||||||
|  |         uses: ./.github/actions/docker-push-variables | ||||||
|  |         id: ev | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         uses: docker/login-action@v2 |         uses: docker/login-action@v2 | ||||||
|         with: |         with: | ||||||
| @ -30,9 +32,11 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik:2022.5.2, |             beryju/authentik:${{ steps.ev.outputs.version }}, | ||||||
|  |             beryju/authentik:${{ steps.ev.outputs.versionFamily }}, | ||||||
|             beryju/authentik:latest, |             beryju/authentik:latest, | ||||||
|             ghcr.io/goauthentik/server:2022.5.2, |             ghcr.io/goauthentik/server:${{ steps.ev.outputs.version }}, | ||||||
|  |             ghcr.io/goauthentik/server:${{ steps.ev.outputs.versionFamily }}, | ||||||
|             ghcr.io/goauthentik/server:latest |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
| @ -53,6 +57,9 @@ jobs: | |||||||
|         uses: docker/setup-qemu-action@v2.0.0 |         uses: docker/setup-qemu-action@v2.0.0 | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v2 |         uses: docker/setup-buildx-action@v2 | ||||||
|  |       - name: prepare variables | ||||||
|  |         uses: ./.github/actions/docker-push-variables | ||||||
|  |         id: ev | ||||||
|       - name: Docker Login Registry |       - name: Docker Login Registry | ||||||
|         uses: docker/login-action@v2 |         uses: docker/login-action@v2 | ||||||
|         with: |         with: | ||||||
| @ -69,9 +76,11 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-${{ matrix.type }}:2022.5.2, |             beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.version }}, | ||||||
|  |             beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }}, | ||||||
|             beryju/authentik-${{ matrix.type }}:latest, |             beryju/authentik-${{ matrix.type }}:latest, | ||||||
|             ghcr.io/goauthentik/${{ matrix.type }}:2022.5.2, |             ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.version }}, | ||||||
|  |             ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }}, | ||||||
|             ghcr.io/goauthentik/${{ matrix.type }}:latest |             ghcr.io/goauthentik/${{ matrix.type }}:latest | ||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
| @ -91,7 +100,7 @@ jobs: | |||||||
|       - uses: actions/setup-go@v3 |       - uses: actions/setup-go@v3 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -138,6 +147,9 @@ jobs: | |||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|  |       - name: prepare variables | ||||||
|  |         uses: ./.github/actions/docker-push-variables | ||||||
|  |         id: ev | ||||||
|       - name: Get static files from docker image |       - name: Get static files from docker image | ||||||
|         run: | |         run: | | ||||||
|           docker pull ghcr.io/goauthentik/server:latest |           docker pull ghcr.io/goauthentik/server:latest | ||||||
| @ -152,7 +164,7 @@ jobs: | |||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2022.5.2 |           version: authentik@${{ steps.ev.outputs.version }} | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|           sourcemaps: './web/dist' |           sourcemaps: './web/dist' | ||||||
|           url_prefix: '~/static/dist' |           url_prefix: '~/static/dist' | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| name: authentik-backend-translate-compile | name: authentik-backend-translate-compile | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: [ master ] |     branches: [ main ] | ||||||
|     paths: |     paths: | ||||||
|       - '/locale/' |       - '/locale/' | ||||||
|   pull_request: |   pull_request: | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| name: authentik-web-api-publish | name: authentik-web-api-publish | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     branches: [ master ] |     branches: [ main ] | ||||||
|     paths: |     paths: | ||||||
|       - 'schema.yml' |       - 'schema.yml' | ||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
| @ -11,7 +11,7 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       # Setup .npmrc file to publish to npm |       # Setup .npmrc file to publish to npm | ||||||
|       - uses: actions/setup-node@v3.2.0 |       - uses: actions/setup-node@v3.3.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           registry-url: 'https://registry.npmjs.org' |           registry-url: 'https://registry.npmjs.org' | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -193,7 +193,6 @@ pip-selfcheck.json | |||||||
| # End of https://www.gitignore.io/api/python,django | # End of https://www.gitignore.io/api/python,django | ||||||
| /static/ | /static/ | ||||||
| local.env.yml | local.env.yml | ||||||
| .vscode/ |  | ||||||
|  |  | ||||||
| # Selenium Screenshots | # Selenium Screenshots | ||||||
| selenium_screenshots/ | selenium_screenshots/ | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,6 @@ | |||||||
| { | { | ||||||
|     "cSpell.words": [ |     "cSpell.words": [ | ||||||
|  |         "akadmin", | ||||||
|         "asgi", |         "asgi", | ||||||
|         "authentik", |         "authentik", | ||||||
|         "authn", |         "authn", | ||||||
| @ -21,5 +22,9 @@ | |||||||
|     "python.formatting.provider": "black", |     "python.formatting.provider": "black", | ||||||
|     "files.associations": { |     "files.associations": { | ||||||
|         "*.akflow": "json" |         "*.akflow": "json" | ||||||
|     } |     }, | ||||||
|  |     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||||
|  |     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||||
|  |     "typescript.tsdk": "./web/node_modules/typescript/lib", | ||||||
|  |     "typescript.enablePromptUseWorkspaceTsdk": true | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										86
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,86 @@ | |||||||
|  | { | ||||||
|  |     "version": "2.0.0", | ||||||
|  |     "tasks": [ | ||||||
|  |         { | ||||||
|  |             "label": "authentik[core]: format & test", | ||||||
|  |             "command": "poetry", | ||||||
|  |             "args": [ | ||||||
|  |                 "run", | ||||||
|  |                 "make" | ||||||
|  |             ], | ||||||
|  |             "group": "build", | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik[core]: run", | ||||||
|  |             "command": "poetry", | ||||||
|  |             "args": [ | ||||||
|  |                 "run", | ||||||
|  |                 "make", | ||||||
|  |                 "run", | ||||||
|  |             ], | ||||||
|  |             "group": "build", | ||||||
|  |             "presentation": { | ||||||
|  |                 "panel": "dedicated", | ||||||
|  |                 "group": "running" | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik[web]: format", | ||||||
|  |             "command": "make", | ||||||
|  |             "args": ["web"], | ||||||
|  |             "group": "build", | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik[web]: watch", | ||||||
|  |             "command": "make", | ||||||
|  |             "args": ["web-watch"], | ||||||
|  |             "group": "build", | ||||||
|  |             "presentation": { | ||||||
|  |                 "panel": "dedicated", | ||||||
|  |                 "group": "running" | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik: install", | ||||||
|  |             "command": "make", | ||||||
|  |             "args": ["install"], | ||||||
|  |             "group": "build", | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik: i18n-extract", | ||||||
|  |             "command": "poetry", | ||||||
|  |             "args": [ | ||||||
|  |                 "run", | ||||||
|  |                 "make", | ||||||
|  |                 "i18n-extract" | ||||||
|  |             ], | ||||||
|  |             "group": "build", | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik[website]: format", | ||||||
|  |             "command": "make", | ||||||
|  |             "args": ["website"], | ||||||
|  |             "group": "build", | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik[website]: watch", | ||||||
|  |             "command": "make", | ||||||
|  |             "args": ["website-watch"], | ||||||
|  |             "group": "build", | ||||||
|  |             "presentation": { | ||||||
|  |                 "panel": "dedicated", | ||||||
|  |                 "group": "running" | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "label": "authentik[api]: generate", | ||||||
|  |             "command": "poetry", | ||||||
|  |             "args": [ | ||||||
|  |                 "run", | ||||||
|  |                 "make", | ||||||
|  |                 "gen" | ||||||
|  |             ], | ||||||
|  |             "group": "build" | ||||||
|  |         }, | ||||||
|  |     ] | ||||||
|  | } | ||||||
| @ -60,7 +60,7 @@ representative at an online or offline event. | |||||||
|  |  | ||||||
| Instances of abusive, harassing, or otherwise unacceptable behavior may be | Instances of abusive, harassing, or otherwise unacceptable behavior may be | ||||||
| reported to the community leaders responsible for enforcement at | reported to the community leaders responsible for enforcement at | ||||||
| hello@beryju.org. | hello@goauthentik.io. | ||||||
| All complaints will be reviewed and investigated promptly and fairly. | All complaints will be reviewed and investigated promptly and fairly. | ||||||
|  |  | ||||||
| All community leaders are obligated to respect the privacy and security of the | All community leaders are obligated to respect the privacy and security of the | ||||||
|  | |||||||
| @ -18,7 +18,7 @@ WORKDIR /work/web | |||||||
| RUN npm ci && npm run build | RUN npm ci && npm run build | ||||||
|  |  | ||||||
| # Stage 3: Poetry to requirements.txt export | # Stage 3: Poetry to requirements.txt export | ||||||
| FROM docker.io/python:3.10.4-slim-bullseye AS poetry-locker | FROM docker.io/python:3.10.5-slim-bullseye AS poetry-locker | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
| COPY ./pyproject.toml /work | COPY ./pyproject.toml /work | ||||||
| @ -29,7 +29,7 @@ RUN pip install --no-cache-dir poetry && \ | |||||||
|     poetry export -f requirements.txt --dev --output requirements-dev.txt |     poetry export -f requirements.txt --dev --output requirements-dev.txt | ||||||
|  |  | ||||||
| # Stage 4: Build go proxy | # Stage 4: Build go proxy | ||||||
| FROM docker.io/golang:1.18.2-bullseye AS builder | FROM docker.io/golang:1.18.3-bullseye AS builder | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
|  |  | ||||||
| @ -45,7 +45,7 @@ COPY ./go.sum /work/go.sum | |||||||
| RUN go build -o /work/authentik ./cmd/server/main.go | RUN go build -o /work/authentik ./cmd/server/main.go | ||||||
|  |  | ||||||
| # Stage 5: Run | # Stage 5: Run | ||||||
| FROM docker.io/python:3.10.4-slim-bullseye | FROM docker.io/python:3.10.5-slim-bullseye | ||||||
|  |  | ||||||
| LABEL org.opencontainers.image.url https://goauthentik.io | LABEL org.opencontainers.image.url https://goauthentik.io | ||||||
| LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info. | LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info. | ||||||
|  | |||||||
							
								
								
									
										21
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								Makefile
									
									
									
									
									
								
							| @ -45,8 +45,8 @@ lint-fix: | |||||||
| 		website/developer-docs | 		website/developer-docs | ||||||
|  |  | ||||||
| lint: | lint: | ||||||
| 	bandit -r authentik tests lifecycle -x node_modules |  | ||||||
| 	pylint authentik tests lifecycle | 	pylint authentik tests lifecycle | ||||||
|  | 	bandit -r authentik tests lifecycle -x node_modules | ||||||
| 	golangci-lint run -v | 	golangci-lint run -v | ||||||
|  |  | ||||||
| i18n-extract: i18n-extract-core web-extract | i18n-extract: i18n-extract-core web-extract | ||||||
| @ -55,7 +55,7 @@ i18n-extract-core: | |||||||
| 	./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en | 	./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en | ||||||
|  |  | ||||||
| gen-build: | gen-build: | ||||||
| 	./manage.py spectacular --file schema.yml | 	AUTHENTIK_DEBUG=true ./manage.py spectacular --file schema.yml | ||||||
|  |  | ||||||
| gen-clean: | gen-clean: | ||||||
| 	rm -rf web/api/src/ | 	rm -rf web/api/src/ | ||||||
| @ -65,7 +65,7 @@ gen-client-web: | |||||||
| 	docker run \ | 	docker run \ | ||||||
| 		--rm -v ${PWD}:/local \ | 		--rm -v ${PWD}:/local \ | ||||||
| 		--user ${UID}:${GID} \ | 		--user ${UID}:${GID} \ | ||||||
| 		openapitools/openapi-generator-cli:v6.0.0-beta generate \ | 		openapitools/openapi-generator-cli:v6.0.0 generate \ | ||||||
| 		-i /local/schema.yml \ | 		-i /local/schema.yml \ | ||||||
| 		-g typescript-fetch \ | 		-g typescript-fetch \ | ||||||
| 		-o /local/gen-ts-api \ | 		-o /local/gen-ts-api \ | ||||||
| @ -83,7 +83,7 @@ gen-client-go: | |||||||
| 	docker run \ | 	docker run \ | ||||||
| 		--rm -v ${PWD}:/local \ | 		--rm -v ${PWD}:/local \ | ||||||
| 		--user ${UID}:${GID} \ | 		--user ${UID}:${GID} \ | ||||||
| 		openapitools/openapi-generator-cli:v5.2.1 generate \ | 		openapitools/openapi-generator-cli:v6.0.0 generate \ | ||||||
| 		-i /local/schema.yml \ | 		-i /local/schema.yml \ | ||||||
| 		-g go \ | 		-g go \ | ||||||
| 		-o /local/gen-go-api \ | 		-o /local/gen-go-api \ | ||||||
| @ -103,12 +103,18 @@ run: | |||||||
| ## Web | ## Web | ||||||
| ######################### | ######################### | ||||||
|  |  | ||||||
| web: web-lint-fix web-lint web-extract | web-build: web-install | ||||||
|  | 	cd web && npm run build | ||||||
|  |  | ||||||
|  | web: web-lint-fix web-lint | ||||||
|  |  | ||||||
| web-install: | web-install: | ||||||
| 	cd web && npm ci | 	cd web && npm ci | ||||||
|  |  | ||||||
| web-watch: | web-watch: | ||||||
|  | 	rm -rf web/dist/ | ||||||
|  | 	mkdir web/dist/ | ||||||
|  | 	touch web/dist/.gitkeep | ||||||
| 	cd web && npm run watch | 	cd web && npm run watch | ||||||
|  |  | ||||||
| web-lint-fix: | web-lint-fix: | ||||||
| @ -163,8 +169,3 @@ ci-pending-migrations: ci--meta-debug | |||||||
|  |  | ||||||
| install: web-install website-install | install: web-install website-install | ||||||
| 	poetry install | 	poetry install | ||||||
|  |  | ||||||
| a: install |  | ||||||
| 	tmux \ |  | ||||||
| 		new-session 'make run' \; \ |  | ||||||
| 		split-window 'make web-watch' |  | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ | |||||||
| [](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml) | [](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml) | ||||||
| [](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml) | [](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml) | ||||||
| [](https://codecov.io/gh/goauthentik/authentik) | [](https://codecov.io/gh/goauthentik/authentik) | ||||||
| [](https://goauthentik.testspace.com/) | [](https://goauthentik.testspace.com/) | ||||||
|  |  | ||||||
|  |  | ||||||
| [](https://www.transifex.com/beryjuorg/authentik/) | [](https://www.transifex.com/beryjuorg/authentik/) | ||||||
|  | |||||||
| @ -6,9 +6,10 @@ | |||||||
|  |  | ||||||
| | Version    | Supported          | | | Version    | Supported          | | ||||||
| | ---------- | ------------------ | | | ---------- | ------------------ | | ||||||
| | 2022.3.x   | :white_check_mark: | | | 2022.5.x   | :white_check_mark: | | ||||||
| | 2022.4.x   | :white_check_mark: | | | 2022.6.x   | :white_check_mark: | | ||||||
|  | | 2022.7.x   | :white_check_mark: | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
| To report a vulnerability, send an email to [security@beryju.org](mailto:security@beryju.org) | To report a vulnerability, send an email to [security@goauthentik.io](mailto:security@goauthentik.io) | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from os import environ | from os import environ | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| __version__ = "2022.5.2" | __version__ = "2022.7.2" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """authentik administration overview""" | """authentik administration overview""" | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from drf_spectacular.utils import extend_schema, inline_serializer | from drf_spectacular.utils import extend_schema, inline_serializer | ||||||
| from prometheus_client import Gauge |  | ||||||
| from rest_framework.fields import IntegerField | from rest_framework.fields import IntegerField | ||||||
| from rest_framework.permissions import IsAdminUser | from rest_framework.permissions import IsAdminUser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| @ -10,8 +9,6 @@ from rest_framework.views import APIView | |||||||
|  |  | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
| GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class WorkerView(APIView): | class WorkerView(APIView): | ||||||
|     """Get currently connected worker count.""" |     """Get currently connected worker count.""" | ||||||
|  | |||||||
| @ -2,6 +2,10 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
|  | from prometheus_client import Gauge, Info | ||||||
|  |  | ||||||
|  | PROM_INFO = Info("authentik_version", "Currently running authentik version") | ||||||
|  | GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers") | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikAdminConfig(AppConfig): | class AuthentikAdminConfig(AppConfig): | ||||||
| @ -12,7 +16,4 @@ class AuthentikAdminConfig(AppConfig): | |||||||
|     verbose_name = "authentik Admin" |     verbose_name = "authentik Admin" | ||||||
|  |  | ||||||
|     def ready(self): |     def ready(self): | ||||||
|         from authentik.admin.tasks import clear_update_notifications |  | ||||||
|  |  | ||||||
|         clear_update_notifications.delay() |  | ||||||
|         import_module("authentik.admin.signals") |         import_module("authentik.admin.signals") | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
|  |  | ||||||
| from authentik.admin.api.tasks import TaskInfo | from authentik.admin.api.tasks import TaskInfo | ||||||
| from authentik.admin.api.workers import GAUGE_WORKERS | from authentik.admin.apps import GAUGE_WORKERS | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| from authentik.root.monitoring import monitoring_set | from authentik.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,11 +4,11 @@ import re | |||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.core.validators import URLValidator | from django.core.validators import URLValidator | ||||||
| from packaging.version import parse | from packaging.version import parse | ||||||
| from prometheus_client import Info |  | ||||||
| from requests import RequestException | from requests import RequestException | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import __version__, get_build_hash | from authentik import __version__, get_build_hash | ||||||
|  | from authentik.admin.apps import PROM_INFO | ||||||
| from authentik.events.models import Event, EventAction, Notification | from authentik.events.models import Event, EventAction, Notification | ||||||
| from authentik.events.monitored_tasks import ( | from authentik.events.monitored_tasks import ( | ||||||
|     MonitoredTask, |     MonitoredTask, | ||||||
| @ -25,7 +25,6 @@ VERSION_CACHE_KEY = "authentik_latest_version" | |||||||
| VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours | ||||||
| # Chop of the first ^ because we want to search the entire string | # Chop of the first ^ because we want to search the entire string | ||||||
| URL_FINDER = URLValidator.regex.pattern[1:] | URL_FINDER = URLValidator.regex.pattern[1:] | ||||||
| PROM_INFO = Info("authentik_version", "Currently running authentik version") |  | ||||||
| LOCAL_VERSION = parse(__version__) | LOCAL_VERSION = parse(__version__) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -10,6 +10,8 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.core.middleware import KEY_AUTH_VIA, LOCAL | from authentik.core.middleware import KEY_AUTH_VIA, LOCAL | ||||||
| from authentik.core.models import Token, TokenIntents, User | from authentik.core.models import Token, TokenIntents, User | ||||||
| from authentik.outposts.models import Outpost | from authentik.outposts.models import Outpost | ||||||
|  | from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API | ||||||
|  | from authentik.providers.oauth2.models import RefreshToken | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -24,7 +26,7 @@ def validate_auth(header: bytes) -> str: | |||||||
|     if auth_type.lower() != "bearer": |     if auth_type.lower() != "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") | ||||||
|     if auth_credentials == "":  # nosec |     if auth_credentials == "":  # nosec # noqa | ||||||
|         raise AuthenticationFailed("Malformed header") |         raise AuthenticationFailed("Malformed header") | ||||||
|     return auth_credentials |     return auth_credentials | ||||||
|  |  | ||||||
| @ -34,14 +36,30 @@ def bearer_auth(raw_header: bytes) -> Optional[User]: | |||||||
|     auth_credentials = validate_auth(raw_header) |     auth_credentials = validate_auth(raw_header) | ||||||
|     if not auth_credentials: |     if not auth_credentials: | ||||||
|         return None |         return None | ||||||
|  |     if not hasattr(LOCAL, "authentik"): | ||||||
|  |         LOCAL.authentik = {} | ||||||
|     # first, check traditional tokens |     # first, check traditional tokens | ||||||
|     token = Token.filter_not_expired(key=auth_credentials, intent=TokenIntents.INTENT_API).first() |     key_token = Token.filter_not_expired( | ||||||
|     if hasattr(LOCAL, "authentik"): |         key=auth_credentials, intent=TokenIntents.INTENT_API | ||||||
|  |     ).first() | ||||||
|  |     if key_token: | ||||||
|         LOCAL.authentik[KEY_AUTH_VIA] = "api_token" |         LOCAL.authentik[KEY_AUTH_VIA] = "api_token" | ||||||
|     if token: |         return key_token.user | ||||||
|         return token.user |     # then try to auth via JWT | ||||||
|  |     jwt_token = RefreshToken.filter_not_expired( | ||||||
|  |         refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API | ||||||
|  |     ).first() | ||||||
|  |     if jwt_token: | ||||||
|  |         # Double-check scopes, since they are saved in a single string | ||||||
|  |         # we want to check the parsed version too | ||||||
|  |         if SCOPE_AUTHENTIK_API not in jwt_token.scope: | ||||||
|  |             raise AuthenticationFailed("Token invalid/expired") | ||||||
|  |         LOCAL.authentik[KEY_AUTH_VIA] = "jwt" | ||||||
|  |         return jwt_token.user | ||||||
|  |     # then try to auth via secret key (for embedded outpost/etc) | ||||||
|     user = token_secret_key(auth_credentials) |     user = token_secret_key(auth_credentials) | ||||||
|     if user: |     if user: | ||||||
|  |         LOCAL.authentik[KEY_AUTH_VIA] = "secret_key" | ||||||
|         return user |         return user | ||||||
|     raise AuthenticationFailed("Token invalid/expired") |     raise AuthenticationFailed("Token invalid/expired") | ||||||
|  |  | ||||||
| @ -56,8 +74,6 @@ def token_secret_key(value: str) -> Optional[User]: | |||||||
|     outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) |     outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) | ||||||
|     if not outposts: |     if not outposts: | ||||||
|         return None |         return None | ||||||
|     if hasattr(LOCAL, "authentik"): |  | ||||||
|         LOCAL.authentik[KEY_AUTH_VIA] = "secret_key" |  | ||||||
|     outpost = outposts.first() |     outpost = outposts.first() | ||||||
|     return outpost.user |     return outpost.user | ||||||
|  |  | ||||||
|  | |||||||
| @ -8,9 +8,6 @@ API Browser - {{ tenant.branding_title }} | |||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script> | <script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script> | ||||||
| {% endblock %} |  | ||||||
|  |  | ||||||
| {% block body %} |  | ||||||
| <script> | <script> | ||||||
| function getCookie(name) { | function getCookie(name) { | ||||||
|     let cookieValue = ""; |     let cookieValue = ""; | ||||||
| @ -34,16 +31,58 @@ window.addEventListener('DOMContentLoaded', (event) => { | |||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  | <style> | ||||||
|  |     img.logo { | ||||||
|  |         width: 100%; | ||||||
|  |         padding: 1rem 0.5rem 1.5rem 0.5rem; | ||||||
|  |         min-height: 48px; | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block body %} | ||||||
| <rapi-doc | <rapi-doc | ||||||
|     spec-url="{{ path }}" |     spec-url="{{ path }}" | ||||||
|     heading-text="authentik" |     heading-text="" | ||||||
|     theme="dark" |     theme="light" | ||||||
|     render-style="view" |     render-style="read" | ||||||
|  |     default-schema-tab="schema" | ||||||
|     primary-color="#fd4b2d" |     primary-color="#fd4b2d" | ||||||
|  |     nav-bg-color="#212427" | ||||||
|  |     bg-color="#000000" | ||||||
|  |     text-color="#000000" | ||||||
|  |     nav-text-color="#ffffff" | ||||||
|  |     nav-hover-bg-color="#3c3f42" | ||||||
|  |     nav-accent-color="#4f5255" | ||||||
|  |     nav-hover-text-color="#ffffff" | ||||||
|  |     use-path-in-nav-bar="true" | ||||||
|  |     nav-item-spacing="relaxed" | ||||||
|  |     allow-server-selection="false" | ||||||
|  |     show-header="false" | ||||||
|     allow-spec-url-load="false" |     allow-spec-url-load="false" | ||||||
|     allow-spec-file-load="false"> |     allow-spec-file-load="false"> | ||||||
|     <div slot="logo"> |     <div slot="nav-logo"> | ||||||
|         <img src="{% static 'dist/assets/icons/icon.png' %}" style="width:50px; height:50px" /> |         <img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" /> | ||||||
|     </div> |     </div> | ||||||
| </rapi-doc> | </rapi-doc> | ||||||
|  | <script> | ||||||
|  | const rapidoc = document.querySelector("rapi-doc"); | ||||||
|  | const matcher = window.matchMedia("(prefers-color-scheme: light)"); | ||||||
|  | const changer = (ev) => { | ||||||
|  |     const style = getComputedStyle(document.documentElement); | ||||||
|  |     let bg, text = ""; | ||||||
|  |     if (matcher.matches) { | ||||||
|  |         bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300'); | ||||||
|  |         text = style.getPropertyValue('--pf-global--Color--300'); | ||||||
|  |     } else { | ||||||
|  |         bg = style.getPropertyValue('--ak-dark-background'); | ||||||
|  |         text = style.getPropertyValue('--ak-dark-foreground'); | ||||||
|  |     } | ||||||
|  |     rapidoc.attributes.getNamedItem("bg-color").value = bg.trim(); | ||||||
|  |     rapidoc.attributes.getNamedItem("text-color").value = text.trim(); | ||||||
|  |     rapidoc.requestUpdate(); | ||||||
|  | }; | ||||||
|  | matcher.addEventListener("change", changer); | ||||||
|  | window.addEventListener("load", changer); | ||||||
|  | </script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  | |||||||
| @ -8,28 +8,37 @@ from rest_framework.exceptions import AuthenticationFailed | |||||||
|  |  | ||||||
| from authentik.api.authentication import bearer_auth | from authentik.api.authentication import bearer_auth | ||||||
| from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents | from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents | ||||||
|  | from authentik.core.tests.utils import create_test_flow | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
| from authentik.outposts.managed import OutpostManager | from authentik.outposts.managed import OutpostManager | ||||||
|  | from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API | ||||||
|  | from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestAPIAuth(TestCase): | class TestAPIAuth(TestCase): | ||||||
|     """Test API Authentication""" |     """Test API Authentication""" | ||||||
|  |  | ||||||
|     def test_valid_bearer(self): |  | ||||||
|         """Test valid token""" |  | ||||||
|         token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user()) |  | ||||||
|         self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user) |  | ||||||
|  |  | ||||||
|     def test_invalid_type(self): |     def test_invalid_type(self): | ||||||
|         """Test invalid type""" |         """Test invalid type""" | ||||||
|         with self.assertRaises(AuthenticationFailed): |         with self.assertRaises(AuthenticationFailed): | ||||||
|             bearer_auth("foo bar".encode()) |             bearer_auth("foo bar".encode()) | ||||||
|  |  | ||||||
|  |     def test_invalid_empty(self): | ||||||
|  |         """Test invalid type""" | ||||||
|  |         self.assertIsNone(bearer_auth("Bearer ".encode())) | ||||||
|  |         self.assertIsNone(bearer_auth("".encode())) | ||||||
|  |  | ||||||
|     def test_invalid_no_token(self): |     def test_invalid_no_token(self): | ||||||
|         """Test invalid with no token""" |         """Test invalid with no token""" | ||||||
|         with self.assertRaises(AuthenticationFailed): |         with self.assertRaises(AuthenticationFailed): | ||||||
|             auth = b64encode(":abc".encode()).decode() |             auth = b64encode(":abc".encode()).decode() | ||||||
|             self.assertIsNone(bearer_auth(f"Basic :{auth}".encode())) |             self.assertIsNone(bearer_auth(f"Basic :{auth}".encode())) | ||||||
|  |  | ||||||
|  |     def test_bearer_valid(self): | ||||||
|  |         """Test valid token""" | ||||||
|  |         token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user()) | ||||||
|  |         self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user) | ||||||
|  |  | ||||||
|     def test_managed_outpost(self): |     def test_managed_outpost(self): | ||||||
|         """Test managed outpost""" |         """Test managed outpost""" | ||||||
|         with self.assertRaises(AuthenticationFailed): |         with self.assertRaises(AuthenticationFailed): | ||||||
| @ -38,3 +47,30 @@ class TestAPIAuth(TestCase): | |||||||
|         OutpostManager().run() |         OutpostManager().run() | ||||||
|         user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) |         user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode()) | ||||||
|         self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True) |         self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True) | ||||||
|  |  | ||||||
|  |     def test_jwt_valid(self): | ||||||
|  |         """Test valid JWT""" | ||||||
|  |         provider = OAuth2Provider.objects.create( | ||||||
|  |             name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() | ||||||
|  |         ) | ||||||
|  |         refresh = RefreshToken.objects.create( | ||||||
|  |             user=get_anonymous_user(), | ||||||
|  |             provider=provider, | ||||||
|  |             refresh_token=generate_id(), | ||||||
|  |             _scope=SCOPE_AUTHENTIK_API, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user) | ||||||
|  |  | ||||||
|  |     def test_jwt_missing_scope(self): | ||||||
|  |         """Test valid JWT""" | ||||||
|  |         provider = OAuth2Provider.objects.create( | ||||||
|  |             name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() | ||||||
|  |         ) | ||||||
|  |         refresh = RefreshToken.objects.create( | ||||||
|  |             user=get_anonymous_user(), | ||||||
|  |             provider=provider, | ||||||
|  |             refresh_token=generate_id(), | ||||||
|  |             _scope="", | ||||||
|  |         ) | ||||||
|  |         with self.assertRaises(AuthenticationFailed): | ||||||
|  |             self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user) | ||||||
|  | |||||||
							
								
								
									
										29
									
								
								authentik/api/tests/test_viewsets.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								authentik/api/tests/test_viewsets.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | |||||||
|  | """authentik API Modelviewset tests""" | ||||||
|  | from typing import Callable | ||||||
|  |  | ||||||
|  | from django.test import TestCase | ||||||
|  | from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.api.v3.urls import router | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestModelViewSets(TestCase): | ||||||
|  |     """Test Viewset""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable: | ||||||
|  |     """Test Viewset""" | ||||||
|  |  | ||||||
|  |     def tester(self: TestModelViewSets): | ||||||
|  |         self.assertIsNotNone(getattr(test_viewset, "search_fields", None)) | ||||||
|  |         filterset_class = getattr(test_viewset, "filterset_class", None) | ||||||
|  |         if not filterset_class: | ||||||
|  |             self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None)) | ||||||
|  |  | ||||||
|  |     return tester | ||||||
|  |  | ||||||
|  |  | ||||||
|  | for _, viewset, _ in router.registry: | ||||||
|  |     if not issubclass(viewset, (ModelViewSet, ReadOnlyModelViewSet)): | ||||||
|  |         continue | ||||||
|  |     setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset)) | ||||||
| @ -74,7 +74,7 @@ class ConfigView(APIView): | |||||||
|         config = ConfigSerializer( |         config = ConfigSerializer( | ||||||
|             { |             { | ||||||
|                 "error_reporting": { |                 "error_reporting": { | ||||||
|                     "enabled": CONFIG.y("error_reporting.enabled") and not settings.DEBUG, |                     "enabled": CONFIG.y("error_reporting.enabled"), | ||||||
|                     "environment": CONFIG.y("error_reporting.environment"), |                     "environment": CONFIG.y("error_reporting.environment"), | ||||||
|                     "send_pii": CONFIG.y("error_reporting.send_pii"), |                     "send_pii": CONFIG.y("error_reporting.send_pii"), | ||||||
|                     "traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)), |                     "traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)), | ||||||
|  | |||||||
| @ -63,6 +63,7 @@ class ApplicationSerializer(ModelSerializer): | |||||||
|             "provider", |             "provider", | ||||||
|             "provider_obj", |             "provider_obj", | ||||||
|             "launch_url", |             "launch_url", | ||||||
|  |             "open_in_new_tab", | ||||||
|             "meta_launch_url", |             "meta_launch_url", | ||||||
|             "meta_icon", |             "meta_icon", | ||||||
|             "meta_description", |             "meta_description", | ||||||
| @ -88,7 +89,16 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|         "meta_publisher", |         "meta_publisher", | ||||||
|         "group", |         "group", | ||||||
|     ] |     ] | ||||||
|  |     filterset_fields = [ | ||||||
|  |         "name", | ||||||
|  |         "slug", | ||||||
|  |         "meta_launch_url", | ||||||
|  |         "meta_description", | ||||||
|  |         "meta_publisher", | ||||||
|  |         "group", | ||||||
|  |     ] | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|  |     filterset_fields = ["name", "slug"] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: |     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ from rest_framework.decorators import action | |||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | from rest_framework.serializers import ModelSerializer, ReadOnlyField, SerializerMethodField | ||||||
| from rest_framework.viewsets import GenericViewSet | from rest_framework.viewsets import GenericViewSet | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -26,6 +26,7 @@ LOGGER = get_logger() | |||||||
| class SourceSerializer(ModelSerializer, MetaNameSerializer): | class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||||
|     """Source Serializer""" |     """Source Serializer""" | ||||||
|  |  | ||||||
|  |     managed = ReadOnlyField() | ||||||
|     component = SerializerMethodField() |     component = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_component(self, obj: Source) -> str: |     def get_component(self, obj: Source) -> str: | ||||||
| @ -51,6 +52,8 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|             "meta_model_name", |             "meta_model_name", | ||||||
|             "policy_engine_mode", |             "policy_engine_mode", | ||||||
|             "user_matching_mode", |             "user_matching_mode", | ||||||
|  |             "managed", | ||||||
|  |             "user_path_template", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -66,6 +69,8 @@ class SourceViewSet( | |||||||
|     queryset = Source.objects.none() |     queryset = Source.objects.none() | ||||||
|     serializer_class = SourceSerializer |     serializer_class = SourceSerializer | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|  |     search_fields = ["slug", "name"] | ||||||
|  |     filterset_fields = ["slug", "name", "managed"] | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self):  # pragma: no cover | ||||||
|         return Source.objects.select_subclasses() |         return Source.objects.select_subclasses() | ||||||
|  | |||||||
| @ -24,7 +24,13 @@ from drf_spectacular.utils import ( | |||||||
| ) | ) | ||||||
| from guardian.shortcuts import get_anonymous_user, get_objects_for_user | from guardian.shortcuts import get_anonymous_user, get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField, JSONField, SerializerMethodField | from rest_framework.fields import ( | ||||||
|  |     CharField, | ||||||
|  |     IntegerField, | ||||||
|  |     JSONField, | ||||||
|  |     ListField, | ||||||
|  |     SerializerMethodField, | ||||||
|  | ) | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ( | from rest_framework.serializers import ( | ||||||
| @ -43,16 +49,23 @@ from authentik.api.decorators import permission_required | |||||||
| from authentik.core.api.groups import GroupSerializer | from authentik.core.api.groups import GroupSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | ||||||
| from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER | from authentik.core.middleware import ( | ||||||
|  |     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, | ||||||
|  |     SESSION_KEY_IMPERSONATE_USER, | ||||||
|  | ) | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
|     USER_ATTRIBUTE_SA, |     USER_ATTRIBUTE_SA, | ||||||
|     USER_ATTRIBUTE_TOKEN_EXPIRING, |     USER_ATTRIBUTE_TOKEN_EXPIRING, | ||||||
|  |     USER_PATH_SERVICE_ACCOUNT, | ||||||
|     Group, |     Group, | ||||||
|     Token, |     Token, | ||||||
|     TokenIntents, |     TokenIntents, | ||||||
|     User, |     User, | ||||||
| ) | ) | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
|  | from authentik.flows.models import FlowToken | ||||||
|  | from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner | ||||||
|  | from authentik.flows.views.executor import QS_KEY_TOKEN | ||||||
| from authentik.stages.email.models import EmailStage | from authentik.stages.email.models import EmailStage | ||||||
| from authentik.stages.email.tasks import send_mails | from authentik.stages.email.tasks import send_mails | ||||||
| from authentik.stages.email.utils import TemplateEmailMessage | from authentik.stages.email.utils import TemplateEmailMessage | ||||||
| @ -72,6 +85,16 @@ class UserSerializer(ModelSerializer): | |||||||
|     ) |     ) | ||||||
|     groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups") |     groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups") | ||||||
|     uid = CharField(read_only=True) |     uid = CharField(read_only=True) | ||||||
|  |     username = CharField(max_length=150) | ||||||
|  |  | ||||||
|  |     def validate_path(self, path: str) -> str: | ||||||
|  |         """Validate path""" | ||||||
|  |         if path[:1] == "/" or path[-1] == "/": | ||||||
|  |             raise ValidationError(_("No leading or trailing slashes allowed.")) | ||||||
|  |         for segment in path.split("/"): | ||||||
|  |             if segment == "": | ||||||
|  |                 raise ValidationError(_("No empty segments in user path allowed.")) | ||||||
|  |         return path | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -89,6 +112,7 @@ class UserSerializer(ModelSerializer): | |||||||
|             "avatar", |             "avatar", | ||||||
|             "attributes", |             "attributes", | ||||||
|             "uid", |             "uid", | ||||||
|  |             "path", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "name": {"allow_blank": True}, |             "name": {"allow_blank": True}, | ||||||
| @ -204,6 +228,11 @@ class UsersFilter(FilterSet): | |||||||
|     is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") |     is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") | ||||||
|     uuid = CharFilter(field_name="uuid") |     uuid = CharFilter(field_name="uuid") | ||||||
|  |  | ||||||
|  |     path = CharFilter( | ||||||
|  |         field_name="path", | ||||||
|  |     ) | ||||||
|  |     path_startswith = CharFilter(field_name="path", lookup_expr="startswith") | ||||||
|  |  | ||||||
|     groups_by_name = ModelMultipleChoiceFilter( |     groups_by_name = ModelMultipleChoiceFilter( | ||||||
|         field_name="ak_groups__name", |         field_name="ak_groups__name", | ||||||
|         to_field_name="name", |         to_field_name="name", | ||||||
| @ -268,12 +297,23 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|             LOGGER.debug("No recovery flow set") |             LOGGER.debug("No recovery flow set") | ||||||
|             return None, None |             return None, None | ||||||
|         user: User = self.get_object() |         user: User = self.get_object() | ||||||
|         token, __ = Token.objects.get_or_create( |         planner = FlowPlanner(flow) | ||||||
|             identifier=f"{user.uid}-password-reset", |         planner.allow_empty_flows = True | ||||||
|             user=user, |         plan = planner.plan( | ||||||
|             intent=TokenIntents.INTENT_RECOVERY, |             self.request._request, | ||||||
|  |             { | ||||||
|  |                 PLAN_CONTEXT_PENDING_USER: user, | ||||||
|  |             }, | ||||||
|         ) |         ) | ||||||
|         querystring = urlencode({"token": token.key}) |         token, __ = FlowToken.objects.update_or_create( | ||||||
|  |             identifier=f"{user.uid}-password-reset", | ||||||
|  |             defaults={ | ||||||
|  |                 "user": user, | ||||||
|  |                 "flow": flow, | ||||||
|  |                 "_plan": FlowToken.pickle(plan), | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         querystring = urlencode({QS_KEY_TOKEN: token.key}) | ||||||
|         link = self.request.build_absolute_uri( |         link = self.request.build_absolute_uri( | ||||||
|             reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) |             reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||||
|             + f"?{querystring}" |             + f"?{querystring}" | ||||||
| @ -295,6 +335,9 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|                 { |                 { | ||||||
|                     "username": CharField(required=True), |                     "username": CharField(required=True), | ||||||
|                     "token": CharField(required=True), |                     "token": CharField(required=True), | ||||||
|  |                     "user_uid": CharField(required=True), | ||||||
|  |                     "user_pk": IntegerField(required=True), | ||||||
|  |                     "group_pk": CharField(required=False), | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|         }, |         }, | ||||||
| @ -310,19 +353,27 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|                     username=username, |                     username=username, | ||||||
|                     name=username, |                     name=username, | ||||||
|                     attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False}, |                     attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False}, | ||||||
|  |                     path=USER_PATH_SERVICE_ACCOUNT, | ||||||
|                 ) |                 ) | ||||||
|  |                 response = { | ||||||
|  |                     "username": user.username, | ||||||
|  |                     "user_uid": user.uid, | ||||||
|  |                     "user_pk": user.pk, | ||||||
|  |                 } | ||||||
|                 if create_group and self.request.user.has_perm("authentik_core.add_group"): |                 if create_group and self.request.user.has_perm("authentik_core.add_group"): | ||||||
|                     group = Group.objects.create( |                     group = Group.objects.create( | ||||||
|                         name=username, |                         name=username, | ||||||
|                     ) |                     ) | ||||||
|                     group.users.add(user) |                     group.users.add(user) | ||||||
|  |                     response["group_pk"] = str(group.pk) | ||||||
|                 token = Token.objects.create( |                 token = Token.objects.create( | ||||||
|                     identifier=slugify(f"service-account-{username}-password"), |                     identifier=slugify(f"service-account-{username}-password"), | ||||||
|                     intent=TokenIntents.INTENT_APP_PASSWORD, |                     intent=TokenIntents.INTENT_APP_PASSWORD, | ||||||
|                     user=user, |                     user=user, | ||||||
|                     expires=now() + timedelta(days=360), |                     expires=now() + timedelta(days=360), | ||||||
|                 ) |                 ) | ||||||
|                 return Response({"username": user.username, "token": token.key}) |                 response["token"] = token.key | ||||||
|  |                 return Response(response) | ||||||
|             except (IntegrityError) as exc: |             except (IntegrityError) as exc: | ||||||
|                 return Response(data={"non_field_errors": [str(exc)]}, status=400) |                 return Response(data={"non_field_errors": [str(exc)]}, status=400) | ||||||
|  |  | ||||||
| @ -335,11 +386,12 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         serializer = SessionUserSerializer( |         serializer = SessionUserSerializer( | ||||||
|             data={"user": UserSelfSerializer(instance=request.user, context=context).data} |             data={"user": UserSelfSerializer(instance=request.user, context=context).data} | ||||||
|         ) |         ) | ||||||
|         if SESSION_IMPERSONATE_USER in request._request.session: |         if SESSION_KEY_IMPERSONATE_USER in request._request.session: | ||||||
|             serializer.initial_data["original"] = UserSelfSerializer( |             serializer.initial_data["original"] = UserSelfSerializer( | ||||||
|                 instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER], |                 instance=request._request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER], | ||||||
|                 context=context, |                 context=context, | ||||||
|             ).data |             ).data | ||||||
|  |         self.request.session.modified = True | ||||||
|         return Response(serializer.initial_data) |         return Response(serializer.initial_data) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.reset_user_password") |     @permission_required("authentik_core.reset_user_password") | ||||||
| @ -366,7 +418,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         except (ValidationError, IntegrityError) as exc: |         except (ValidationError, IntegrityError) as exc: | ||||||
|             LOGGER.debug("Failed to set password", exc=exc) |             LOGGER.debug("Failed to set password", exc=exc) | ||||||
|             return Response(status=400) |             return Response(status=400) | ||||||
|         if user.pk == request.user.pk and SESSION_IMPERSONATE_USER not in self.request.session: |         if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session: | ||||||
|             LOGGER.debug("Updating session hash after password change") |             LOGGER.debug("Updating session hash after password change") | ||||||
|             update_session_auth_hash(self.request, user) |             update_session_auth_hash(self.request, user) | ||||||
|         return Response(status=204) |         return Response(status=204) | ||||||
| @ -459,3 +511,32 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         if self.request.user.has_perm("authentik_core.view_user"): |         if self.request.user.has_perm("authentik_core.view_user"): | ||||||
|             return self._filter_queryset_for_list(queryset) |             return self._filter_queryset_for_list(queryset) | ||||||
|         return super().filter_queryset(queryset) |         return super().filter_queryset(queryset) | ||||||
|  |  | ||||||
|  |     @extend_schema( | ||||||
|  |         responses={ | ||||||
|  |             200: inline_serializer( | ||||||
|  |                 "UserPathSerializer", {"paths": ListField(child=CharField(), read_only=True)} | ||||||
|  |             ) | ||||||
|  |         }, | ||||||
|  |         parameters=[ | ||||||
|  |             OpenApiParameter( | ||||||
|  |                 name="search", | ||||||
|  |                 location=OpenApiParameter.QUERY, | ||||||
|  |                 type=OpenApiTypes.STR, | ||||||
|  |             ) | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     @action(detail=False, pagination_class=None) | ||||||
|  |     def paths(self, request: Request) -> Response: | ||||||
|  |         """Get all user paths""" | ||||||
|  |         return Response( | ||||||
|  |             data={ | ||||||
|  |                 "paths": list( | ||||||
|  |                     self.filter_queryset(self.get_queryset()) | ||||||
|  |                     .values("path") | ||||||
|  |                     .distinct() | ||||||
|  |                     .order_by("path") | ||||||
|  |                     .values_list("path", flat=True) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -2,10 +2,7 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.db import ProgrammingError | from django.conf import settings | ||||||
|  |  | ||||||
| from authentik.core.signals import GAUGE_MODELS |  | ||||||
| from authentik.lib.utils.reflection import get_apps |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikCoreConfig(AppConfig): | class AuthentikCoreConfig(AppConfig): | ||||||
| @ -19,12 +16,7 @@ class AuthentikCoreConfig(AppConfig): | |||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.core.signals") |         import_module("authentik.core.signals") | ||||||
|         import_module("authentik.core.managed") |         import_module("authentik.core.managed") | ||||||
|         try: |         if settings.DEBUG: | ||||||
|             for app in get_apps(): |             from authentik.root.celery import worker_ready_hook | ||||||
|                 for model in app.get_models(): |  | ||||||
|                     GAUGE_MODELS.labels( |             worker_ready_hook() | ||||||
|                         model_name=model._meta.model_name, |  | ||||||
|                         app=model._meta.app_label, |  | ||||||
|                     ).set(model.objects.count()) |  | ||||||
|         except ProgrammingError: |  | ||||||
|             pass |  | ||||||
|  | |||||||
| @ -12,5 +12,6 @@ class CoreManager(ObjectManager): | |||||||
|                 Source, |                 Source, | ||||||
|                 "goauthentik.io/sources/inbuilt", |                 "goauthentik.io/sources/inbuilt", | ||||||
|                 name="authentik Built-in", |                 name="authentik Built-in", | ||||||
|  |                 slug="authentik-built-in", | ||||||
|             ), |             ), | ||||||
|         ] |         ] | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								authentik/core/management/commands/bootstrap_tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								authentik/core/management/commands/bootstrap_tasks.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | """Run bootstrap tasks""" | ||||||
|  | from django.core.management.base import BaseCommand | ||||||
|  |  | ||||||
|  | from authentik.root.celery import _get_startup_tasks | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Command(BaseCommand):  # pragma: no cover | ||||||
|  |     """Run bootstrap tasks to ensure certain objects are created""" | ||||||
|  |  | ||||||
|  |     def handle(self, **options): | ||||||
|  |         tasks = _get_startup_tasks() | ||||||
|  |         for task in tasks: | ||||||
|  |             task() | ||||||
| @ -2,6 +2,7 @@ | |||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.contrib.auth.management import create_permissions | from django.contrib.auth.management import create_permissions | ||||||
| from django.core.management.base import BaseCommand, no_translations | from django.core.management.base import BaseCommand, no_translations | ||||||
|  | from guardian.management import create_anonymous_user | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class Command(BaseCommand):  # pragma: no cover | class Command(BaseCommand):  # pragma: no cover | ||||||
| @ -13,3 +14,4 @@ class Command(BaseCommand):  # pragma: no cover | |||||||
|         for app in apps.get_app_configs(): |         for app in apps.get_app_configs(): | ||||||
|             self.stdout.write(f"Checking app {app.name} ({app.label})\n") |             self.stdout.write(f"Checking app {app.name} ({app.label})\n") | ||||||
|             create_permissions(app, verbosity=0) |             create_permissions(app, verbosity=0) | ||||||
|  |         create_anonymous_user(None, using="default") | ||||||
| @ -7,8 +7,8 @@ from uuid import uuid4 | |||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from sentry_sdk.api import set_tag | from sentry_sdk.api import set_tag | ||||||
|  |  | ||||||
| SESSION_IMPERSONATE_USER = "authentik_impersonate_user" | SESSION_KEY_IMPERSONATE_USER = "authentik/impersonate/user" | ||||||
| SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user" | SESSION_KEY_IMPERSONATE_ORIGINAL_USER = "authentik/impersonate/original_user" | ||||||
| LOCAL = local() | LOCAL = local() | ||||||
| RESPONSE_HEADER_ID = "X-authentik-id" | RESPONSE_HEADER_ID = "X-authentik-id" | ||||||
| KEY_AUTH_VIA = "auth_via" | KEY_AUTH_VIA = "auth_via" | ||||||
| @ -25,10 +25,10 @@ class ImpersonateMiddleware: | |||||||
|  |  | ||||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: |     def __call__(self, request: HttpRequest) -> HttpResponse: | ||||||
|         # No permission checks are done here, they need to be checked before |         # No permission checks are done here, they need to be checked before | ||||||
|         # SESSION_IMPERSONATE_USER is set. |         # SESSION_KEY_IMPERSONATE_USER is set. | ||||||
|  |  | ||||||
|         if SESSION_IMPERSONATE_USER in request.session: |         if SESSION_KEY_IMPERSONATE_USER in request.session: | ||||||
|             request.user = request.session[SESSION_IMPERSONATE_USER] |             request.user = request.session[SESSION_KEY_IMPERSONATE_USER] | ||||||
|             # Ensure that the user is active, otherwise nothing will work |             # Ensure that the user is active, otherwise nothing will work | ||||||
|             request.user.is_active = True |             request.user.is_active = True | ||||||
|  |  | ||||||
|  | |||||||
| @ -12,18 +12,25 @@ import authentik.core.models | |||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     # We have to use a direct import here, otherwise we get an object manager error |     from django.contrib.auth.hashers import make_password | ||||||
|     from authentik.core.models import User |  | ||||||
|  |  | ||||||
|  |     User = apps.get_model("authentik_core", "User") | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|     akadmin, _ = User.objects.using(db_alias).get_or_create( |     akadmin, _ = User.objects.using(db_alias).get_or_create( | ||||||
|         username="akadmin", email="root@localhost", name="authentik Default Admin" |         username="akadmin", email="root@localhost", name="authentik Default Admin" | ||||||
|     ) |     ) | ||||||
|     if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST: |     password = None | ||||||
|         akadmin.set_password(environ.get("AK_ADMIN_PASS", "akadmin"), signal=False)  # noqa # nosec |     if "TF_BUILD" in environ or settings.TEST: | ||||||
|  |         password = "akadmin"  # noqa # nosec | ||||||
|  |     if "AK_ADMIN_PASS" in environ: | ||||||
|  |         password = environ["AK_ADMIN_PASS"] | ||||||
|  |     if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ: | ||||||
|  |         password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] | ||||||
|  |     if password: | ||||||
|  |         akadmin.password = make_password(password) | ||||||
|     else: |     else: | ||||||
|         akadmin.set_unusable_password() |         akadmin.password = make_password(None) | ||||||
|     akadmin.save() |     akadmin.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -8,18 +8,25 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | |||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     # We have to use a direct import here, otherwise we get an object manager error |     from django.contrib.auth.hashers import make_password | ||||||
|     from authentik.core.models import User |  | ||||||
|  |  | ||||||
|  |     User = apps.get_model("authentik_core", "User") | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|     akadmin, _ = User.objects.using(db_alias).get_or_create( |     akadmin, _ = User.objects.using(db_alias).get_or_create( | ||||||
|         username="akadmin", email="root@localhost", name="authentik Default Admin" |         username="akadmin", email="root@localhost", name="authentik Default Admin" | ||||||
|     ) |     ) | ||||||
|     if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST: |     password = None | ||||||
|         akadmin.set_password(environ.get("AK_ADMIN_PASS", "akadmin"), signal=False)  # noqa # nosec |     if "TF_BUILD" in environ or settings.TEST: | ||||||
|  |         password = "akadmin"  # noqa # nosec | ||||||
|  |     if "AK_ADMIN_PASS" in environ: | ||||||
|  |         password = environ["AK_ADMIN_PASS"] | ||||||
|  |     if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ: | ||||||
|  |         password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] | ||||||
|  |     if password: | ||||||
|  |         akadmin.password = make_password(password) | ||||||
|     else: |     else: | ||||||
|         akadmin.set_unusable_password() |         akadmin.password = make_password(None) | ||||||
|     akadmin.save() |     akadmin.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -36,22 +36,29 @@ def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     # We have to use a direct import here, otherwise we get an object manager error |     from authentik.core.models import TokenIntents | ||||||
|     from authentik.core.models import Token, TokenIntents, User |  | ||||||
|  |     User = apps.get_model("authentik_core", "User") | ||||||
|  |     Token = apps.get_model("authentik_core", "Token") | ||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|     akadmin = User.objects.using(db_alias).filter(username="akadmin") |     akadmin = User.objects.using(db_alias).filter(username="akadmin") | ||||||
|     if not akadmin.exists(): |     if not akadmin.exists(): | ||||||
|         return |         return | ||||||
|     if "AK_ADMIN_TOKEN" not in environ: |     key = None | ||||||
|  |     if "AK_ADMIN_TOKEN" in environ: | ||||||
|  |         key = environ["AK_ADMIN_TOKEN"] | ||||||
|  |     if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ: | ||||||
|  |         key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"] | ||||||
|  |     if not key: | ||||||
|         return |         return | ||||||
|     Token.objects.using(db_alias).create( |     Token.objects.using(db_alias).create( | ||||||
|         identifier="authentik-boostrap-token", |         identifier="authentik-bootstrap-token", | ||||||
|         user=akadmin.first(), |         user=akadmin.first(), | ||||||
|         intent=TokenIntents.INTENT_API, |         intent=TokenIntents.INTENT_API, | ||||||
|         expiring=False, |         expiring=False, | ||||||
|         key=environ["AK_ADMIN_TOKEN"], |         key=key, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -0,0 +1,20 @@ | |||||||
|  | # Generated by Django 4.0.5 on 2022-06-04 06:54 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_core", "0019_application_group"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="application", | ||||||
|  |             name="open_in_new_tab", | ||||||
|  |             field=models.BooleanField( | ||||||
|  |                 default=False, help_text="Open launch URL in a new browser tab or window." | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										23
									
								
								authentik/core/migrations/0021_source_user_path_user_path.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/core/migrations/0021_source_user_path_user_path.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | # Generated by Django 4.0.5 on 2022-06-13 18:51 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_core", "0020_application_open_in_new_tab"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="source", | ||||||
|  |             name="user_path_template", | ||||||
|  |             field=models.TextField(default="goauthentik.io/sources/%(slug)s"), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="user", | ||||||
|  |             name="path", | ||||||
|  |             field=models.TextField(default="users"), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -7,22 +7,29 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | |||||||
|  |  | ||||||
|  |  | ||||||
| def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     # We have to use a direct import here, otherwise we get an object manager error |     from authentik.core.models import TokenIntents | ||||||
|     from authentik.core.models import Token, TokenIntents, User |  | ||||||
|  |     User = apps.get_model("authentik_core", "User") | ||||||
|  |     Token = apps.get_model("authentik_core", "Token") | ||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|     akadmin = User.objects.using(db_alias).filter(username="akadmin") |     akadmin = User.objects.using(db_alias).filter(username="akadmin") | ||||||
|     if not akadmin.exists(): |     if not akadmin.exists(): | ||||||
|         return |         return | ||||||
|     if "AK_ADMIN_TOKEN" not in environ: |     key = None | ||||||
|  |     if "AK_ADMIN_TOKEN" in environ: | ||||||
|  |         key = environ["AK_ADMIN_TOKEN"] | ||||||
|  |     if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ: | ||||||
|  |         key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"] | ||||||
|  |     if not key: | ||||||
|         return |         return | ||||||
|     Token.objects.using(db_alias).create( |     Token.objects.using(db_alias).create( | ||||||
|         identifier="authentik-boostrap-token", |         identifier="authentik-bootstrap-token", | ||||||
|         user=akadmin.first(), |         user=akadmin.first(), | ||||||
|         intent=TokenIntents.INTENT_API, |         intent=TokenIntents.INTENT_API, | ||||||
|         expiring=False, |         expiring=False, | ||||||
|         key=environ["AK_ADMIN_TOKEN"], |         key=key, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -46,6 +46,9 @@ USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name" | |||||||
| USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email" | USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email" | ||||||
| USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | ||||||
|  |  | ||||||
|  | USER_PATH_SYSTEM_PREFIX = "goauthentik.io" | ||||||
|  | USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts" | ||||||
|  |  | ||||||
| GRAVATAR_URL = "https://secure.gravatar.com" | GRAVATAR_URL = "https://secure.gravatar.com" | ||||||
| DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | ||||||
|  |  | ||||||
| @ -103,7 +106,10 @@ class Group(models.Model): | |||||||
|  |  | ||||||
|             SELECT authentik_core_group.*, parents.relative_depth - 1 |             SELECT authentik_core_group.*, parents.relative_depth - 1 | ||||||
|             FROM authentik_core_group,parents |             FROM authentik_core_group,parents | ||||||
|             WHERE authentik_core_group.parent_id = parents.group_uuid |             WHERE ( | ||||||
|  |                 authentik_core_group.parent_id = parents.group_uuid and | ||||||
|  |                 parents.relative_depth > -20 | ||||||
|  |             ) | ||||||
|         ) |         ) | ||||||
|         SELECT group_uuid |         SELECT group_uuid | ||||||
|         FROM parents |         FROM parents | ||||||
| @ -138,6 +144,7 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|  |  | ||||||
|     uuid = models.UUIDField(default=uuid4, editable=False) |     uuid = models.UUIDField(default=uuid4, editable=False) | ||||||
|     name = models.TextField(help_text=_("User's display name.")) |     name = models.TextField(help_text=_("User's display name.")) | ||||||
|  |     path = models.TextField(default="users") | ||||||
|  |  | ||||||
|     sources = models.ManyToManyField("Source", through="UserSourceConnection") |     sources = models.ManyToManyField("Source", through="UserSourceConnection") | ||||||
|     ak_groups = models.ManyToManyField("Group", related_name="users") |     ak_groups = models.ManyToManyField("Group", related_name="users") | ||||||
| @ -147,6 +154,11 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|  |  | ||||||
|     objects = UserManager() |     objects = UserManager() | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def default_path() -> str: | ||||||
|  |         """Get the default user path""" | ||||||
|  |         return User._meta.get_field("path").default | ||||||
|  |  | ||||||
|     def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]: |     def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]: | ||||||
|         """Get a dictionary containing the attributes from all groups the user belongs to, |         """Get a dictionary containing the attributes from all groups the user belongs to, | ||||||
|         including the users attributes""" |         including the users attributes""" | ||||||
| @ -192,7 +204,7 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def uid(self) -> str: |     def uid(self) -> str: | ||||||
|         """Generate a globall unique UID, based on the user ID and the hashed secret key""" |         """Generate a globally unique UID, based on the user ID and the hashed secret key""" | ||||||
|         return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() |         return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -278,6 +290,11 @@ class Application(PolicyBindingModel): | |||||||
|     meta_launch_url = models.TextField( |     meta_launch_url = models.TextField( | ||||||
|         default="", blank=True, validators=[DomainlessURLValidator()] |         default="", blank=True, validators=[DomainlessURLValidator()] | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     open_in_new_tab = models.BooleanField( | ||||||
|  |         default=False, help_text=_("Open launch URL in a new browser tab or window.") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     # For template applications, this can be set to /static/authentik/applications/* |     # For template applications, this can be set to /static/authentik/applications/* | ||||||
|     meta_icon = models.FileField( |     meta_icon = models.FileField( | ||||||
|         upload_to="application-icons/", |         upload_to="application-icons/", | ||||||
| @ -368,6 +385,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|     name = models.TextField(help_text=_("Source's display Name.")) |     name = models.TextField(help_text=_("Source's display Name.")) | ||||||
|     slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True) |     slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True) | ||||||
|  |  | ||||||
|  |     user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s") | ||||||
|  |  | ||||||
|     enabled = models.BooleanField(default=True) |     enabled = models.BooleanField(default=True) | ||||||
|     property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) |     property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) | ||||||
|  |  | ||||||
| @ -403,6 +422,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|  |  | ||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  |  | ||||||
|  |     def get_user_path(self) -> str: | ||||||
|  |         """Get user path, fallback to default for formatting errors""" | ||||||
|  |         try: | ||||||
|  |             return self.user_path_template % { | ||||||
|  |                 "slug": self.slug, | ||||||
|  |             } | ||||||
|  |         # pylint: disable=broad-except | ||||||
|  |         except Exception as exc: | ||||||
|  |             LOGGER.warning("Failed to template user path", exc=exc, source=self) | ||||||
|  |             return User.default_path() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def component(self) -> str: |     def component(self) -> str: | ||||||
|         """Return component used to edit this object""" |         """Return component used to edit this object""" | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """authentik core signals""" | """authentik core signals""" | ||||||
| from typing import TYPE_CHECKING | from typing import TYPE_CHECKING | ||||||
|  |  | ||||||
| from django.apps import apps |  | ||||||
| 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.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| @ -10,30 +9,16 @@ 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.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 authentik.root.monitoring import monitoring_set |  | ||||||
|  |  | ||||||
| # Arguments: user: User, password: str | # Arguments: user: User, password: str | ||||||
| password_changed = Signal() | password_changed = Signal() | ||||||
|  | # Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage | ||||||
| GAUGE_MODELS = Gauge("authentik_models", "Count of various objects", ["model_name", "app"]) | login_failed = Signal() | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from authentik.core.models import AuthenticatedSession, User |     from authentik.core.models import AuthenticatedSession, User | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(monitoring_set) |  | ||||||
| # pylint: disable=unused-argument |  | ||||||
| def monitoring_set_models(sender, **kwargs): |  | ||||||
|     """set models gauges""" |  | ||||||
|     for model in apps.get_models(): |  | ||||||
|         GAUGE_MODELS.labels( |  | ||||||
|             model_name=model._meta.model_name, |  | ||||||
|             app=model._meta.app_label, |  | ||||||
|         ).set(model.objects.count()) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def post_save_application(sender: type[Model], instance, created: bool, **_): | def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||||
|  | |||||||
| @ -26,11 +26,11 @@ from authentik.flows.planner import ( | |||||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | from authentik.flows.views.executor 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.denied import AccessDeniedResponse | from authentik.policies.denied import AccessDeniedResponse | ||||||
| from authentik.policies.types import PolicyResult |  | ||||||
| from authentik.policies.utils import delete_none_keys | from authentik.policies.utils import delete_none_keys | ||||||
| from authentik.stages.password import BACKEND_INBUILT | from authentik.stages.password import BACKEND_INBUILT | ||||||
| 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_write.stage import PLAN_CONTEXT_USER_PATH | ||||||
|  |  | ||||||
|  |  | ||||||
| class Action(Enum): | class Action(Enum): | ||||||
| @ -165,9 +165,9 @@ class SourceFlowManager: | |||||||
|                     return self.handle_enroll(connection) |                     return self.handle_enroll(connection) | ||||||
|         except FlowNonApplicableException as exc: |         except FlowNonApplicableException as exc: | ||||||
|             self._logger.warning("Flow non applicable", exc=exc) |             self._logger.warning("Flow non applicable", exc=exc) | ||||||
|             return self.error_handler(exc, exc.policy_result) |             return self.error_handler(exc) | ||||||
|         # Default case, assume deny |         # Default case, assume deny | ||||||
|         error = ( |         error = Exception( | ||||||
|             _( |             _( | ||||||
|                 ( |                 ( | ||||||
|                     "Request to authenticate with %(source)s has been denied. Please authenticate " |                     "Request to authenticate with %(source)s has been denied. Please authenticate " | ||||||
| @ -178,14 +178,13 @@ class SourceFlowManager: | |||||||
|         ) |         ) | ||||||
|         return self.error_handler(error) |         return self.error_handler(error) | ||||||
|  |  | ||||||
|     def error_handler( |     def error_handler(self, error: Exception) -> HttpResponse: | ||||||
|         self, error: Exception, policy_result: Optional[PolicyResult] = None |  | ||||||
|     ) -> HttpResponse: |  | ||||||
|         """Handle any errors by returning an access denied stage""" |         """Handle any errors by returning an access denied stage""" | ||||||
|         response = AccessDeniedResponse(self.request) |         response = AccessDeniedResponse(self.request) | ||||||
|         response.error_message = str(error) |         response.error_message = str(error) | ||||||
|         if policy_result: |         if isinstance(error, FlowNonApplicableException): | ||||||
|             response.policy_result = policy_result |             response.policy_result = error.policy_result | ||||||
|  |             response.error_message = error.messages | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
| @ -291,5 +290,6 @@ class SourceFlowManager: | |||||||
|             connection, |             connection, | ||||||
|             **{ |             **{ | ||||||
|                 PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), |                 PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), | ||||||
|  |                 PLAN_CONTEXT_USER_PATH: self.source.get_user_path(), | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -5,11 +5,14 @@ | |||||||
|  |  | ||||||
| {% block head_before %} | {% block head_before %} | ||||||
| {{ block.super }} | {{ block.super }} | ||||||
|  | <link rel="prefetch" href="{{ flow.background_url }}" /> | ||||||
| {% if flow.compatibility_mode and not inspector %} | {% if flow.compatibility_mode and not inspector %} | ||||||
| <script>ShadyDOM = { force: !navigator.webdriver };</script> | <script>ShadyDOM = { force: !navigator.webdriver };</script> | ||||||
| {% endif %} | {% endif %} | ||||||
| <script> | <script> | ||||||
| window.authentik = {}; | window.authentik = { | ||||||
|  |     "locale": "{{ tenant.default_locale }}", | ||||||
|  | }; | ||||||
| window.authentik.flow = { | window.authentik.flow = { | ||||||
|     "layout": "{{ flow.layout }}", |     "layout": "{{ flow.layout }}", | ||||||
| }; | }; | ||||||
| @ -19,7 +22,7 @@ window.authentik.flow = { | |||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script> | <script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script> | ||||||
| <style> | <style> | ||||||
| .pf-c-background-image::before { | :root { | ||||||
|     --ak-flow-background: url("{{ flow.background_url }}"); |     --ak-flow-background: url("{{ flow.background_url }}"); | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|  | |||||||
| @ -4,13 +4,19 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| {% block head_before %} | {% block head_before %} | ||||||
|  | <link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" /> | ||||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}"> | <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}"> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <style> | <style> | ||||||
| .pf-c-background-image::before { | :root { | ||||||
|     --ak-flow-background: url("/static/dist/assets/images/flow_background.jpg"); |     --ak-flow-background: url("/static/dist/assets/images/flow_background.jpg"); | ||||||
|  |     --pf-c-background-image--BackgroundImage: var(--ak-flow-background); | ||||||
|  |     --pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background); | ||||||
|  |     --pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background); | ||||||
|  |     --pf-c-background-image--BackgroundImage--sm-2x: var(--ak-flow-background); | ||||||
|  |     --pf-c-background-image--BackgroundImage--lg: var(--ak-flow-background); | ||||||
| } | } | ||||||
| /* Form with user */ | /* Form with user */ | ||||||
| .form-control-static { | .form-control-static { | ||||||
|  | |||||||
| @ -29,6 +29,7 @@ class TestApplicationsAPI(APITestCase): | |||||||
|             name="allowed", |             name="allowed", | ||||||
|             slug="allowed", |             slug="allowed", | ||||||
|             meta_launch_url="https://goauthentik.io/%(username)s", |             meta_launch_url="https://goauthentik.io/%(username)s", | ||||||
|  |             open_in_new_tab=True, | ||||||
|             provider=self.provider, |             provider=self.provider, | ||||||
|         ) |         ) | ||||||
|         self.denied = Application.objects.create(name="denied", slug="denied") |         self.denied = Application.objects.create(name="denied", slug="denied") | ||||||
| @ -100,6 +101,7 @@ class TestApplicationsAPI(APITestCase): | |||||||
|                         }, |                         }, | ||||||
|                         "launch_url": f"https://goauthentik.io/{self.user.username}", |                         "launch_url": f"https://goauthentik.io/{self.user.username}", | ||||||
|                         "meta_launch_url": "https://goauthentik.io/%(username)s", |                         "meta_launch_url": "https://goauthentik.io/%(username)s", | ||||||
|  |                         "open_in_new_tab": True, | ||||||
|                         "meta_icon": None, |                         "meta_icon": None, | ||||||
|                         "meta_description": "", |                         "meta_description": "", | ||||||
|                         "meta_publisher": "", |                         "meta_publisher": "", | ||||||
| @ -148,6 +150,7 @@ class TestApplicationsAPI(APITestCase): | |||||||
|                         }, |                         }, | ||||||
|                         "launch_url": f"https://goauthentik.io/{self.user.username}", |                         "launch_url": f"https://goauthentik.io/{self.user.username}", | ||||||
|                         "meta_launch_url": "https://goauthentik.io/%(username)s", |                         "meta_launch_url": "https://goauthentik.io/%(username)s", | ||||||
|  |                         "open_in_new_tab": True, | ||||||
|                         "meta_icon": None, |                         "meta_icon": None, | ||||||
|                         "meta_description": "", |                         "meta_description": "", | ||||||
|                         "meta_publisher": "", |                         "meta_publisher": "", | ||||||
| @ -158,6 +161,7 @@ class TestApplicationsAPI(APITestCase): | |||||||
|                         "meta_description": "", |                         "meta_description": "", | ||||||
|                         "meta_icon": None, |                         "meta_icon": None, | ||||||
|                         "meta_launch_url": "", |                         "meta_launch_url": "", | ||||||
|  |                         "open_in_new_tab": False, | ||||||
|                         "meta_publisher": "", |                         "meta_publisher": "", | ||||||
|                         "group": "", |                         "group": "", | ||||||
|                         "name": "denied", |                         "name": "denied", | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
| from django.test.testcases import TestCase | from django.test.testcases import TestCase | ||||||
|  |  | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestGroups(TestCase): | class TestGroups(TestCase): | ||||||
| @ -9,32 +10,43 @@ class TestGroups(TestCase): | |||||||
|  |  | ||||||
|     def test_group_membership_simple(self): |     def test_group_membership_simple(self): | ||||||
|         """Test simple membership""" |         """Test simple membership""" | ||||||
|         user = User.objects.create(username="user") |         user = User.objects.create(username=generate_id()) | ||||||
|         user2 = User.objects.create(username="user2") |         user2 = User.objects.create(username=generate_id()) | ||||||
|         group = Group.objects.create(name="group") |         group = Group.objects.create(name=generate_id()) | ||||||
|         group.users.add(user) |         group.users.add(user) | ||||||
|         self.assertTrue(group.is_member(user)) |         self.assertTrue(group.is_member(user)) | ||||||
|         self.assertFalse(group.is_member(user2)) |         self.assertFalse(group.is_member(user2)) | ||||||
|  |  | ||||||
|     def test_group_membership_parent(self): |     def test_group_membership_parent(self): | ||||||
|         """Test parent membership""" |         """Test parent membership""" | ||||||
|         user = User.objects.create(username="user") |         user = User.objects.create(username=generate_id()) | ||||||
|         user2 = User.objects.create(username="user2") |         user2 = User.objects.create(username=generate_id()) | ||||||
|         first = Group.objects.create(name="first") |         first = Group.objects.create(name=generate_id()) | ||||||
|         second = Group.objects.create(name="second", parent=first) |         second = Group.objects.create(name=generate_id(), parent=first) | ||||||
|         second.users.add(user) |         second.users.add(user) | ||||||
|         self.assertTrue(first.is_member(user)) |         self.assertTrue(first.is_member(user)) | ||||||
|         self.assertFalse(first.is_member(user2)) |         self.assertFalse(first.is_member(user2)) | ||||||
|  |  | ||||||
|     def test_group_membership_parent_extra(self): |     def test_group_membership_parent_extra(self): | ||||||
|         """Test parent membership""" |         """Test parent membership""" | ||||||
|         user = User.objects.create(username="user") |         user = User.objects.create(username=generate_id()) | ||||||
|         user2 = User.objects.create(username="user2") |         user2 = User.objects.create(username=generate_id()) | ||||||
|         first = Group.objects.create(name="first") |         first = Group.objects.create(name=generate_id()) | ||||||
|         second = Group.objects.create(name="second", parent=first) |         second = Group.objects.create(name=generate_id(), parent=first) | ||||||
|         third = Group.objects.create(name="third", parent=second) |         third = Group.objects.create(name=generate_id(), parent=second) | ||||||
|         second.users.add(user) |         second.users.add(user) | ||||||
|         self.assertTrue(first.is_member(user)) |         self.assertTrue(first.is_member(user)) | ||||||
|         self.assertFalse(first.is_member(user2)) |         self.assertFalse(first.is_member(user2)) | ||||||
|         self.assertFalse(third.is_member(user)) |         self.assertFalse(third.is_member(user)) | ||||||
|         self.assertFalse(third.is_member(user2)) |         self.assertFalse(third.is_member(user2)) | ||||||
|  |  | ||||||
|  |     def test_group_membership_recursive(self): | ||||||
|  |         """Test group membership (recursive)""" | ||||||
|  |         user = User.objects.create(username=generate_id()) | ||||||
|  |         group = Group.objects.create(name=generate_id()) | ||||||
|  |         group2 = Group.objects.create(name=generate_id(), parent=group) | ||||||
|  |         group.users.add(user) | ||||||
|  |         group.parent = group2 | ||||||
|  |         group.save() | ||||||
|  |         self.assertTrue(group.is_member(user)) | ||||||
|  |         self.assertTrue(group2.is_member(user)) | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from rest_framework.test import APITestCase | |||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | ||||||
| from authentik.flows.models import FlowDesignation | from authentik.flows.models import FlowDesignation | ||||||
| from authentik.lib.generators import generate_key | from authentik.lib.generators import generate_id, generate_key | ||||||
| from authentik.stages.email.models import EmailStage | from authentik.stages.email.models import EmailStage | ||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
| @ -149,3 +149,65 @@ class TestUsersAPI(APITestCase): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 400) |         self.assertEqual(response.status_code, 400) | ||||||
|  |  | ||||||
|  |     def test_paths(self): | ||||||
|  |         """Test path""" | ||||||
|  |         self.client.force_login(self.admin) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:user-paths"), | ||||||
|  |         ) | ||||||
|  |         print(response.content) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertJSONEqual(response.content.decode(), {"paths": ["users"]}) | ||||||
|  |  | ||||||
|  |     def test_path_valid(self): | ||||||
|  |         """Test path""" | ||||||
|  |         self.client.force_login(self.admin) | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:user-list"), | ||||||
|  |             data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |  | ||||||
|  |     def test_path_invalid(self): | ||||||
|  |         """Test path (invalid)""" | ||||||
|  |         self.client.force_login(self.admin) | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:user-list"), | ||||||
|  |             data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), {"path": ["No leading or trailing slashes allowed."]} | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.client.force_login(self.admin) | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:user-list"), | ||||||
|  |             data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]}) | ||||||
|  |  | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:user-list"), | ||||||
|  |             data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), {"path": ["No leading or trailing slashes allowed."]} | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:user-list"), | ||||||
|  |             data={ | ||||||
|  |                 "name": generate_id(), | ||||||
|  |                 "username": generate_id(), | ||||||
|  |                 "groups": [], | ||||||
|  |                 "path": "fos//o", | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), {"path": ["No empty segments in user path allowed."]} | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -11,14 +11,13 @@ from authentik.lib.generators import generate_id | |||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_test_flow(designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION) -> Flow: | def create_test_flow( | ||||||
|  |     designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION, **kwargs | ||||||
|  | ) -> Flow: | ||||||
|     """Generate a flow that can be used for testing""" |     """Generate a flow that can be used for testing""" | ||||||
|     uid = generate_id(10) |     uid = generate_id(10) | ||||||
|     return Flow.objects.create( |     return Flow.objects.create( | ||||||
|         name=uid, |         name=uid, title=uid, slug=slugify(uid), designation=designation, **kwargs | ||||||
|         title=uid, |  | ||||||
|         slug=slugify(uid), |  | ||||||
|         designation=designation, |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -47,11 +46,11 @@ def create_test_tenant() -> Tenant: | |||||||
|  |  | ||||||
| def create_test_cert() -> CertificateKeyPair: | def create_test_cert() -> CertificateKeyPair: | ||||||
|     """Generate a certificate for testing""" |     """Generate a certificate for testing""" | ||||||
|     CertificateKeyPair.objects.filter(name="goauthentik.io").delete() |  | ||||||
|     builder = CertificateBuilder() |     builder = CertificateBuilder() | ||||||
|     builder.common_name = "goauthentik.io" |     builder.common_name = "goauthentik.io" | ||||||
|     builder.build( |     builder.build( | ||||||
|         subject_alt_names=["goauthentik.io"], |         subject_alt_names=["goauthentik.io"], | ||||||
|         validity_days=360, |         validity_days=360, | ||||||
|     ) |     ) | ||||||
|  |     builder.name = generate_id() | ||||||
|     return builder.save() |     return builder.save() | ||||||
|  | |||||||
| @ -14,7 +14,9 @@ from authentik.core.views.session import EndSessionView | |||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path( |     path( | ||||||
|         "", |         "", | ||||||
|         login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")), |         login_required( | ||||||
|  |             RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True) | ||||||
|  |         ), | ||||||
|         name="root-redirect", |         name="root-redirect", | ||||||
|     ), |     ), | ||||||
|     path( |     path( | ||||||
|  | |||||||
| @ -5,7 +5,10 @@ from django.shortcuts import get_object_or_404, redirect | |||||||
| from django.views import View | from django.views import View | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER | from authentik.core.middleware import ( | ||||||
|  |     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, | ||||||
|  |     SESSION_KEY_IMPERSONATE_USER, | ||||||
|  | ) | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| @ -27,8 +30,8 @@ class ImpersonateInitView(View): | |||||||
|  |  | ||||||
|         user_to_be = get_object_or_404(User, pk=user_id) |         user_to_be = get_object_or_404(User, pk=user_id) | ||||||
|  |  | ||||||
|         request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user |         request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user | ||||||
|         request.session[SESSION_IMPERSONATE_USER] = user_to_be |         request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be | ||||||
|  |  | ||||||
|         Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) |         Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) | ||||||
|  |  | ||||||
| @ -41,16 +44,16 @@ class ImpersonateEndView(View): | |||||||
|     def get(self, request: HttpRequest) -> HttpResponse: |     def get(self, request: HttpRequest) -> HttpResponse: | ||||||
|         """End Impersonation handler""" |         """End Impersonation handler""" | ||||||
|         if ( |         if ( | ||||||
|             SESSION_IMPERSONATE_USER not in request.session |             SESSION_KEY_IMPERSONATE_USER not in request.session | ||||||
|             or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session |             or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session | ||||||
|         ): |         ): | ||||||
|             LOGGER.debug("Can't end impersonation", user=request.user) |             LOGGER.debug("Can't end impersonation", user=request.user) | ||||||
|             return redirect("authentik_core:if-user") |             return redirect("authentik_core:if-user") | ||||||
|  |  | ||||||
|         original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER] |         original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] | ||||||
|  |  | ||||||
|         del request.session[SESSION_IMPERSONATE_USER] |         del request.session[SESSION_KEY_IMPERSONATE_USER] | ||||||
|         del request.session[SESSION_IMPERSONATE_ORIGINAL_USER] |         del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] | ||||||
|  |  | ||||||
|         Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) |         Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) | ||||||
|  |  | ||||||
|  | |||||||
| @ -53,10 +53,7 @@ class CertificateBuilder: | |||||||
|             .subject_name( |             .subject_name( | ||||||
|                 x509.Name( |                 x509.Name( | ||||||
|                     [ |                     [ | ||||||
|                         x509.NameAttribute( |                         x509.NameAttribute(NameOID.COMMON_NAME, self.common_name), | ||||||
|                             NameOID.COMMON_NAME, |  | ||||||
|                             self.common_name, |  | ||||||
|                         ), |  | ||||||
|                         x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"), |                         x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"), | ||||||
|                         x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"), |                         x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"), | ||||||
|                     ] |                     ] | ||||||
| @ -65,10 +62,7 @@ class CertificateBuilder: | |||||||
|             .issuer_name( |             .issuer_name( | ||||||
|                 x509.Name( |                 x509.Name( | ||||||
|                     [ |                     [ | ||||||
|                         x509.NameAttribute( |                         x509.NameAttribute(NameOID.COMMON_NAME, f"authentik {__version__}"), | ||||||
|                             NameOID.COMMON_NAME, |  | ||||||
|                             f"authentik {__version__}", |  | ||||||
|                         ), |  | ||||||
|                     ] |                     ] | ||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|  | |||||||
| @ -2,6 +2,8 @@ | |||||||
|  |  | ||||||
| from django.db import migrations | from django.db import migrations | ||||||
|  |  | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_self_signed(apps, schema_editor): | def create_self_signed(apps, schema_editor): | ||||||
|     CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair") |     CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair") | ||||||
| @ -9,7 +11,7 @@ def create_self_signed(apps, schema_editor): | |||||||
|     from authentik.crypto.builder import CertificateBuilder |     from authentik.crypto.builder import CertificateBuilder | ||||||
|  |  | ||||||
|     builder = CertificateBuilder() |     builder = CertificateBuilder() | ||||||
|     builder.build() |     builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"]) | ||||||
|     CertificateKeyPair.objects.using(db_alias).create( |     CertificateKeyPair.objects.using(db_alias).create( | ||||||
|         name="authentik Self-signed Certificate", |         name="authentik Self-signed Certificate", | ||||||
|         certificate_data=builder.certificate, |         certificate_data=builder.certificate, | ||||||
|  | |||||||
| @ -26,3 +26,4 @@ class NotificationWebhookMappingViewSet(UsedByMixin, ModelViewSet): | |||||||
|     serializer_class = NotificationWebhookMappingSerializer |     serializer_class = NotificationWebhookMappingSerializer | ||||||
|     filterset_fields = ["name"] |     filterset_fields = ["name"] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |     search_fields = ["name"] | ||||||
|  | |||||||
| @ -32,3 +32,4 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet): | |||||||
|     serializer_class = NotificationRuleSerializer |     serializer_class = NotificationRuleSerializer | ||||||
|     filterset_fields = ["name", "severity", "group__name"] |     filterset_fields = ["name", "severity", "group__name"] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |     search_fields = ["name", "group__name"] | ||||||
|  | |||||||
| @ -68,6 +68,7 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet): | |||||||
|     queryset = NotificationTransport.objects.all() |     queryset = NotificationTransport.objects.all() | ||||||
|     serializer_class = NotificationTransportSerializer |     serializer_class = NotificationTransportSerializer | ||||||
|     filterset_fields = ["name", "mode", "webhook_url", "send_once"] |     filterset_fields = ["name", "mode", "webhook_url", "send_once"] | ||||||
|  |     search_fields = ["name", "mode", "webhook_url"] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|     @permission_required("authentik_events.change_notificationtransport") |     @permission_required("authentik_events.change_notificationtransport") | ||||||
|  | |||||||
| @ -2,6 +2,13 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
|  | from prometheus_client import Gauge | ||||||
|  |  | ||||||
|  | GAUGE_TASKS = Gauge( | ||||||
|  |     "authentik_system_tasks", | ||||||
|  |     "System tasks and their status", | ||||||
|  |     ["task_name", "task_uid", "status"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikEventsConfig(AppConfig): | class AuthentikEventsConfig(AppConfig): | ||||||
|  | |||||||
| @ -76,11 +76,8 @@ class GeoIPReader: | |||||||
|             except (GeoIP2Error, ValueError): |             except (GeoIP2Error, ValueError): | ||||||
|                 return None |                 return None | ||||||
|  |  | ||||||
|     def city_dict(self, ip_address: str) -> Optional[GeoIPDict]: |     def city_to_dict(self, city: City) -> GeoIPDict: | ||||||
|         """Wrapper for self.city that returns a dict""" |         """Convert City to dict""" | ||||||
|         city = self.city(ip_address) |  | ||||||
|         if not city: |  | ||||||
|             return None |  | ||||||
|         city_dict: GeoIPDict = { |         city_dict: GeoIPDict = { | ||||||
|             "continent": city.continent.code, |             "continent": city.continent.code, | ||||||
|             "country": city.country.iso_code, |             "country": city.country.iso_code, | ||||||
| @ -92,5 +89,12 @@ class GeoIPReader: | |||||||
|             city_dict["city"] = city.city.name |             city_dict["city"] = city.city.name | ||||||
|         return city_dict |         return city_dict | ||||||
|  |  | ||||||
|  |     def city_dict(self, ip_address: str) -> Optional[GeoIPDict]: | ||||||
|  |         """Wrapper for self.city that returns a dict""" | ||||||
|  |         city = self.city(ip_address) | ||||||
|  |         if not city: | ||||||
|  |             return None | ||||||
|  |         return self.city_to_dict(city) | ||||||
|  |  | ||||||
|  |  | ||||||
| GEOIP_READER = GeoIPReader() | GEOIP_READER = GeoIPReader() | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from functools import partial | |||||||
| from typing import Callable | from typing import Callable | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  | from django.contrib.sessions.models import Session | ||||||
| from django.core.exceptions import SuspiciousOperation | from django.core.exceptions import SuspiciousOperation | ||||||
| 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 | ||||||
| @ -15,6 +16,7 @@ from authentik.core.models import AuthenticatedSession, 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.flows.models import FlowToken | ||||||
| from authentik.lib.sentry import before_send | from authentik.lib.sentry import before_send | ||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
| @ -24,11 +26,13 @@ IGNORED_MODELS = [ | |||||||
|     UserObjectPermission, |     UserObjectPermission, | ||||||
|     AuthenticatedSession, |     AuthenticatedSession, | ||||||
|     StaticToken, |     StaticToken, | ||||||
|  |     Session, | ||||||
|  |     FlowToken, | ||||||
| ] | ] | ||||||
| if settings.DEBUG: | if settings.DEBUG: | ||||||
|     from silk.models import Request, Response |     from silk.models import Request, Response, SQLQuery | ||||||
|  |  | ||||||
|     IGNORED_MODELS += [Request, Response] |     IGNORED_MODELS += [Request, Response, SQLQuery] | ||||||
| IGNORED_MODELS = tuple(IGNORED_MODELS) | IGNORED_MODELS = tuple(IGNORED_MODELS) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -383,6 +383,7 @@ class Migration(migrations.Migration): | |||||||
|                     models.ManyToManyField( |                     models.ManyToManyField( | ||||||
|                         help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.", |                         help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.", | ||||||
|                         to="authentik_events.NotificationTransport", |                         to="authentik_events.NotificationTransport", | ||||||
|  |                         blank=True, | ||||||
|                     ), |                     ), | ||||||
|                 ), |                 ), | ||||||
|             ], |             ], | ||||||
|  | |||||||
| @ -0,0 +1,50 @@ | |||||||
|  | # Generated by Django 4.0.4 on 2022-05-30 18:08 | ||||||
|  | from django.apps.registry import Apps | ||||||
|  | from django.db import migrations, models | ||||||
|  | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
|  | from authentik.events.models import TransportMode | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def notify_local_transport(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|  |     NotificationTransport = apps.get_model("authentik_events", "NotificationTransport") | ||||||
|  |     NotificationRule = apps.get_model("authentik_events", "NotificationRule") | ||||||
|  |  | ||||||
|  |     local_transport, _ = NotificationTransport.objects.using(db_alias).update_or_create( | ||||||
|  |         name="default-local-transport", | ||||||
|  |         defaults={"mode": TransportMode.LOCAL}, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     for trigger in NotificationRule.objects.using(db_alias).filter( | ||||||
|  |         name__in=[ | ||||||
|  |             "default-notify-configuration-error", | ||||||
|  |             "default-notify-exception", | ||||||
|  |             "default-notify-update", | ||||||
|  |         ] | ||||||
|  |     ): | ||||||
|  |         trigger.transports.add(local_transport) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="notificationtransport", | ||||||
|  |             name="mode", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("local", "authentik inbuilt notifications"), | ||||||
|  |                     ("webhook", "Generic Webhook"), | ||||||
|  |                     ("webhook_slack", "Slack Webhook (Slack/Discord)"), | ||||||
|  |                     ("email", "Email"), | ||||||
|  |                 ], | ||||||
|  |                 default="local", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython(notify_local_transport), | ||||||
|  |     ] | ||||||
| @ -23,7 +23,10 @@ from requests import RequestException | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER | from authentik.core.middleware import ( | ||||||
|  |     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, | ||||||
|  |     SESSION_KEY_IMPERSONATE_USER, | ||||||
|  | ) | ||||||
| from authentik.core.models import ExpiringModel, Group, PropertyMapping, User | from authentik.core.models import ExpiringModel, Group, PropertyMapping, User | ||||||
| from authentik.events.geo import GEOIP_READER | from authentik.events.geo import GEOIP_READER | ||||||
| from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict | from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict | ||||||
| @ -233,15 +236,15 @@ class Event(ExpiringModel): | |||||||
|         if hasattr(request, "user"): |         if hasattr(request, "user"): | ||||||
|             original_user = None |             original_user = None | ||||||
|             if hasattr(request, "session"): |             if hasattr(request, "session"): | ||||||
|                 original_user = request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None) |                 original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None) | ||||||
|             self.user = get_user(request.user, original_user) |             self.user = get_user(request.user, original_user) | ||||||
|         if user: |         if user: | ||||||
|             self.user = get_user(user) |             self.user = get_user(user) | ||||||
|         # Check if we're currently impersonating, and add that user |         # Check if we're currently impersonating, and add that user | ||||||
|         if hasattr(request, "session"): |         if hasattr(request, "session"): | ||||||
|             if SESSION_IMPERSONATE_ORIGINAL_USER in request.session: |             if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session: | ||||||
|                 self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER]) |                 self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]) | ||||||
|                 self.user["on_behalf_of"] = get_user(request.session[SESSION_IMPERSONATE_USER]) |                 self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER]) | ||||||
|         # User 255.255.255.255 as fallback if IP cannot be determined |         # User 255.255.255.255 as fallback if IP cannot be determined | ||||||
|         self.client_ip = get_client_ip(request) |         self.client_ip = get_client_ip(request) | ||||||
|         # Apply GeoIP Data, when enabled |         # Apply GeoIP Data, when enabled | ||||||
| @ -289,6 +292,7 @@ class Event(ExpiringModel): | |||||||
| class TransportMode(models.TextChoices): | class TransportMode(models.TextChoices): | ||||||
|     """Modes that a notification transport can send a notification""" |     """Modes that a notification transport can send a notification""" | ||||||
|  |  | ||||||
|  |     LOCAL = "local", _("authentik inbuilt notifications") | ||||||
|     WEBHOOK = "webhook", _("Generic Webhook") |     WEBHOOK = "webhook", _("Generic Webhook") | ||||||
|     WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)") |     WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)") | ||||||
|     EMAIL = "email", _("Email") |     EMAIL = "email", _("Email") | ||||||
| @ -300,7 +304,7 @@ class NotificationTransport(models.Model): | |||||||
|     uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) |     uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||||
|  |  | ||||||
|     name = models.TextField(unique=True) |     name = models.TextField(unique=True) | ||||||
|     mode = models.TextField(choices=TransportMode.choices) |     mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL) | ||||||
|  |  | ||||||
|     webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()]) |     webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()]) | ||||||
|     webhook_mapping = models.ForeignKey( |     webhook_mapping = models.ForeignKey( | ||||||
| @ -315,6 +319,8 @@ class NotificationTransport(models.Model): | |||||||
|  |  | ||||||
|     def send(self, notification: "Notification") -> list[str]: |     def send(self, notification: "Notification") -> list[str]: | ||||||
|         """Send notification to user, called from async task""" |         """Send notification to user, called from async task""" | ||||||
|  |         if self.mode == TransportMode.LOCAL: | ||||||
|  |             return self.send_local(notification) | ||||||
|         if self.mode == TransportMode.WEBHOOK: |         if self.mode == TransportMode.WEBHOOK: | ||||||
|             return self.send_webhook(notification) |             return self.send_webhook(notification) | ||||||
|         if self.mode == TransportMode.WEBHOOK_SLACK: |         if self.mode == TransportMode.WEBHOOK_SLACK: | ||||||
| @ -323,6 +329,17 @@ class NotificationTransport(models.Model): | |||||||
|             return self.send_email(notification) |             return self.send_email(notification) | ||||||
|         raise ValueError(f"Invalid mode {self.mode} set") |         raise ValueError(f"Invalid mode {self.mode} set") | ||||||
|  |  | ||||||
|  |     def send_local(self, notification: "Notification") -> list[str]: | ||||||
|  |         """Local notification delivery""" | ||||||
|  |         if self.webhook_mapping: | ||||||
|  |             self.webhook_mapping.evaluate( | ||||||
|  |                 user=notification.user, | ||||||
|  |                 request=None, | ||||||
|  |                 notification=notification, | ||||||
|  |             ) | ||||||
|  |         notification.save() | ||||||
|  |         return [] | ||||||
|  |  | ||||||
|     def send_webhook(self, notification: "Notification") -> list[str]: |     def send_webhook(self, notification: "Notification") -> list[str]: | ||||||
|         """Send notification to generic webhook""" |         """Send notification to generic webhook""" | ||||||
|         default_body = { |         default_body = { | ||||||
| @ -481,6 +498,7 @@ class NotificationRule(PolicyBindingModel): | |||||||
|                 "selected, the notification will only be shown in the authentik UI." |                 "selected, the notification will only be shown in the authentik UI." | ||||||
|             ) |             ) | ||||||
|         ), |         ), | ||||||
|  |         blank=True, | ||||||
|     ) |     ) | ||||||
|     severity = models.TextField( |     severity = models.TextField( | ||||||
|         choices=NotificationSeverity.choices, |         choices=NotificationSeverity.choices, | ||||||
|  | |||||||
| @ -8,18 +8,12 @@ from typing import Any, Optional | |||||||
| from celery import Task | from celery import Task | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from prometheus_client import Gauge |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.events.apps import GAUGE_TASKS | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
| GAUGE_TASKS = Gauge( |  | ||||||
|     "authentik_system_tasks", |  | ||||||
|     "System tasks and their status", |  | ||||||
|     ["task_name", "task_uid", "status"], |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,15 +2,16 @@ | |||||||
| from threading import Thread | from threading import Thread | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed | from django.contrib.auth.signals import user_logged_in, user_logged_out | ||||||
| from django.db.models.signals import post_save, pre_delete | from django.db.models.signals import post_save, pre_delete | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.core.signals import password_changed | from authentik.core.signals import login_failed, password_changed | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.events.tasks import event_notification_handler, gdpr_cleanup | from authentik.events.tasks import event_notification_handler, gdpr_cleanup | ||||||
|  | from authentik.flows.models import Stage | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan | from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan | ||||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
| from authentik.stages.invitation.models import Invitation | from authentik.stages.invitation.models import Invitation | ||||||
| @ -77,11 +78,18 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any] | |||||||
|     thread.run() |     thread.run() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_login_failed) | @receiver(login_failed) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def on_user_login_failed(sender, credentials: dict[str, str], request: HttpRequest, **_): | def on_login_failed( | ||||||
|     """Failed Login""" |     signal, | ||||||
|     thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials) |     sender, | ||||||
|  |     credentials: dict[str, str], | ||||||
|  |     request: HttpRequest, | ||||||
|  |     stage: Optional[Stage] = None, | ||||||
|  |     **kwargs, | ||||||
|  | ): | ||||||
|  |     """Failed Login, authentik custom event""" | ||||||
|  |     thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials, stage=stage, **kwargs) | ||||||
|     thread.run() |     thread.run() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| """Event notification tasks""" | """Event notification tasks""" | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
| from django.db.models.query_utils import Q | from django.db.models.query_utils import Q | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import ( | from authentik.events.models import ( | ||||||
|     Event, |     Event, | ||||||
| @ -39,10 +42,9 @@ def event_trigger_handler(event_uuid: str, trigger_name: str): | |||||||
|         LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid) |         LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid) | ||||||
|         return |         return | ||||||
|     event: Event = events.first() |     event: Event = events.first() | ||||||
|     triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name) |     trigger: Optional[NotificationRule] = NotificationRule.objects.filter(name=trigger_name).first() | ||||||
|     if not triggers.exists(): |     if not trigger: | ||||||
|         return |         return | ||||||
|     trigger = triggers.first() |  | ||||||
|  |  | ||||||
|     if "policy_uuid" in event.context: |     if "policy_uuid" in event.context: | ||||||
|         policy_uuid = event.context["policy_uuid"] |         policy_uuid = event.context["policy_uuid"] | ||||||
| @ -81,11 +83,14 @@ def event_trigger_handler(event_uuid: str, trigger_name: str): | |||||||
|     for transport in trigger.transports.all(): |     for transport in trigger.transports.all(): | ||||||
|         for user in trigger.group.users.all(): |         for user in trigger.group.users.all(): | ||||||
|             LOGGER.debug("created notification") |             LOGGER.debug("created notification") | ||||||
|             notification = Notification.objects.create( |  | ||||||
|                 severity=trigger.severity, body=event.summary, event=event, user=user |  | ||||||
|             ) |  | ||||||
|             notification_transport.apply_async( |             notification_transport.apply_async( | ||||||
|                 args=[notification.pk, transport.pk], queue="authentik_events" |                 args=[ | ||||||
|  |                     transport.pk, | ||||||
|  |                     str(event.pk), | ||||||
|  |                     user.pk, | ||||||
|  |                     str(trigger.pk), | ||||||
|  |                 ], | ||||||
|  |                 queue="authentik_events", | ||||||
|             ) |             ) | ||||||
|             if transport.send_once: |             if transport.send_once: | ||||||
|                 break |                 break | ||||||
| @ -97,19 +102,30 @@ def event_trigger_handler(event_uuid: str, trigger_name: str): | |||||||
|     retry_backoff=True, |     retry_backoff=True, | ||||||
|     base=MonitoredTask, |     base=MonitoredTask, | ||||||
| ) | ) | ||||||
| def notification_transport(self: MonitoredTask, notification_pk: int, transport_pk: int): | def notification_transport( | ||||||
|  |     self: MonitoredTask, transport_pk: int, event_pk: str, user_pk: int, trigger_pk: str | ||||||
|  | ): | ||||||
|     """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.filter(pk=notification_pk).first() |         event = Event.objects.filter(pk=event_pk).first() | ||||||
|         if not notification: |         if not event: | ||||||
|             return |             return | ||||||
|  |         user = User.objects.filter(pk=user_pk).first() | ||||||
|  |         if not user: | ||||||
|  |             return | ||||||
|  |         trigger = NotificationRule.objects.filter(pk=trigger_pk).first() | ||||||
|  |         if not trigger: | ||||||
|  |             return | ||||||
|  |         notification = Notification( | ||||||
|  |             severity=trigger.severity, body=event.summary, event=event, user=user | ||||||
|  |         ) | ||||||
|         transport = NotificationTransport.objects.filter(pk=transport_pk).first() |         transport = NotificationTransport.objects.filter(pk=transport_pk).first() | ||||||
|         if not transport: |         if not transport: | ||||||
|             return |             return | ||||||
|         transport.send(notification) |         transport.send(notification) | ||||||
|         self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) |         self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) | ||||||
|     except NotificationTransportError as exc: |     except (NotificationTransportError, PropertyMappingExpressionException) as exc: | ||||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|         raise exc |         raise exc | ||||||
|  |  | ||||||
|  | |||||||
| @ -11,7 +11,10 @@ from authentik.events.models import ( | |||||||
|     Notification, |     Notification, | ||||||
|     NotificationRule, |     NotificationRule, | ||||||
|     NotificationTransport, |     NotificationTransport, | ||||||
|  |     NotificationWebhookMapping, | ||||||
|  |     TransportMode, | ||||||
| ) | ) | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
| from authentik.policies.event_matcher.models import EventMatcherPolicy | from authentik.policies.event_matcher.models import EventMatcherPolicy | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
| @ -105,4 +108,26 @@ class TestEventsNotifications(TestCase): | |||||||
|         execute_mock = MagicMock() |         execute_mock = MagicMock() | ||||||
|         with patch("authentik.events.models.NotificationTransport.send", execute_mock): |         with patch("authentik.events.models.NotificationTransport.send", execute_mock): | ||||||
|             Event.new(EventAction.CUSTOM_PREFIX).save() |             Event.new(EventAction.CUSTOM_PREFIX).save() | ||||||
|         self.assertEqual(Notification.objects.count(), 1) |         self.assertEqual(execute_mock.call_count, 1) | ||||||
|  |  | ||||||
|  |     def test_transport_mapping(self): | ||||||
|  |         """Test transport mapping""" | ||||||
|  |         mapping = NotificationWebhookMapping.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             expression="""notification.body = 'foo'""", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         transport = NotificationTransport.objects.create( | ||||||
|  |             name="transport", webhook_mapping=mapping, mode=TransportMode.LOCAL | ||||||
|  |         ) | ||||||
|  |         NotificationRule.objects.filter(name__startswith="default").delete() | ||||||
|  |         trigger = NotificationRule.objects.create(name="trigger", group=self.group) | ||||||
|  |         trigger.transports.add(transport) | ||||||
|  |         matcher = EventMatcherPolicy.objects.create( | ||||||
|  |             name="matcher", action=EventAction.CUSTOM_PREFIX | ||||||
|  |         ) | ||||||
|  |         PolicyBinding.objects.create(target=trigger, policy=matcher, order=0) | ||||||
|  |  | ||||||
|  |         Notification.objects.all().delete() | ||||||
|  |         Event.new(EventAction.CUSTOM_PREFIX).save() | ||||||
|  |         self.assertEqual(Notification.objects.first().body, "foo") | ||||||
|  | |||||||
| @ -10,9 +10,11 @@ from django.db import models | |||||||
| from django.db.models.base import Model | from django.db.models.base import Model | ||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from django.views.debug import SafeExceptionReporterFilter | from django.views.debug import SafeExceptionReporterFilter | ||||||
|  | from geoip2.models import City | ||||||
| from guardian.utils import get_anonymous_user | from guardian.utils import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  | from authentik.events.geo import GEOIP_READER | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
| # Special keys which are *not* cleaned, even when the default filter | # Special keys which are *not* cleaned, even when the default filter | ||||||
| @ -93,6 +95,8 @@ def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]: | |||||||
|             final_dict[key] = value.hex |             final_dict[key] = value.hex | ||||||
|         elif isinstance(value, (HttpRequest, WSGIRequest)): |         elif isinstance(value, (HttpRequest, WSGIRequest)): | ||||||
|             continue |             continue | ||||||
|  |         elif isinstance(value, City): | ||||||
|  |             final_dict[key] = GEOIP_READER.city_to_dict(value) | ||||||
|         elif isinstance(value, type): |         elif isinstance(value, type): | ||||||
|             final_dict[key] = { |             final_dict[key] = { | ||||||
|                 "type": value.__name__, |                 "type": value.__name__, | ||||||
|  | |||||||
| @ -35,3 +35,4 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet): | |||||||
|     queryset = FlowStageBinding.objects.all() |     queryset = FlowStageBinding.objects.all() | ||||||
|     serializer_class = FlowStageBindingSerializer |     serializer_class = FlowStageBindingSerializer | ||||||
|     filterset_fields = "__all__" |     filterset_fields = "__all__" | ||||||
|  |     search_fields = ["stage__name"] | ||||||
|  | |||||||
| @ -73,6 +73,7 @@ class FlowSerializer(ModelSerializer): | |||||||
|             "compatibility_mode", |             "compatibility_mode", | ||||||
|             "export_url", |             "export_url", | ||||||
|             "layout", |             "layout", | ||||||
|  |             "denied_action", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = { |         extra_kwargs = { | ||||||
|             "background": {"read_only": True}, |             "background": {"read_only": True}, | ||||||
| @ -110,8 +111,8 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     serializer_class = FlowSerializer |     serializer_class = FlowSerializer | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|     ordering = ["slug", "name"] |     ordering = ["slug", "name"] | ||||||
|     search_fields = ["name", "slug", "designation", "title"] |     search_fields = ["name", "slug", "designation", "title", "denied_action"] | ||||||
|     filterset_fields = ["flow_uuid", "name", "slug", "designation"] |     filterset_fields = ["flow_uuid", "name", "slug", "designation", "denied_action"] | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_flows.view_flow_cache"]) |     @permission_required(None, ["authentik_flows.view_flow_cache"]) | ||||||
|     @extend_schema(responses={200: CacheSerializer(many=False)}) |     @extend_schema(responses={200: CacheSerializer(many=False)}) | ||||||
| @ -371,7 +372,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|                 request, |                 request, | ||||||
|                 _( |                 _( | ||||||
|                     "Flow not applicable to current user/request: %(messages)s" |                     "Flow not applicable to current user/request: %(messages)s" | ||||||
|                     % {"messages": str(exc)} |                     % {"messages": exc.messages} | ||||||
|                 ), |                 ), | ||||||
|             ) |             ) | ||||||
|         return Response( |         return Response( | ||||||
|  | |||||||
| @ -3,9 +3,20 @@ from importlib import import_module | |||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.db.utils import ProgrammingError | from django.db.utils import ProgrammingError | ||||||
|  | from prometheus_client import Gauge, Histogram | ||||||
|  |  | ||||||
| from authentik.lib.utils.reflection import all_subclasses | from authentik.lib.utils.reflection import all_subclasses | ||||||
|  |  | ||||||
|  | GAUGE_FLOWS_CACHED = Gauge( | ||||||
|  |     "authentik_flows_cached", | ||||||
|  |     "Cached flows", | ||||||
|  | ) | ||||||
|  | HIST_FLOWS_PLAN_TIME = Histogram( | ||||||
|  |     "authentik_flows_plan_time", | ||||||
|  |     "Duration to build a plan for a flow", | ||||||
|  |     ["flow_slug"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikFlowsConfig(AppConfig): | class AuthentikFlowsConfig(AppConfig): | ||||||
|     """authentik flows app config""" |     """authentik flows app config""" | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """Challenge helpers""" | """Challenge helpers""" | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from typing import TYPE_CHECKING, Optional | from typing import TYPE_CHECKING, Optional, TypedDict | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.http import JsonResponse | from django.http import JsonResponse | ||||||
| @ -95,6 +95,13 @@ class AccessDeniedChallenge(WithUserInfoChallenge): | |||||||
|     component = CharField(default="ak-stage-access-denied") |     component = CharField(default="ak-stage-access-denied") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PermissionDict(TypedDict): | ||||||
|  |     """Consent Permission""" | ||||||
|  |  | ||||||
|  |     id: str | ||||||
|  |     name: str | ||||||
|  |  | ||||||
|  |  | ||||||
| class PermissionSerializer(PassiveSerializer): | class PermissionSerializer(PassiveSerializer): | ||||||
|     """Permission used for consent""" |     """Permission used for consent""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| """flow exceptions""" | """flow exceptions""" | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.policies.types import PolicyResult | from authentik.policies.types import PolicyResult | ||||||
| @ -9,6 +10,13 @@ class FlowNonApplicableException(SentryIgnoredException): | |||||||
|  |  | ||||||
|     policy_result: PolicyResult |     policy_result: PolicyResult | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def messages(self) -> str: | ||||||
|  |         """Get messages from policy result, fallback to generic reason""" | ||||||
|  |         if len(self.policy_result.messages) < 1: | ||||||
|  |             return _("Flow does not apply to current user (denied by policy).") | ||||||
|  |         return "\n".join(self.policy_result.messages) | ||||||
|  |  | ||||||
|  |  | ||||||
| class EmptyFlowException(SentryIgnoredException): | class EmptyFlowException(SentryIgnoredException): | ||||||
|     """Flow has no stages.""" |     """Flow has no stages.""" | ||||||
|  | |||||||
| @ -94,9 +94,9 @@ class Command(BaseCommand):  # pragma: no cover | |||||||
|  |  | ||||||
|     def output_overview(self, values): |     def output_overview(self, values): | ||||||
|         """Output results human readable""" |         """Output results human readable""" | ||||||
|         total_max: int = max([max(inner) for inner in values]) |         total_max: int = max(max(inner) for inner in values) | ||||||
|         total_min: int = min([min(inner) for inner in values]) |         total_min: int = min(min(inner) for inner in values) | ||||||
|         total_avg = sum([sum(inner) for inner in values]) / sum([len(inner) for inner in values]) |         total_avg = sum(sum(inner) for inner in values) / sum(len(inner) for inner in values) | ||||||
|  |  | ||||||
|         print(f"Version: {__version__}") |         print(f"Version: {__version__}") | ||||||
|         print(f"Processes: {len(values)}") |         print(f"Processes: {len(values)}") | ||||||
|  | |||||||
| @ -47,7 +47,8 @@ class ReevaluateMarker(StageMarker): | |||||||
|         from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER |         from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||||
|  |  | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|             "f(plan_inst)[re-eval marker]: running re-evaluation", |             "f(plan_inst): running re-evaluation", | ||||||
|  |             marker="ReevaluateMarker", | ||||||
|             binding=binding, |             binding=binding, | ||||||
|             policy_binding=self.binding, |             policy_binding=self.binding, | ||||||
|         ) |         ) | ||||||
| @ -56,13 +57,15 @@ class ReevaluateMarker(StageMarker): | |||||||
|         ) |         ) | ||||||
|         engine.use_cache = False |         engine.use_cache = False | ||||||
|         engine.request.set_http_request(http_request) |         engine.request.set_http_request(http_request) | ||||||
|         engine.request.context = plan.context |         engine.request.context["flow_plan"] = plan | ||||||
|  |         engine.request.context.update(plan.context) | ||||||
|         engine.build() |         engine.build() | ||||||
|         result = engine.result |         result = engine.result | ||||||
|         if result.passing: |         if result.passing: | ||||||
|             return binding |             return binding | ||||||
|         LOGGER.warning( |         LOGGER.warning( | ||||||
|             "f(plan_inst)[re-eval marker]: binding failed re-evaluation", |             "f(plan_inst): binding failed re-evaluation", | ||||||
|  |             marker="ReevaluateMarker", | ||||||
|             binding=binding, |             binding=binding, | ||||||
|             messages=result.messages, |             messages=result.messages, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ return not akadmin.has_usable_password()""" | |||||||
| PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently running flow | PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently running flow | ||||||
| # 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["flow_plan"].context["pending_user"] = akadmin | ||||||
| return True""" | return True""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										26
									
								
								authentik/flows/migrations/0023_flow_denied_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								authentik/flows/migrations/0023_flow_denied_action.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | |||||||
|  | # Generated by Django 4.0.5 on 2022-07-02 12:42 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_flows", "0022_flow_layout"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="flow", | ||||||
|  |             name="denied_action", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("message_continue", "Message Continue"), | ||||||
|  |                     ("message", "Message"), | ||||||
|  |                     ("continue", "Continue"), | ||||||
|  |                 ], | ||||||
|  |                 default="message_continue", | ||||||
|  |                 help_text="Configure what should happen when a flow denies access to a user.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Optional | |||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.http import HttpRequest |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from model_utils.managers import InheritanceManager | from model_utils.managers import InheritanceManager | ||||||
| from rest_framework.serializers import BaseSerializer | from rest_framework.serializers import BaseSerializer | ||||||
| @ -40,6 +39,14 @@ class InvalidResponseAction(models.TextChoices): | |||||||
|     RESTART_WITH_CONTEXT = "restart_with_context" |     RESTART_WITH_CONTEXT = "restart_with_context" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FlowDeniedAction(models.TextChoices): | ||||||
|  |     """Configure what response is given to denied flow executions""" | ||||||
|  |  | ||||||
|  |     MESSAGE_CONTINUE = "message_continue" | ||||||
|  |     MESSAGE = "message" | ||||||
|  |     CONTINUE = "continue" | ||||||
|  |  | ||||||
|  |  | ||||||
| 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.""" | ||||||
| @ -87,13 +94,15 @@ class Stage(SerializerModel): | |||||||
|         return f"Stage {self.name}" |         return f"Stage {self.name}" | ||||||
|  |  | ||||||
|  |  | ||||||
| def in_memory_stage(view: type["StageView"]) -> Stage: | def in_memory_stage(view: type["StageView"], **kwargs) -> Stage: | ||||||
|     """Creates an in-memory stage instance, based on a `view` as view.""" |     """Creates an in-memory stage instance, based on a `view` as view.""" | ||||||
|     stage = Stage() |     stage = Stage() | ||||||
|     # Because we can't pickle a locally generated function, |     # Because we can't pickle a locally generated function, | ||||||
|     # we set the view as a separate property and reference a generic function |     # we set the view as a separate property and reference a generic function | ||||||
|     # that returns that member |     # that returns that member | ||||||
|     setattr(stage, "__in_memory_type", view) |     setattr(stage, "__in_memory_type", view) | ||||||
|  |     for key, value in kwargs.items(): | ||||||
|  |         setattr(stage, key, value) | ||||||
|     return stage |     return stage | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -137,6 +146,12 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     denied_action = models.TextField( | ||||||
|  |         choices=FlowDeniedAction.choices, | ||||||
|  |         default=FlowDeniedAction.MESSAGE_CONTINUE, | ||||||
|  |         help_text=_("Configure what should happen when a flow denies access to a user."), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def background_url(self) -> str: |     def background_url(self) -> str: | ||||||
|         """Get the URL to the background image. If the name is /static or starts with http |         """Get the URL to the background image. If the name is /static or starts with http | ||||||
| @ -155,23 +170,6 @@ class Flow(SerializerModel, PolicyBindingModel): | |||||||
|  |  | ||||||
|         return FlowSerializer |         return FlowSerializer | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]: |  | ||||||
|         """Get a Flow by `**flow_filter` and check if the request from `request` can access it.""" |  | ||||||
|         from authentik.policies.engine import PolicyEngine |  | ||||||
|  |  | ||||||
|         flows = Flow.objects.filter(**flow_filter).order_by("slug") |  | ||||||
|         for flow in flows: |  | ||||||
|             engine = PolicyEngine(flow, request.user, request) |  | ||||||
|             engine.build() |  | ||||||
|             result = engine.result |  | ||||||
|             if result.passing: |  | ||||||
|                 LOGGER.debug("with_policy: flow passing", flow=flow) |  | ||||||
|                 return flow |  | ||||||
|             LOGGER.warning("with_policy: flow not passing", flow=flow, messages=result.messages) |  | ||||||
|         LOGGER.debug("with_policy: no flow found", filters=flow_filter) |  | ||||||
|         return None |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Flow {self.name} ({self.slug})" |         return f"Flow {self.name} ({self.slug})" | ||||||
|  |  | ||||||
|  | |||||||
| @ -4,16 +4,16 @@ from typing import Any, Optional | |||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from prometheus_client import Gauge, Histogram |  | ||||||
| from sentry_sdk.hub import Hub | from sentry_sdk.hub import Hub | ||||||
| from sentry_sdk.tracing import Span | from sentry_sdk.tracing import Span | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.events.models import cleanse_dict | from authentik.events.models import cleanse_dict | ||||||
|  | from authentik.flows.apps import HIST_FLOWS_PLAN_TIME | ||||||
| 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, FlowDesignation, FlowStageBinding, Stage | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage, in_memory_stage | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
|  |  | ||||||
| @ -26,15 +26,6 @@ PLAN_CONTEXT_SOURCE = "source" | |||||||
| # Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan | # Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan | ||||||
| # was restored. | # was restored. | ||||||
| PLAN_CONTEXT_IS_RESTORED = "is_restored" | PLAN_CONTEXT_IS_RESTORED = "is_restored" | ||||||
| GAUGE_FLOWS_CACHED = Gauge( |  | ||||||
|     "authentik_flows_cached", |  | ||||||
|     "Cached flows", |  | ||||||
| ) |  | ||||||
| HIST_FLOWS_PLAN_TIME = Histogram( |  | ||||||
|     "authentik_flows_plan_time", |  | ||||||
|     "Duration to build a plan for a flow", |  | ||||||
|     ["flow_slug"], |  | ||||||
| ) |  | ||||||
| CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows")) | CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows")) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -71,6 +62,12 @@ class FlowPlan: | |||||||
|         self.bindings.insert(1, FlowStageBinding(stage=stage, order=0)) |         self.bindings.insert(1, FlowStageBinding(stage=stage, order=0)) | ||||||
|         self.markers.insert(1, marker or StageMarker()) |         self.markers.insert(1, marker or StageMarker()) | ||||||
|  |  | ||||||
|  |     def redirect(self, destination: str): | ||||||
|  |         """Insert a redirect stage as next stage""" | ||||||
|  |         from authentik.flows.stage import RedirectStage | ||||||
|  |  | ||||||
|  |         self.insert_stage(in_memory_stage(RedirectStage, destination=destination)) | ||||||
|  |  | ||||||
|     def next(self, http_request: Optional[HttpRequest]) -> Optional[FlowStageBinding]: |     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: | ||||||
| @ -117,7 +114,7 @@ class FlowPlanner: | |||||||
|         self.use_cache = True |         self.use_cache = True | ||||||
|         self.allow_empty_flows = False |         self.allow_empty_flows = False | ||||||
|         self.flow = flow |         self.flow = flow | ||||||
|         self._logger = get_logger().bind(flow=flow) |         self._logger = get_logger().bind(flow_slug=flow.slug) | ||||||
|  |  | ||||||
|     def plan( |     def plan( | ||||||
|         self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None |         self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None | ||||||
| @ -146,11 +143,11 @@ class FlowPlanner: | |||||||
|             engine = PolicyEngine(self.flow, user, request) |             engine = PolicyEngine(self.flow, user, request) | ||||||
|             if default_context: |             if default_context: | ||||||
|                 span.set_data("default_context", cleanse_dict(default_context)) |                 span.set_data("default_context", cleanse_dict(default_context)) | ||||||
|                 engine.request.context = default_context |                 engine.request.context.update(default_context) | ||||||
|             engine.build() |             engine.build() | ||||||
|             result = engine.result |             result = engine.result | ||||||
|             if not result.passing: |             if not result.passing: | ||||||
|                 exc = FlowNonApplicableException(",".join(result.messages)) |                 exc = FlowNonApplicableException() | ||||||
|                 exc.policy_result = result |                 exc.policy_result = result | ||||||
|                 raise exc |                 raise exc | ||||||
|             # User is passing so far, check if we have a cached plan |             # User is passing so far, check if we have a cached plan | ||||||
| @ -207,7 +204,8 @@ class FlowPlanner: | |||||||
|                         stage=binding.stage, |                         stage=binding.stage, | ||||||
|                     ) |                     ) | ||||||
|                     engine = PolicyEngine(binding, user, request) |                     engine = PolicyEngine(binding, user, request) | ||||||
|                     engine.request.context = plan.context |                     engine.request.context["flow_plan"] = plan | ||||||
|  |                     engine.request.context.update(plan.context) | ||||||
|                     engine.build() |                     engine.build() | ||||||
|                     if engine.passing: |                     if engine.passing: | ||||||
|                         self._logger.debug( |                         self._logger.debug( | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete | |||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.flows.planner import GAUGE_FLOWS_CACHED | from authentik.flows.apps import GAUGE_FLOWS_CACHED | ||||||
| from authentik.root.monitoring import monitoring_set | from authentik.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ from django.urls import reverse | |||||||
| from django.views.generic.base import View | from django.views.generic.base import View | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from sentry_sdk.hub import Hub | from sentry_sdk.hub import Hub | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import DEFAULT_AVATAR, User | from authentik.core.models import DEFAULT_AVATAR, User | ||||||
| from authentik.flows.challenge import ( | from authentik.flows.challenge import ( | ||||||
| @ -19,27 +19,35 @@ from authentik.flows.challenge import ( | |||||||
|     ChallengeTypes, |     ChallengeTypes, | ||||||
|     ContextualFlowInfo, |     ContextualFlowInfo, | ||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|  |     RedirectChallenge, | ||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| from authentik.flows.models import InvalidResponseAction | from authentik.flows.models import InvalidResponseAction | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER | ||||||
|  | from authentik.lib.utils.reflection import class_to_path | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
|     from authentik.flows.views.executor import FlowExecutorView |     from authentik.flows.views.executor import FlowExecutorView | ||||||
|  |  | ||||||
| PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | ||||||
| LOGGER = get_logger() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class StageView(View): | class StageView(View): | ||||||
|     """Abstract Stage, inherits TemplateView but can be combined with FormView""" |     """Abstract Stage""" | ||||||
|  |  | ||||||
|     executor: "FlowExecutorView" |     executor: "FlowExecutorView" | ||||||
|  |  | ||||||
|     request: HttpRequest = None |     request: HttpRequest = None | ||||||
|  |  | ||||||
|  |     logger: BoundLogger | ||||||
|  |  | ||||||
|     def __init__(self, executor: "FlowExecutorView", **kwargs): |     def __init__(self, executor: "FlowExecutorView", **kwargs): | ||||||
|         self.executor = executor |         self.executor = executor | ||||||
|  |         current_stage = getattr(self.executor, "current_stage", None) | ||||||
|  |         self.logger = get_logger().bind( | ||||||
|  |             stage=getattr(current_stage, "name", None), | ||||||
|  |             stage_view=class_to_path(type(self)), | ||||||
|  |         ) | ||||||
|         super().__init__(**kwargs) |         super().__init__(**kwargs) | ||||||
|  |  | ||||||
|     def get_pending_user(self, for_display=False) -> User: |     def get_pending_user(self, for_display=False) -> User: | ||||||
| @ -60,6 +68,9 @@ class StageView(View): | |||||||
|             return self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] |             return self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] | ||||||
|         return self.request.user |         return self.request.user | ||||||
|  |  | ||||||
|  |     def cleanup(self): | ||||||
|  |         """Cleanup session""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ChallengeStageView(StageView): | class ChallengeStageView(StageView): | ||||||
|     """Stage view which response with a challenge""" |     """Stage view which response with a challenge""" | ||||||
| @ -74,12 +85,9 @@ 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( |             self.logger.warning( | ||||||
|                 "f(ch): Invalid challenge", |                 "f(ch): Invalid challenge", | ||||||
|                 binding=self.executor.current_binding, |  | ||||||
|                 errors=challenge.errors, |                 errors=challenge.errors, | ||||||
|                 stage_view=self, |  | ||||||
|                 challenge=challenge, |  | ||||||
|             ) |             ) | ||||||
|         return HttpChallengeResponse(challenge) |         return HttpChallengeResponse(challenge) | ||||||
|  |  | ||||||
| @ -96,10 +104,8 @@ class ChallengeStageView(StageView): | |||||||
|                     self.executor.current_binding.invalid_response_action |                     self.executor.current_binding.invalid_response_action | ||||||
|                     == InvalidResponseAction.RESTART_WITH_CONTEXT |                     == InvalidResponseAction.RESTART_WITH_CONTEXT | ||||||
|                 ) |                 ) | ||||||
|                 LOGGER.debug( |                 self.logger.debug( | ||||||
|                     "f(ch): Invalid response, restarting flow", |                     "f(ch): Invalid response, restarting flow", | ||||||
|                     binding=self.executor.current_binding, |  | ||||||
|                     stage_view=self, |  | ||||||
|                     keep_context=keep_context, |                     keep_context=keep_context, | ||||||
|                 ) |                 ) | ||||||
|                 return self.executor.restart_flow(keep_context) |                 return self.executor.restart_flow(keep_context) | ||||||
| @ -125,7 +131,7 @@ class ChallengeStageView(StageView): | |||||||
|             } |             } | ||||||
|         # pylint: disable=broad-except |         # pylint: disable=broad-except | ||||||
|         except Exception as exc: |         except Exception as exc: | ||||||
|             LOGGER.warning("failed to template title", exc=exc) |             self.logger.warning("failed to template title", exc=exc) | ||||||
|             return self.executor.flow.title |             return self.executor.flow.title | ||||||
|  |  | ||||||
|     def _get_challenge(self, *args, **kwargs) -> Challenge: |     def _get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
| @ -185,11 +191,9 @@ 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.error( |             self.logger.error( | ||||||
|                 "f(ch): invalid challenge response", |                 "f(ch): invalid challenge response", | ||||||
|                 binding=self.executor.current_binding, |  | ||||||
|                 errors=challenge_response.errors, |                 errors=challenge_response.errors, | ||||||
|                 stage_view=self, |  | ||||||
|             ) |             ) | ||||||
|         return HttpChallengeResponse(challenge_response) |         return HttpChallengeResponse(challenge_response) | ||||||
|  |  | ||||||
| @ -216,3 +220,21 @@ class AccessDeniedChallengeView(ChallengeStageView): | |||||||
|     # .get() method is called |     # .get() method is called | ||||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:  # pragma: no cover |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:  # pragma: no cover | ||||||
|         return self.executor.cancel() |         return self.executor.cancel() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RedirectStage(ChallengeStageView): | ||||||
|  |     """Redirect to any URL""" | ||||||
|  |  | ||||||
|  |     def get_challenge(self, *args, **kwargs) -> RedirectChallenge: | ||||||
|  |         destination = getattr( | ||||||
|  |             self.executor.current_stage, "destination", reverse("authentik_core:root-redirect") | ||||||
|  |         ) | ||||||
|  |         return RedirectChallenge( | ||||||
|  |             data={ | ||||||
|  |                 "type": ChallengeTypes.REDIRECT.value, | ||||||
|  |                 "to": destination, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||||
|  |         return HttpChallengeResponse(self.get_challenge()) | ||||||
|  | |||||||
| @ -6,14 +6,20 @@ from django.test.client import RequestFactory | |||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.core.tests.utils import create_test_flow | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction | from authentik.flows.models import ( | ||||||
|  |     FlowDeniedAction, | ||||||
|  |     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.tests import FlowTestCase | from authentik.flows.tests import FlowTestCase | ||||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
| 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.reputation.models import ReputationPolicy | ||||||
| @ -22,7 +28,7 @@ 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 | from authentik.stages.identification.models import IdentificationStage, UserFields | ||||||
|  |  | ||||||
| POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) | POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False, "foo")) | ||||||
| POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -47,12 +53,10 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|     ) |     ) | ||||||
|     def test_existing_plan_diff_flow(self): |     def test_existing_plan_diff_flow(self): | ||||||
|         """Check that a plan for a different flow cancels the current plan""" |         """Check that a plan for a different flow cancels the current plan""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-existing-plan-diff", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-existing-plan-diff", |  | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |  | ||||||
|         ) |         ) | ||||||
|         stage = DummyStage.objects.create(name="dummy") |         stage = DummyStage.objects.create(name=generate_id()) | ||||||
|         binding = FlowStageBinding(target=flow, stage=stage, order=0) |         binding = FlowStageBinding(target=flow, stage=stage, order=0) | ||||||
|         plan = FlowPlan(flow_pk=flow.pk.hex + "a", bindings=[binding], markers=[StageMarker()]) |         plan = FlowPlan(flow_pk=flow.pk.hex + "a", bindings=[binding], markers=[StageMarker()]) | ||||||
|         session = self.client.session |         session = self.client.session | ||||||
| @ -77,10 +81,8 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|     ) |     ) | ||||||
|     def test_invalid_non_applicable_flow(self): |     def test_invalid_non_applicable_flow(self): | ||||||
|         """Tests that a non-applicable flow returns the correct error message""" |         """Tests that a non-applicable flow returns the correct error message""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-non-applicable", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-non-applicable", |  | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         CONFIG.update_from_dict({"domain": "testserver"}) |         CONFIG.update_from_dict({"domain": "testserver"}) | ||||||
| @ -90,7 +92,7 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|         self.assertStageResponse( |         self.assertStageResponse( | ||||||
|             response, |             response, | ||||||
|             flow=flow, |             flow=flow, | ||||||
|             error_message=FlowNonApplicableException.__doc__, |             error_message="foo", | ||||||
|             component="ak-stage-access-denied", |             component="ak-stage-access-denied", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -98,12 +100,15 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|         "authentik.flows.views.executor.to_stage_response", |         "authentik.flows.views.executor.to_stage_response", | ||||||
|         TO_STAGE_RESPONSE_MOCK, |         TO_STAGE_RESPONSE_MOCK, | ||||||
|     ) |     ) | ||||||
|     def test_invalid_empty_flow(self): |     @patch( | ||||||
|         """Tests that an empty flow returns the correct error message""" |         "authentik.policies.engine.PolicyEngine.result", | ||||||
|         flow = Flow.objects.create( |         POLICY_RETURN_FALSE, | ||||||
|             name="test-empty", |     ) | ||||||
|             slug="test-empty", |     def test_invalid_non_applicable_flow_continue(self): | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |         """Tests that a non-applicable flow that should redirect""" | ||||||
|  |         flow = create_test_flow( | ||||||
|  |             FlowDesignation.AUTHENTICATION, | ||||||
|  |             denied_action=FlowDeniedAction.CONTINUE, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         CONFIG.update_from_dict({"domain": "testserver"}) |         CONFIG.update_from_dict({"domain": "testserver"}) | ||||||
| @ -119,10 +124,8 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|     ) |     ) | ||||||
|     def test_invalid_flow_redirect(self): |     def test_invalid_flow_redirect(self): | ||||||
|         """Tests that an invalid flow still redirects""" |         """Tests that an invalid flow still redirects""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-empty", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-empty", |  | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         CONFIG.update_from_dict({"domain": "testserver"}) |         CONFIG.update_from_dict({"domain": "testserver"}) | ||||||
| @ -132,18 +135,33 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|         self.assertEqual(response.status_code, 302) |         self.assertEqual(response.status_code, 302) | ||||||
|         self.assertEqual(response.url, reverse("authentik_core:root-redirect")) |         self.assertEqual(response.url, reverse("authentik_core:root-redirect")) | ||||||
|  |  | ||||||
|  |     @patch( | ||||||
|  |         "authentik.flows.views.executor.to_stage_response", | ||||||
|  |         TO_STAGE_RESPONSE_MOCK, | ||||||
|  |     ) | ||||||
|  |     def test_invalid_empty_flow(self): | ||||||
|  |         """Tests that an empty flow returns the correct error message""" | ||||||
|  |         flow = create_test_flow( | ||||||
|  |             FlowDesignation.AUTHENTICATION, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         CONFIG.update_from_dict({"domain": "testserver"}) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.url, reverse("authentik_core:root-redirect")) | ||||||
|  |  | ||||||
|     def test_multi_stage_flow(self): |     def test_multi_stage_flow(self): | ||||||
|         """Test a full flow with multiple stages""" |         """Test a full flow with multiple stages""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-full", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-full", |  | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |  | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create( |         FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create( |         FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=1 | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) |         exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) | ||||||
| @ -170,19 +188,19 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|     ) |     ) | ||||||
|     def test_reevaluate_remove_last(self): |     def test_reevaluate_remove_last(self): | ||||||
|         """Test planner with re-evaluate (last stage is removed)""" |         """Test planner with re-evaluate (last stage is removed)""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-default-context", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-default-context", |         ) | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |         false_policy = DummyPolicy.objects.create( | ||||||
|  |             name=generate_id(), result=False, wait_min=1, wait_max=2 | ||||||
|         ) |         ) | ||||||
|         false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) |  | ||||||
|  |  | ||||||
|         binding = FlowStageBinding.objects.create( |         binding = FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||||
|         ) |         ) | ||||||
|         binding2 = FlowStageBinding.objects.create( |         binding2 = FlowStageBinding.objects.create( | ||||||
|             target=flow, |             target=flow, | ||||||
|             stage=DummyStage.objects.create(name="dummy2"), |             stage=DummyStage.objects.create(name=generate_id()), | ||||||
|             order=1, |             order=1, | ||||||
|             re_evaluate_policies=True, |             re_evaluate_policies=True, | ||||||
|         ) |         ) | ||||||
| @ -217,24 +235,24 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|  |  | ||||||
|     def test_reevaluate_remove_middle(self): |     def test_reevaluate_remove_middle(self): | ||||||
|         """Test planner with re-evaluate (middle stage is removed)""" |         """Test planner with re-evaluate (middle stage is removed)""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-default-context", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-default-context", |         ) | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |         false_policy = DummyPolicy.objects.create( | ||||||
|  |             name=generate_id(), result=False, wait_min=1, wait_max=2 | ||||||
|         ) |         ) | ||||||
|         false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) |  | ||||||
|  |  | ||||||
|         binding = FlowStageBinding.objects.create( |         binding = FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||||
|         ) |         ) | ||||||
|         binding2 = FlowStageBinding.objects.create( |         binding2 = FlowStageBinding.objects.create( | ||||||
|             target=flow, |             target=flow, | ||||||
|             stage=DummyStage.objects.create(name="dummy2"), |             stage=DummyStage.objects.create(name=generate_id()), | ||||||
|             order=1, |             order=1, | ||||||
|             re_evaluate_policies=True, |             re_evaluate_policies=True, | ||||||
|         ) |         ) | ||||||
|         binding3 = FlowStageBinding.objects.create( |         binding3 = FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2 | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) |         PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) | ||||||
| @ -277,24 +295,24 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|  |  | ||||||
|     def test_reevaluate_keep(self): |     def test_reevaluate_keep(self): | ||||||
|         """Test planner with re-evaluate (everything is kept)""" |         """Test planner with re-evaluate (everything is kept)""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-default-context", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-default-context", |         ) | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |         true_policy = DummyPolicy.objects.create( | ||||||
|  |             name=generate_id(), result=True, wait_min=1, wait_max=2 | ||||||
|         ) |         ) | ||||||
|         true_policy = DummyPolicy.objects.create(result=True, wait_min=1, wait_max=2) |  | ||||||
|  |  | ||||||
|         binding = FlowStageBinding.objects.create( |         binding = FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||||
|         ) |         ) | ||||||
|         binding2 = FlowStageBinding.objects.create( |         binding2 = FlowStageBinding.objects.create( | ||||||
|             target=flow, |             target=flow, | ||||||
|             stage=DummyStage.objects.create(name="dummy2"), |             stage=DummyStage.objects.create(name=generate_id()), | ||||||
|             order=1, |             order=1, | ||||||
|             re_evaluate_policies=True, |             re_evaluate_policies=True, | ||||||
|         ) |         ) | ||||||
|         binding3 = FlowStageBinding.objects.create( |         binding3 = FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2 | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0) |         PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0) | ||||||
| @ -347,30 +365,30 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|  |  | ||||||
|     def test_reevaluate_remove_consecutive(self): |     def test_reevaluate_remove_consecutive(self): | ||||||
|         """Test planner with re-evaluate (consecutive stages are removed)""" |         """Test planner with re-evaluate (consecutive stages are removed)""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-default-context", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-default-context", |         ) | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |         false_policy = DummyPolicy.objects.create( | ||||||
|  |             name=generate_id(), result=False, wait_min=1, wait_max=2 | ||||||
|         ) |         ) | ||||||
|         false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) |  | ||||||
|  |  | ||||||
|         binding = FlowStageBinding.objects.create( |         binding = FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||||
|         ) |         ) | ||||||
|         binding2 = FlowStageBinding.objects.create( |         binding2 = FlowStageBinding.objects.create( | ||||||
|             target=flow, |             target=flow, | ||||||
|             stage=DummyStage.objects.create(name="dummy2"), |             stage=DummyStage.objects.create(name=generate_id()), | ||||||
|             order=1, |             order=1, | ||||||
|             re_evaluate_policies=True, |             re_evaluate_policies=True, | ||||||
|         ) |         ) | ||||||
|         binding3 = FlowStageBinding.objects.create( |         binding3 = FlowStageBinding.objects.create( | ||||||
|             target=flow, |             target=flow, | ||||||
|             stage=DummyStage.objects.create(name="dummy3"), |             stage=DummyStage.objects.create(name=generate_id()), | ||||||
|             order=2, |             order=2, | ||||||
|             re_evaluate_policies=True, |             re_evaluate_policies=True, | ||||||
|         ) |         ) | ||||||
|         binding4 = FlowStageBinding.objects.create( |         binding4 = FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy4"), order=2 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2 | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) |         PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) | ||||||
| @ -415,13 +433,11 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|  |  | ||||||
|     def test_stageview_user_identifier(self): |     def test_stageview_user_identifier(self): | ||||||
|         """Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER""" |         """Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="test-default-context", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="test-default-context", |  | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |  | ||||||
|         ) |         ) | ||||||
|         FlowStageBinding.objects.create( |         FlowStageBinding.objects.create( | ||||||
|             target=flow, stage=DummyStage.objects.create(name="dummy"), order=0 |             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         ident = "test-identifier" |         ident = "test-identifier" | ||||||
| @ -443,10 +459,8 @@ class TestFlowExecutor(FlowTestCase): | |||||||
|  |  | ||||||
|     def test_invalid_restart(self): |     def test_invalid_restart(self): | ||||||
|         """Test flow that restarts on invalid entry""" |         """Test flow that restarts on invalid entry""" | ||||||
|         flow = Flow.objects.create( |         flow = create_test_flow( | ||||||
|             name="restart-on-invalid", |             FlowDesignation.AUTHENTICATION, | ||||||
|             slug="restart-on-invalid", |  | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |  | ||||||
|         ) |         ) | ||||||
|         # Stage 0 is a deny stage that is added dynamically |         # Stage 0 is a deny stage that is added dynamically | ||||||
|         # when the reputation policy says so |         # when the reputation policy says so | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ from rest_framework.test import APITestCase | |||||||
| from authentik.core.tests.utils import create_test_admin_user | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.flows.challenge import ChallengeTypes | from authentik.flows.challenge import ChallengeTypes | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
| from authentik.stages.dummy.models import DummyStage | from authentik.stages.dummy.models import DummyStage | ||||||
| from authentik.stages.identification.models import IdentificationStage, UserFields | from authentik.stages.identification.models import IdentificationStage, UserFields | ||||||
|  |  | ||||||
| @ -24,8 +25,8 @@ class TestFlowInspector(APITestCase): | |||||||
|     def test(self): |     def test(self): | ||||||
|         """test inspector""" |         """test inspector""" | ||||||
|         flow = Flow.objects.create( |         flow = Flow.objects.create( | ||||||
|             name="test-full", |             name=generate_id(), | ||||||
|             slug="test-full", |             slug=generate_id(), | ||||||
|             designation=FlowDesignation.AUTHENTICATION, |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -13,6 +13,26 @@ from authentik.policies.models import PolicyBinding | |||||||
| from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage | from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage | ||||||
| from authentik.stages.user_login.models import UserLoginStage | from authentik.stages.user_login.models import UserLoginStage | ||||||
|  |  | ||||||
|  | STATIC_PROMPT_EXPORT = """{ | ||||||
|  |     "version": 1, | ||||||
|  |     "entries": [ | ||||||
|  |         { | ||||||
|  |             "identifiers": { | ||||||
|  |                 "pk": "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4" | ||||||
|  |             }, | ||||||
|  |             "model": "authentik_stages_prompt.prompt", | ||||||
|  |             "attrs": { | ||||||
|  |                 "field_key": "username", | ||||||
|  |                 "label": "Username", | ||||||
|  |                 "type": "username", | ||||||
|  |                 "required": true, | ||||||
|  |                 "placeholder": "Username", | ||||||
|  |                 "order": 0 | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  | }""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestFlowTransfer(TransactionTestCase): | class TestFlowTransfer(TransactionTestCase): | ||||||
|     """Test flow transfer""" |     """Test flow transfer""" | ||||||
| @ -58,6 +78,22 @@ class TestFlowTransfer(TransactionTestCase): | |||||||
|  |  | ||||||
|         self.assertTrue(Flow.objects.filter(slug=flow_slug).exists()) |         self.assertTrue(Flow.objects.filter(slug=flow_slug).exists()) | ||||||
|  |  | ||||||
|  |     def test_export_validate_import_re_import(self): | ||||||
|  |         """Test export and import it twice""" | ||||||
|  |         count_initial = Prompt.objects.filter(field_key="username").count() | ||||||
|  |  | ||||||
|  |         importer = FlowImporter(STATIC_PROMPT_EXPORT) | ||||||
|  |         self.assertTrue(importer.validate()) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |  | ||||||
|  |         count_before = Prompt.objects.filter(field_key="username").count() | ||||||
|  |         self.assertEqual(count_initial + 1, count_before) | ||||||
|  |  | ||||||
|  |         importer = FlowImporter(STATIC_PROMPT_EXPORT) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |  | ||||||
|  |         self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before) | ||||||
|  |  | ||||||
|     def test_export_validate_import_policies(self): |     def test_export_validate_import_policies(self): | ||||||
|         """Test export and validate it""" |         """Test export and validate it""" | ||||||
|         flow_slug = generate_id() |         flow_slug = generate_id() | ||||||
|  | |||||||
| @ -27,6 +27,7 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]: | |||||||
|         "promptstage_set", |         "promptstage_set", | ||||||
|         "policybindingmodel_ptr_id", |         "policybindingmodel_ptr_id", | ||||||
|         "export_url", |         "export_url", | ||||||
|  |         "meta_model_name", | ||||||
|     ) |     ) | ||||||
|     for to_remove_name in to_remove: |     for to_remove_name in to_remove: | ||||||
|         if to_remove_name in data: |         if to_remove_name in data: | ||||||
|  | |||||||
| @ -28,6 +28,7 @@ ALLOWED_MODELS = (Flow, FlowStageBinding, Stage, Policy, PolicyBinding, Prompt) | |||||||
| def transaction_rollback(): | def transaction_rollback(): | ||||||
|     """Enters an atomic transaction and always triggers a rollback at the end of the block.""" |     """Enters an atomic transaction and always triggers a rollback at the end of the block.""" | ||||||
|     atomic = transaction.atomic() |     atomic = transaction.atomic() | ||||||
|  |     # pylint: disable=unnecessary-dunder-call | ||||||
|     atomic.__enter__() |     atomic.__enter__() | ||||||
|     yield |     yield | ||||||
|     atomic.__exit__(IntegrityError, None, None) |     atomic.__exit__(IntegrityError, None, None) | ||||||
| @ -115,6 +116,11 @@ class FlowImporter: | |||||||
|             serializer_kwargs["instance"] = model_instance |             serializer_kwargs["instance"] = model_instance | ||||||
|         else: |         else: | ||||||
|             self.logger.debug("initialise new instance", model=model, **updated_identifiers) |             self.logger.debug("initialise new instance", model=model, **updated_identifiers) | ||||||
|  |             model_instance = model() | ||||||
|  |             # pk needs to be set on the model instance otherwise a new one will be generated | ||||||
|  |             if "pk" in updated_identifiers: | ||||||
|  |                 model_instance.pk = updated_identifiers["pk"] | ||||||
|  |             serializer_kwargs["instance"] = model_instance | ||||||
|         full_data = self.__update_pks_for_attrs(entry.attrs) |         full_data = self.__update_pks_for_attrs(entry.attrs) | ||||||
|         full_data.update(updated_identifiers) |         full_data.update(updated_identifiers) | ||||||
|         serializer_kwargs["data"] = full_data |         serializer_kwargs["data"] = full_data | ||||||
| @ -167,7 +173,7 @@ class FlowImporter: | |||||||
|     def validate(self) -> bool: |     def validate(self) -> bool: | ||||||
|         """Validate loaded flow export, ensure all models are allowed |         """Validate loaded flow export, ensure all models are allowed | ||||||
|         and serializers have no errors""" |         and serializers have no errors""" | ||||||
|         self.logger.debug("Starting flow import validaton") |         self.logger.debug("Starting flow import validation") | ||||||
|         if self.__import.version != 1: |         if self.__import.version != 1: | ||||||
|             self.logger.warning("Invalid bundle version") |             self.logger.warning("Invalid bundle version") | ||||||
|             return False |             return False | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | |||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| from django.template.response import TemplateResponse | from django.template.response import TemplateResponse | ||||||
|  | from django.urls import reverse | ||||||
| from django.utils.decorators import method_decorator | from django.utils.decorators import method_decorator | ||||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||||
| from django.views.generic import View | from django.views.generic import View | ||||||
| @ -37,6 +38,7 @@ from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableExce | |||||||
| from authentik.flows.models import ( | from authentik.flows.models import ( | ||||||
|     ConfigurableStage, |     ConfigurableStage, | ||||||
|     Flow, |     Flow, | ||||||
|  |     FlowDeniedAction, | ||||||
|     FlowDesignation, |     FlowDesignation, | ||||||
|     FlowStageBinding, |     FlowStageBinding, | ||||||
|     FlowToken, |     FlowToken, | ||||||
| @ -49,21 +51,22 @@ from authentik.flows.planner import ( | |||||||
|     FlowPlan, |     FlowPlan, | ||||||
|     FlowPlanner, |     FlowPlanner, | ||||||
| ) | ) | ||||||
| from authentik.flows.stage import AccessDeniedChallengeView | from authentik.flows.stage import AccessDeniedChallengeView, StageView | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
| 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.policies.engine import PolicyEngine | ||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| # Argument used to redirect user after login | # Argument used to redirect user after login | ||||||
| NEXT_ARG_NAME = "next" | NEXT_ARG_NAME = "next" | ||||||
| SESSION_KEY_PLAN = "authentik_flows_plan" | SESSION_KEY_PLAN = "authentik/flows/plan" | ||||||
| SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre" | SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre" | ||||||
| SESSION_KEY_GET = "authentik_flows_get" | SESSION_KEY_GET = "authentik/flows/get" | ||||||
| SESSION_KEY_POST = "authentik_flows_post" | SESSION_KEY_POST = "authentik/flows/post" | ||||||
| SESSION_KEY_HISTORY = "authentik_flows_history" | SESSION_KEY_HISTORY = "authentik/flows/history" | ||||||
| QS_KEY_TOKEN = "flow_token"  # nosec | QS_KEY_TOKEN = "flow_token"  # nosec | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -129,21 +132,27 @@ class FlowExecutorView(APIView): | |||||||
|         self._logger = get_logger().bind(flow_slug=flow_slug) |         self._logger = get_logger().bind(flow_slug=flow_slug) | ||||||
|         set_tag("authentik.flow", self.flow.slug) |         set_tag("authentik.flow", self.flow.slug) | ||||||
|  |  | ||||||
|     def handle_invalid_flow(self, exc: BaseException) -> HttpResponse: |     def handle_invalid_flow(self, exc: FlowNonApplicableException) -> HttpResponse: | ||||||
|         """When a flow is non-applicable check if user is on the correct domain""" |         """When a flow is non-applicable check if user is on the correct domain""" | ||||||
|         if NEXT_ARG_NAME in self.request.GET: |         if self.flow.denied_action in [ | ||||||
|             if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)): |             FlowDeniedAction.CONTINUE, | ||||||
|  |             FlowDeniedAction.MESSAGE_CONTINUE, | ||||||
|  |         ]: | ||||||
|  |             next_url = self.request.GET.get(NEXT_ARG_NAME) | ||||||
|  |             if next_url and not is_url_absolute(next_url): | ||||||
|                 self._logger.debug("f(exec): Redirecting to next on fail") |                 self._logger.debug("f(exec): Redirecting to next on fail") | ||||||
|                 return redirect(self.request.GET.get(NEXT_ARG_NAME)) |                 return to_stage_response(self.request, redirect(next_url)) | ||||||
|         message = exc.__doc__ if exc.__doc__ else str(exc) |         if self.flow.denied_action == FlowDeniedAction.CONTINUE: | ||||||
|         return self.stage_invalid(error_message=message) |             return to_stage_response( | ||||||
|  |                 self.request, redirect(reverse("authentik_core:root-redirect")) | ||||||
|  |             ) | ||||||
|  |         return to_stage_response(self.request, self.stage_invalid(error_message=exc.messages)) | ||||||
|  |  | ||||||
|     def _check_flow_token(self, get_params: QueryDict): |     def _check_flow_token(self, key: str) -> Optional[FlowPlan]: | ||||||
|         """Check if the user is using a flow token to restore a plan""" |         """Check if the user is using a flow token to restore a plan""" | ||||||
|         tokens = FlowToken.filter_not_expired(key=get_params[QS_KEY_TOKEN]) |         token: Optional[FlowToken] = FlowToken.filter_not_expired(key=key).first() | ||||||
|         if not tokens.exists(): |         if not token: | ||||||
|             return False |             return None | ||||||
|         token: FlowToken = tokens.first() |  | ||||||
|         try: |         try: | ||||||
|             plan = token.plan |             plan = token.plan | ||||||
|         except (AttributeError, EOFError, ImportError, IndexError) as exc: |         except (AttributeError, EOFError, ImportError, IndexError) as exc: | ||||||
| @ -164,7 +173,7 @@ class FlowExecutorView(APIView): | |||||||
|             span.set_data("authentik Flow", self.flow.slug) |             span.set_data("authentik Flow", self.flow.slug) | ||||||
|             get_params = QueryDict(request.GET.get("query", "")) |             get_params = QueryDict(request.GET.get("query", "")) | ||||||
|             if QS_KEY_TOKEN in get_params: |             if QS_KEY_TOKEN in get_params: | ||||||
|                 plan = self._check_flow_token(get_params) |                 plan = self._check_flow_token(get_params[QS_KEY_TOKEN]) | ||||||
|                 if plan: |                 if plan: | ||||||
|                     self.request.session[SESSION_KEY_PLAN] = plan |                     self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|             # Early check if there's an active Plan for the current session |             # Early check if there's an active Plan for the current session | ||||||
| @ -188,7 +197,7 @@ class FlowExecutorView(APIView): | |||||||
|                     self.plan = self._initiate_plan() |                     self.plan = self._initiate_plan() | ||||||
|                 except FlowNonApplicableException as exc: |                 except FlowNonApplicableException as exc: | ||||||
|                     self._logger.warning("f(exec): Flow not applicable to current user", exc=exc) |                     self._logger.warning("f(exec): Flow not applicable to current user", exc=exc) | ||||||
|                     return to_stage_response(self.request, self.handle_invalid_flow(exc)) |                     return self.handle_invalid_flow(exc) | ||||||
|                 except EmptyFlowException as exc: |                 except EmptyFlowException as exc: | ||||||
|                     self._logger.warning("f(exec): Flow is empty", exc=exc) |                     self._logger.warning("f(exec): Flow is empty", exc=exc) | ||||||
|                     # To match behaviour with loading an empty flow plan from cache, |                     # To match behaviour with loading an empty flow plan from cache, | ||||||
| @ -380,6 +389,8 @@ class FlowExecutorView(APIView): | |||||||
|             "f(exec): Stage ok", |             "f(exec): Stage ok", | ||||||
|             stage_class=class_to_path(self.current_stage_view.__class__), |             stage_class=class_to_path(self.current_stage_view.__class__), | ||||||
|         ) |         ) | ||||||
|  |         if isinstance(self.current_stage_view, StageView): | ||||||
|  |             self.current_stage_view.cleanup() | ||||||
|         self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan)) |         self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan)) | ||||||
|         self.plan.pop() |         self.plan.pop() | ||||||
|         self.request.session[SESSION_KEY_PLAN] = self.plan |         self.request.session[SESSION_KEY_PLAN] = self.plan | ||||||
| @ -416,11 +427,14 @@ class FlowExecutorView(APIView): | |||||||
|             SESSION_KEY_APPLICATION_PRE, |             SESSION_KEY_APPLICATION_PRE, | ||||||
|             SESSION_KEY_PLAN, |             SESSION_KEY_PLAN, | ||||||
|             SESSION_KEY_GET, |             SESSION_KEY_GET, | ||||||
|  |             # We might need the initial POST payloads for later requests | ||||||
|  |             # SESSION_KEY_POST, | ||||||
|             # We don't delete the history on purpose, as a user might |             # We don't delete the history on purpose, as a user might | ||||||
|             # still be inspecting it. |             # still be inspecting it. | ||||||
|             # It's only deleted on a fresh executions |             # It's only deleted on a fresh executions | ||||||
|             # SESSION_KEY_HISTORY, |             # SESSION_KEY_HISTORY, | ||||||
|         ] |         ] | ||||||
|  |         self._logger.debug("f(exec): cleaning up") | ||||||
|         for key in keys_to_delete: |         for key in keys_to_delete: | ||||||
|             if key in self.request.session: |             if key in self.request.session: | ||||||
|                 del self.request.session[key] |                 del self.request.session[key] | ||||||
| @ -466,6 +480,20 @@ class ToDefaultFlow(View): | |||||||
|  |  | ||||||
|     designation: Optional[FlowDesignation] = None |     designation: Optional[FlowDesignation] = None | ||||||
|  |  | ||||||
|  |     def flow_by_policy(self, request: HttpRequest, **flow_filter) -> Optional[Flow]: | ||||||
|  |         """Get a Flow by `**flow_filter` and check if the request from `request` can access it.""" | ||||||
|  |         flows = Flow.objects.filter(**flow_filter).order_by("slug") | ||||||
|  |         for flow in flows: | ||||||
|  |             engine = PolicyEngine(flow, request.user, request) | ||||||
|  |             engine.build() | ||||||
|  |             result = engine.result | ||||||
|  |             if result.passing: | ||||||
|  |                 LOGGER.debug("flow_by_policy: flow passing", flow=flow) | ||||||
|  |                 return flow | ||||||
|  |             LOGGER.warning("flow_by_policy: flow not passing", flow=flow, messages=result.messages) | ||||||
|  |         LOGGER.debug("flow_by_policy: no flow found", filters=flow_filter) | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: |     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||||
|         tenant: Tenant = request.tenant |         tenant: Tenant = request.tenant | ||||||
|         flow = None |         flow = None | ||||||
| @ -476,7 +504,7 @@ class ToDefaultFlow(View): | |||||||
|             flow = tenant.flow_invalidation |             flow = tenant.flow_invalidation | ||||||
|         # If no flow was set, get the first based on slug and policy |         # If no flow was set, get the first based on slug and policy | ||||||
|         if not flow: |         if not flow: | ||||||
|             flow = Flow.with_policy(request, designation=self.designation) |             flow = self.flow_by_policy(request, designation=self.designation) | ||||||
|         # If we still don't have a flow, 404 |         # If we still don't have a flow, 404 | ||||||
|         if not flow: |         if not flow: | ||||||
|             raise Http404 |             raise Http404 | ||||||
|  | |||||||
| @ -1,3 +1,4 @@ | |||||||
|  | # update website/docs/installation/configuration.md | ||||||
| # This is the default configuration file | # This is the default configuration file | ||||||
| postgresql: | postgresql: | ||||||
|   host: localhost |   host: localhost | ||||||
| @ -57,6 +58,10 @@ outposts: | |||||||
|   container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s |   container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s | ||||||
|   discover: true |   discover: true | ||||||
|  |  | ||||||
|  | ldap: | ||||||
|  |   tls: | ||||||
|  |     ciphers: null | ||||||
|  |  | ||||||
| cookie_domain: null | cookie_domain: null | ||||||
| disable_update_check: false | disable_update_check: false | ||||||
| disable_startup_analytics: false | disable_startup_analytics: false | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """authentik sentry integration""" | """authentik sentry integration""" | ||||||
| from typing import Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from aioredis.errors import ConnectionClosedError, ReplyError | from aioredis.errors import ConnectionClosedError, ReplyError | ||||||
| from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError | from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError | ||||||
| @ -17,7 +17,7 @@ from ldap3.core.exceptions import LDAPException | |||||||
| from redis.exceptions import ConnectionError as RedisConnectionError | from redis.exceptions import ConnectionError as RedisConnectionError | ||||||
| from redis.exceptions import RedisError, ResponseError | from redis.exceptions import RedisError, ResponseError | ||||||
| from rest_framework.exceptions import APIException | from rest_framework.exceptions import APIException | ||||||
| from sentry_sdk import Hub | from sentry_sdk import HttpTransport, Hub | ||||||
| from sentry_sdk import init as sentry_sdk_init | from sentry_sdk import init as sentry_sdk_init | ||||||
| from sentry_sdk.api import set_tag | from sentry_sdk.api import set_tag | ||||||
| from sentry_sdk.integrations.celery import CeleryIntegration | from sentry_sdk.integrations.celery import CeleryIntegration | ||||||
| @ -30,6 +30,7 @@ from websockets.exceptions import WebSocketException | |||||||
|  |  | ||||||
| from authentik import __version__, get_build_hash | from authentik import __version__, get_build_hash | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
|  | from authentik.lib.utils.http import authentik_user_agent | ||||||
| from authentik.lib.utils.reflection import class_to_path, get_env | from authentik.lib.utils.reflection import class_to_path, get_env | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -52,11 +53,18 @@ class SentryIgnoredException(Exception): | |||||||
|     """Base Class for all errors that are suppressed, and not sent to sentry.""" |     """Base Class for all errors that are suppressed, and not sent to sentry.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SentryTransport(HttpTransport): | ||||||
|  |     """Custom sentry transport with custom user-agent""" | ||||||
|  |  | ||||||
|  |     def __init__(self, options: dict[str, Any]) -> None: | ||||||
|  |         super().__init__(options) | ||||||
|  |         self._auth = self.parsed_dsn.to_auth(authentik_user_agent()) | ||||||
|  |  | ||||||
|  |  | ||||||
| def sentry_init(**sentry_init_kwargs): | def sentry_init(**sentry_init_kwargs): | ||||||
|     """Configure sentry SDK""" |     """Configure sentry SDK""" | ||||||
|     sentry_env = CONFIG.y("error_reporting.environment", "customer") |     sentry_env = CONFIG.y("error_reporting.environment", "customer") | ||||||
|     kwargs = { |     kwargs = { | ||||||
|         "traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.5)), |  | ||||||
|         "environment": sentry_env, |         "environment": sentry_env, | ||||||
|         "send_default_pii": CONFIG.y_bool("error_reporting.send_pii", False), |         "send_default_pii": CONFIG.y_bool("error_reporting.send_pii", False), | ||||||
|     } |     } | ||||||
| @ -71,7 +79,9 @@ def sentry_init(**sentry_init_kwargs): | |||||||
|             ThreadingIntegration(propagate_hub=True), |             ThreadingIntegration(propagate_hub=True), | ||||||
|         ], |         ], | ||||||
|         before_send=before_send, |         before_send=before_send, | ||||||
|  |         traces_sampler=traces_sampler, | ||||||
|         release=f"authentik@{__version__}", |         release=f"authentik@{__version__}", | ||||||
|  |         transport=SentryTransport, | ||||||
|         **kwargs, |         **kwargs, | ||||||
|     ) |     ) | ||||||
|     set_tag("authentik.build_hash", get_build_hash("tagged")) |     set_tag("authentik.build_hash", get_build_hash("tagged")) | ||||||
| @ -83,6 +93,15 @@ def sentry_init(**sentry_init_kwargs): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def traces_sampler(sampling_context: dict) -> float: | ||||||
|  |     """Custom sampler to ignore certain routes""" | ||||||
|  |     path = sampling_context.get("asgi_scope", {}).get("path", "") | ||||||
|  |     # Ignore all healthcheck routes | ||||||
|  |     if path.startswith("/-/health") or path.startswith("/-/metrics"): | ||||||
|  |         return 0 | ||||||
|  |     return float(CONFIG.y("error_reporting.sample_rate", 0.5)) | ||||||
|  |  | ||||||
|  |  | ||||||
| def before_send(event: dict, hint: dict) -> Optional[dict]: | def before_send(event: dict, hint: dict) -> Optional[dict]: | ||||||
|     """Check if error is database error, and ignore if so""" |     """Check if error is database error, and ignore if so""" | ||||||
|     # pylint: disable=no-name-in-module |     # pylint: disable=no-name-in-module | ||||||
|  | |||||||
| @ -1,10 +1,18 @@ | |||||||
| """error utils""" | """error utils""" | ||||||
| from traceback import format_tb | from traceback import extract_tb | ||||||
|  |  | ||||||
| TRACEBACK_HEADER = "Traceback (most recent call last):\n" | from authentik.lib.utils.reflection import class_to_path | ||||||
|  |  | ||||||
|  | TRACEBACK_HEADER = "Traceback (most recent call last):" | ||||||
|  |  | ||||||
|  |  | ||||||
| def exception_to_string(exc: Exception) -> str: | def exception_to_string(exc: Exception) -> str: | ||||||
|     """Convert exception to string stackrace""" |     """Convert exception to string stackrace""" | ||||||
|     # Either use passed original exception or whatever we have |     # Either use passed original exception or whatever we have | ||||||
|     return TRACEBACK_HEADER + "".join(format_tb(exc.__traceback__)) + str(exc) |     return "\n".join( | ||||||
|  |         [ | ||||||
|  |             TRACEBACK_HEADER, | ||||||
|  |             *[x.rstrip() for x in extract_tb(exc.__traceback__).format()], | ||||||
|  |             f"{class_to_path(exc.__class__)}: {str(exc)}", | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								authentik/lib/xml.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								authentik/lib/xml.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | """XML Utilities""" | ||||||
|  | from lxml.etree import XMLParser, fromstring  # nosec | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_lxml_parser(): | ||||||
|  |     """Get XML parser""" | ||||||
|  |     return XMLParser(resolve_entities=False) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def lxml_from_string(text: str): | ||||||
|  |     """Wrapper around fromstring""" | ||||||
|  |     return fromstring(text, parser=get_lxml_parser()) | ||||||
| @ -8,9 +8,3 @@ class AuthentikManagedConfig(AppConfig): | |||||||
|     name = "authentik.managed" |     name = "authentik.managed" | ||||||
|     label = "authentik_managed" |     label = "authentik_managed" | ||||||
|     verbose_name = "authentik Managed" |     verbose_name = "authentik Managed" | ||||||
|  |  | ||||||
|     def ready(self) -> None: |  | ||||||
|         from authentik.managed.tasks import managed_reconcile |  | ||||||
|  |  | ||||||
|         # pyright: reportGeneralTypeIssues=false |  | ||||||
|         managed_reconcile.delay()  # pylint: disable=no-value-for-parameter |  | ||||||
|  | |||||||
| @ -118,6 +118,7 @@ class DockerServiceConnectionViewSet(UsedByMixin, ModelViewSet): | |||||||
|     serializer_class = DockerServiceConnectionSerializer |     serializer_class = DockerServiceConnectionSerializer | ||||||
|     filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"] |     filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |     search_fields = ["name"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): | class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): | ||||||
| @ -152,3 +153,4 @@ class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet): | |||||||
|     serializer_class = KubernetesServiceConnectionSerializer |     serializer_class = KubernetesServiceConnectionSerializer | ||||||
|     filterset_fields = ["name", "local"] |     filterset_fields = ["name", "local"] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |     search_fields = ["name"] | ||||||
|  | |||||||
| @ -2,11 +2,20 @@ | |||||||
| from importlib import import_module | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.db import ProgrammingError | from prometheus_client import Gauge | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  | GAUGE_OUTPOSTS_CONNECTED = Gauge( | ||||||
|  |     "authentik_outposts_connected", "Currently connected outposts", ["outpost", "uid", "expected"] | ||||||
|  | ) | ||||||
|  | GAUGE_OUTPOSTS_LAST_UPDATE = Gauge( | ||||||
|  |     "authentik_outposts_last_update", | ||||||
|  |     "Last update from any outpost", | ||||||
|  |     ["outpost", "uid", "version"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthentikOutpostConfig(AppConfig): | class AuthentikOutpostConfig(AppConfig): | ||||||
|     """authentik outposts app config""" |     """authentik outposts app config""" | ||||||
| @ -18,10 +27,3 @@ class AuthentikOutpostConfig(AppConfig): | |||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.outposts.signals") |         import_module("authentik.outposts.signals") | ||||||
|         import_module("authentik.outposts.managed") |         import_module("authentik.outposts.managed") | ||||||
|         try: |  | ||||||
|             from authentik.outposts.tasks import outpost_controller_all, outpost_local_connection |  | ||||||
|  |  | ||||||
|             outpost_local_connection.delay() |  | ||||||
|             outpost_controller_all.delay() |  | ||||||
|         except ProgrammingError: |  | ||||||
|             pass |  | ||||||
|  | |||||||
| @ -8,21 +8,12 @@ from channels.exceptions import DenyConnection | |||||||
| from dacite import from_dict | from dacite import from_dict | ||||||
| from dacite.data import Data | from dacite.data import Data | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from prometheus_client import Gauge |  | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  |  | ||||||
| from authentik.core.channels import AuthJsonConsumer | from authentik.core.channels import AuthJsonConsumer | ||||||
|  | from authentik.outposts.apps import GAUGE_OUTPOSTS_CONNECTED, GAUGE_OUTPOSTS_LAST_UPDATE | ||||||
| from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState | ||||||
|  |  | ||||||
| GAUGE_OUTPOSTS_CONNECTED = Gauge( |  | ||||||
|     "authentik_outposts_connected", "Currently connected outposts", ["outpost", "uid", "expected"] |  | ||||||
| ) |  | ||||||
| GAUGE_OUTPOSTS_LAST_UPDATE = Gauge( |  | ||||||
|     "authentik_outposts_last_update", |  | ||||||
|     "Last update from any outpost", |  | ||||||
|     ["outpost", "uid", "version"], |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class WebsocketMessageInstruction(IntEnum): | class WebsocketMessageInstruction(IntEnum): | ||||||
|     """Commands which can be triggered over Websocket""" |     """Commands which can be triggered over Websocket""" | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	