Compare commits
	
		
			519 Commits
		
	
	
		
			version-20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 260a7aac63 | |||
| 37df054f4c | |||
| a3df414f24 | |||
| dcaa8d6322 | |||
| e03dd70f2f | |||
| ceb894039e | |||
| a77616e942 | |||
| 47601a767b | |||
| c7a825c393 | |||
| 181c55aef1 | |||
| 631b1fcc29 | |||
| 54f170650a | |||
| 3bdb551e74 | |||
| 96b2631ec4 | |||
| 4fffa6d2cc | |||
| e46c70e13d | |||
| 7d4e7f84f4 | |||
| d49640ca9b | |||
| ed2cf44471 | |||
| 5b1d15276a | |||
| d9275a3350 | |||
| 2e81dddc1d | |||
| abc73deda0 | |||
| becec6b7d8 | |||
| ab516f782b | |||
| d7b3c545aa | |||
| 81550d9d1d | |||
| 72e5768c2f | |||
| 11cf5fc472 | |||
| fedb81571d | |||
| 37528e1bba | |||
| 97ef2a6f5f | |||
| cc1509cf57 | |||
| 0dfecc6ae2 | |||
| c1e4d78672 | |||
| 0ab427b5bb | |||
| a9f095d1d9 | |||
| de17207c68 | |||
| d9675695fe | |||
| ec7f372fa9 | |||
| 8a675152e6 | |||
| 228fe01f92 | |||
| b9547ece49 | |||
| 6e9bc143bd | |||
| 8cd4bf1be8 | |||
| 76660e4666 | |||
| 73b2e2cb82 | |||
| d741d6dcf1 | |||
| 2575fa6db7 | |||
| 7512c57a2e | |||
| e6e2dfd757 | |||
| 920d1f1b0e | |||
| 680d4fc20d | |||
| 4d3b25ea66 | |||
| 5106c0d0c1 | |||
| fd09ade054 | |||
| 01629fe9e3 | |||
| 5be97e98e4 | |||
| b1fd801ceb | |||
| 62a939b91d | |||
| 257ac04be4 | |||
| ec5e6c14a2 | |||
| 1e1d9f1bdd | |||
| da1ea51dad | |||
| 6ee3b8d644 | |||
| 6155c69b7c | |||
| 136d40d919 | |||
| bb1bb9e22a | |||
| 05e84b63a2 | |||
| 7ab55f7afa | |||
| f5ec5245c5 | |||
| 4f4f954693 | |||
| c57fbcfd89 | |||
| 025fc3fe96 | |||
| 4d079522c4 | |||
| 08acc7ba41 | |||
| 7bdd32506e | |||
| 6283fedcd9 | |||
| 7a0badc81b | |||
| 1e134aa446 | |||
| 27bc5489c5 | |||
| 2dca45917c | |||
| 66a4338b48 | |||
| a4dfc7e068 | |||
| f98a9bed9f | |||
| 5d1bf4a0af | |||
| 34635ab928 | |||
| fabe1130c1 | |||
| 8feda9c2b1 | |||
| 074928cac1 | |||
| 2308f90270 | |||
| 13adca0763 | |||
| 50ded723d1 | |||
| e9064509fe | |||
| 6fdf3ad3e5 | |||
| fb60cefb72 | |||
| 61f7db314a | |||
| ef7952cab3 | |||
| 7e5d8624c8 | |||
| 2c54be85be | |||
| 2f8dbe9b97 | |||
| cebe44403c | |||
| 7261017e13 | |||
| 0b3d33f428 | |||
| 6f0cbd5fa6 | |||
| fb94aefd2f | |||
| c4c8390eff | |||
| 8c2e4478fd | |||
| 94029ee612 | |||
| 8db49f9eca | |||
| 7bd25d90f4 | |||
| 133528ee90 | |||
| 578bd8fcb3 | |||
| 4c2ef95253 | |||
| 702a59222d | |||
| 48e2121a75 | |||
| 61249786ff | |||
| 008af4ccce | |||
| 02e3010efe | |||
| aca4795e0c | |||
| ff0febfecd | |||
| 4daad4b514 | |||
| 677bcaadd7 | |||
| c6e9ecdd37 | |||
| c9ecad6262 | |||
| e545b3b401 | |||
| fec96ea013 | |||
| 1ac1c50b67 | |||
| d2f189c1d0 | |||
| fb33906637 | |||
| 6d3a94f24f | |||
| 84f594e658 | |||
| 1486bd5ab2 | |||
| 2c00f4da2d | |||
| c10a23220b | |||
| f20243d545 | |||
| 903c6422ad | |||
| f5ab955536 | |||
| 3a861f0497 | |||
| 744f250d05 | |||
| 83d435bd3b | |||
| 945cdfe212 | |||
| fcc0963fab | |||
| 2ab4fcd757 | |||
| bfe31b15ad | |||
| 49c4b43f32 | |||
| 19b1f3a8c1 | |||
| 80f218a6bf | |||
| 61aaa90226 | |||
| 7fdda5a387 | |||
| 94597fd2ad | |||
| 09808883f4 | |||
| 81ecb85a55 | |||
| 21bfaa3927 | |||
| 1c9c7be1c0 | |||
| 5a11dc567e | |||
| 4a1acd377b | |||
| c5b84a91d1 | |||
| e77ecda3b8 | |||
| 4e317c10c5 | |||
| eb05a3ddb8 | |||
| a22d6a0924 | |||
| 3f0d67779a | |||
| 0a937ae8e9 | |||
| f8d94f3039 | |||
| 6bb261ac62 | |||
| 45f2c5bae7 | |||
| 5d8c1aa0b0 | |||
| 0101368369 | |||
| 4854f81592 | |||
| 4bed6e02e5 | |||
| 908f123d0e | |||
| 256dd24a1e | |||
| d4284407f9 | |||
| 80da5dfc52 | |||
| b6edf990e0 | |||
| a66dcf9382 | |||
| 9095a840d5 | |||
| 72259f6479 | |||
| 0973c74b9d | |||
| c7ed4f7ac1 | |||
| 3d577cf15e | |||
| 5474a32573 | |||
| a5940b88e3 | |||
| ff15716012 | |||
| c040b13b29 | |||
| 4915e980c5 | |||
| df362dd9ea | |||
| d4e4f93cb4 | |||
| 3af0de6a00 | |||
| 4f24d61290 | |||
| 4c5c4dcf2c | |||
| 660b5cb6c6 | |||
| 6ff1ea73a9 | |||
| 3de224690a | |||
| d4624b510a | |||
| 8856d762d0 | |||
| 5d1cbf14d1 | |||
| 6d5207f644 | |||
| 3b6497cd51 | |||
| ff7320b0f8 | |||
| e5a393c534 | |||
| bb4be944dc | |||
| 21efee8f44 | |||
| f61549a60f | |||
| 0a7bafd1b2 | |||
| b3987c5fa0 | |||
| 0da043a9fe | |||
| f336f204cb | |||
| 3bfcf18492 | |||
| dfafe8b43d | |||
| b5d43b15f8 | |||
| 2ccab75021 | |||
| 9070df6c26 | |||
| a1c8ad55ad | |||
| 872c05c690 | |||
| a9528dc1b5 | |||
| 0e59ade1f2 | |||
| 5ac49c695d | |||
| 3a30ecbe76 | |||
| 1f838bb2aa | |||
| cc42830e23 | |||
| 593eb959ca | |||
| 5bb6785ad6 | |||
| 535c11a729 | |||
| a0fa8d8524 | |||
| c14025c579 | |||
| 8bc3db7c90 | |||
| eaad564e23 | |||
| 511a94975b | |||
| 015810a2fd | |||
| e70e6b84c2 | |||
| d0b9c9a26f | |||
| 3e403fa348 | |||
| 48f4a971ef | |||
| 6314be14ad | |||
| 1a072c6c39 | |||
| ef2eed0bdf | |||
| 91227b1e96 | |||
| 67d68629da | |||
| e875db8f66 | |||
| 055a76393d | |||
| 0754821628 | |||
| fca88d9896 | |||
| dfe0404c51 | |||
| fa61696b46 | |||
| e5773738f4 | |||
| cac8539d79 | |||
| cf600f6f26 | |||
| e194715c3e | |||
| 787f02d5dc | |||
| a0ed01a610 | |||
| 02ba493759 | |||
| a7fea5434d | |||
| 4fb783e953 | |||
| affbf85699 | |||
| 0d92112a3f | |||
| b1ad3ec9db | |||
| c0601baca6 | |||
| 057c5c5e9a | |||
| 05429ab848 | |||
| b66d51a699 | |||
| f834bc0ff2 | |||
| 93fd883d7a | |||
| 7e080d4d68 | |||
| 3e3ca22d04 | |||
| e741caa6b3 | |||
| 4343246a41 | |||
| 3f6f83b4b6 | |||
| c63e1c9b87 | |||
| f44cf06d22 | |||
| 3f609b8601 | |||
| edd89b44a4 | |||
| 3e58748862 | |||
| 7088a6b0e6 | |||
| 6c880e0e62 | |||
| cb1e70be7f | |||
| 6ba150f737 | |||
| 131769ea73 | |||
| e68adbb30d | |||
| f1eef09099 | |||
| 5ab3c7fa9f | |||
| d0cec39a0f | |||
| e15f53a39a | |||
| 25fb995663 | |||
| eac658c64f | |||
| 15e2032493 | |||
| c87f6cd9d9 | |||
| e758995458 | |||
| 20c284a188 | |||
| b0936ea8f3 | |||
| bfc0f4a413 | |||
| 1a9a90cf6a | |||
| 00f1a6fa48 | |||
| 33754a06d2 | |||
| 69b838e1cf | |||
| d5e04a2301 | |||
| fbf251280f | |||
| eaadf62f01 | |||
| 8c33e7a7c1 | |||
| a7d9a80a28 | |||
| 2ea5dce8d3 | |||
| 14bf01efe4 | |||
| 67b24a60e4 | |||
| e6775297cb | |||
| 4e4e2b36b6 | |||
| 3189c56fc3 | |||
| 5b5ea47b7a | |||
| caa382f898 | |||
| 2d63488197 | |||
| c1c8e4c8d4 | |||
| a0e451c5e5 | |||
| eaba8006e6 | |||
| 39ff202f8c | |||
| 654e0d6245 | |||
| ec04443493 | |||
| d247c262af | |||
| dff49b2bef | |||
| 50666a76fb | |||
| b51a7f9746 | |||
| 001dfd9f6c | |||
| 5e4fbeeb25 | |||
| 2c910bf6ca | |||
| 9b11319e81 | |||
| 40dc4b3fb8 | |||
| 0e37b98968 | |||
| 7e132eb014 | |||
| 49dfb4756e | |||
| 814758e2aa | |||
| 5c42dac5e2 | |||
| 88603fa4f7 | |||
| 0232c4e162 | |||
| 11753c1fe1 | |||
| f5cc6c67ec | |||
| 8b8ed3527a | |||
| 1aa0274e7c | |||
| ecd33ca0c1 | |||
| e93be0de9a | |||
| a5adc4f8ed | |||
| a6baed9753 | |||
| ceaf832e63 | |||
| a6b0b14685 | |||
| f679250edd | |||
| acc4de2235 | |||
| 56a8276dbf | |||
| 6dfe6edbef | |||
| 6af4bd0d9a | |||
| 7ee7f6bd6a | |||
| f8b8334010 | |||
| d4b65dc4b4 | |||
| e4bbd3b1c0 | |||
| 87de5e625d | |||
| efbe51673e | |||
| a95bea53ea | |||
| 6021fc0f52 | |||
| 1415b68ff4 | |||
| be6853ac52 | |||
| 7fd6be5abb | |||
| 91d6f572a5 | |||
| 016a9ce34e | |||
| 8adb95af7f | |||
| 1dc54775d8 | |||
| 370ef716b5 | |||
| 16e56ad9ca | |||
| b5b5a9eed3 | |||
| 8b22e7bcc3 | |||
| d48b5b9511 | |||
| 0eccaa3f1e | |||
| 67d550a80d | |||
| ebb5711c32 | |||
| 79ec872232 | |||
| 4284e14ff7 | |||
| 92a09779d0 | |||
| 14c621631d | |||
| c55f503b9b | |||
| a908cad976 | |||
| c2586557d8 | |||
| 01c80a82e2 | |||
| 0d47654651 | |||
| 1183095833 | |||
| c281b11bdc | |||
| 61fe45a58c | |||
| d43aab479c | |||
| 7f8383427a | |||
| a06d6cf33d | |||
| 5b7cb205c9 | |||
| 293a932d20 | |||
| fff901ff03 | |||
| f47c936295 | |||
| 88d5aec618 | |||
| 96ae68cf09 | |||
| 63b3434b6f | |||
| 947ecec02b | |||
| 1c2b452406 | |||
| 47777529ac | |||
| 949095c376 | |||
| 4b112c2799 | |||
| 291a2516b1 | |||
| 4dcfd021e2 | |||
| ca50848db3 | |||
| 0bb3e3c558 | |||
| e4b25809ab | |||
| 7bf932f8e2 | |||
| 99d04528b0 | |||
| e48d172036 | |||
| c2388137a8 | |||
| 650e2cbc38 | |||
| b32800ea71 | |||
| e1c0c0b20c | |||
| fe39e39dcd | |||
| 883f213b03 | |||
| 538996f617 | |||
| 2f4c92deb9 | |||
| ef335ec083 | |||
| 07b09df3fe | |||
| e70e031a1f | |||
| c7ba183dc0 | |||
| 3ed23a37ea | |||
| 3d724db0e3 | |||
| 2997542114 | |||
| 84b18fff96 | |||
| 1dce408c72 | |||
| e5ff47bf14 | |||
| b53bf331c3 | |||
| 90e9a8b34c | |||
| 845f842783 | |||
| 7397849c60 | |||
| 6dd46b5fc5 | |||
| 89ca79ed10 | |||
| 713bef895c | |||
| 925115e9ce | |||
| 42f5cf8c93 | |||
| 82cc1d536a | |||
| 08af2fd46b | |||
| 70e3b27a4d | |||
| 6a411d7960 | |||
| 33567b56d7 | |||
| 0c1954aeb7 | |||
| f4a6c70e98 | |||
| 5f198e7fe4 | |||
| d172d32817 | |||
| af3fb5c2cd | |||
| 885efb526e | |||
| 3bfb8b2cb2 | |||
| 9fc5ff4b77 | |||
| dd8b579dd6 | |||
| e12cbd8711 | |||
| 62d35f8f8c | |||
| 49be504c13 | |||
| edad55e51d | |||
| 38086fa8bb | |||
| c4f9a3e9a7 | |||
| 930df791bd | |||
| 9a6086634c | |||
| b68e65355a | |||
| 72d33a91dd | |||
| 7067e3d69a | |||
| 4db370d24e | |||
| 41e7b9b73f | |||
| 7f47f93e4e | |||
| 89abd44b76 | |||
| 14c7d8c4f4 | |||
| 525976a81b | |||
| 64a2126ea4 | |||
| 994c5882ab | |||
| ad64d51e85 | |||
| a184a7518a | |||
| 943fd80920 | |||
| 01bb18b8c4 | |||
| 94baaaa5a5 | |||
| 40b164ce94 | |||
| 1d7c7801e7 | |||
| 0db0a12ef3 | |||
| 8008aba450 | |||
| eaeab27004 | |||
| 111fbf119b | |||
| 300ad88447 | |||
| 92cc0c9c64 | |||
| 18ff803370 | |||
| 819af78e2b | |||
| 6338785ce1 | |||
| 973e151dff | |||
| fae6d83f27 | |||
| ed84fe0b8d | |||
| 1ee603403e | |||
| 7db7b7cc4d | |||
| 68a98cd86c | |||
| e758db5727 | |||
| 4d7d700afa | |||
| f9a5add01d | |||
| 2986b56389 | |||
| 58f79b525d | |||
| 0a1c0dae05 | |||
| e18ef8dab6 | |||
| 3cacc59bec | |||
| 4eea46d399 | |||
| 11e25617bd | |||
| 4817126811 | |||
| 0181361efa | |||
| 8ff8e1d5f7 | |||
| 19d5902a92 | |||
| 71dffb21a9 | |||
| bd283c506d | |||
| ef564e5f1a | |||
| 2543224c7c | |||
| 077eee9310 | |||
| d894eeaa67 | |||
| 452bfb39bf | |||
| 6b6702521f | |||
| c07b8d95d0 | |||
| bf347730b3 | |||
| ececfc3a30 | |||
| b76546de0c | |||
| 424d490a60 | |||
| 127dd85214 | |||
| 10570ac7f8 | |||
| dc5667b0b8 | |||
| ec9cacb610 | |||
| 0027dbc0e5 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2021.12.5 | current_version = 2022.3.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+)\-?(?P<release>.*) | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -7,7 +7,7 @@ exemptLabels: | |||||||
|   - pinned |   - pinned | ||||||
|   - security |   - security | ||||||
|   - pr_wanted |   - pr_wanted | ||||||
|   - enhancement/confirmed |   - enhancement | ||||||
| # 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 | ||||||
|  | |||||||
							
								
								
									
										65
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										65
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -31,16 +31,16 @@ jobs: | |||||||
|           - pending-migrations |           - pending-migrations | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|       - id: cache-poetry |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
| @ -50,13 +50,13 @@ jobs: | |||||||
|   test-migrations: |   test-migrations: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - id: cache-poetry |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
| @ -66,10 +66,10 @@ jobs: | |||||||
|   test-migrations-from-stable: |   test-migrations-from-stable: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|         with: |         with: | ||||||
|           fetch-depth: 0 |           fetch-depth: 0 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - name: prepare variables |       - name: prepare variables | ||||||
|         id: ev |         id: ev | ||||||
|         run: | |         run: | | ||||||
| @ -79,27 +79,22 @@ jobs: | |||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: checkout stable |       - name: checkout stable | ||||||
|         run: | |         run: | | ||||||
|           # Copy current, latest config to local |           # Copy current, latest config to local | ||||||
|           cp authentik/lib/default.yml local.env.yml |           cp authentik/lib/default.yml local.env.yml | ||||||
|           cp -R .github .. |           cp -R .github .. | ||||||
|           cp -R scripts .. |           cp -R scripts .. | ||||||
|           cp -R poetry.lock pyproject.toml .. |  | ||||||
|           git checkout $(git describe --abbrev=0 --match 'version/*') |           git checkout $(git describe --abbrev=0 --match 'version/*') | ||||||
|           rm -rf .github/ scripts/ |           rm -rf .github/ scripts/ | ||||||
|           mv ../.github ../scripts ../poetry.lock ../pyproject.toml . |           mv ../.github ../scripts . | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: | |         run: | | ||||||
|           scripts/ci_prepare.sh |           scripts/ci_prepare.sh | ||||||
|           # Sync anyways since stable will have different dependencies |           # install anyways since stable will have different dependencies | ||||||
|           # TODO: Remove after next stable release |  | ||||||
|           if [[ -f "Pipfile.lock" ]]; then |  | ||||||
|             pipenv install --dev |  | ||||||
|           fi |  | ||||||
|           poetry install |           poetry install | ||||||
|       - name: run migrations to stable |       - name: run migrations to stable | ||||||
|         run: poetry run python -m lifecycle.migrate |         run: poetry run python -m lifecycle.migrate | ||||||
| @ -108,13 +103,7 @@ jobs: | |||||||
|           set -x |           set -x | ||||||
|           git fetch |           git fetch | ||||||
|           git reset --hard HEAD |           git reset --hard HEAD | ||||||
|           # TODO: Remove after next stable release |  | ||||||
|           rm -f poetry.lock |  | ||||||
|           git checkout $GITHUB_SHA |           git checkout $GITHUB_SHA | ||||||
|           # TODO: Remove after next stable release |  | ||||||
|           if [[ -f "Pipfile.lock" ]]; then |  | ||||||
|             pipenv install --dev |  | ||||||
|           fi |  | ||||||
|           poetry install |           poetry install | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
| @ -125,13 +114,13 @@ jobs: | |||||||
|   test-unittest: |   test-unittest: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - id: cache-poetry |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
| @ -152,13 +141,13 @@ jobs: | |||||||
|   test-integration: |   test-integration: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - id: cache-poetry |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
| @ -181,9 +170,9 @@ jobs: | |||||||
|   test-e2e-provider: |   test-e2e-provider: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -195,7 +184,7 @@ jobs: | |||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
| @ -226,9 +215,9 @@ jobs: | |||||||
|   test-e2e-rest: |   test-e2e-rest: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -240,7 +229,7 @@ jobs: | |||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
| @ -290,7 +279,7 @@ jobs: | |||||||
|         arch: |         arch: | ||||||
|           - 'linux/amd64' |           - 'linux/amd64' | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - name: Set up QEMU |       - name: Set up QEMU | ||||||
|         uses: docker/setup-qemu-action@v1.2.0 |         uses: docker/setup-qemu-action@v1.2.0 | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|  | |||||||
							
								
								
									
										26
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -14,7 +14,7 @@ jobs: | |||||||
|   lint-golint: |   lint-golint: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-go@v2 |       - uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
| @ -28,11 +28,27 @@ jobs: | |||||||
|             --rm \ |             --rm \ | ||||||
|             -v $(pwd):/app \ |             -v $(pwd):/app \ | ||||||
|             -w /app \ |             -w /app \ | ||||||
|             golangci/golangci-lint:v1.39.0 \ |             golangci/golangci-lint:v1.43 \ | ||||||
|             golangci-lint run -v --timeout 200s |             golangci-lint run -v --timeout 200s | ||||||
|  |   test-unittest: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v3 | ||||||
|  |       - uses: actions/setup-go@v2 | ||||||
|  |         with: | ||||||
|  |           go-version: "^1.17" | ||||||
|  |       - name: Get dependencies | ||||||
|  |         run: | | ||||||
|  |           go get github.com/axw/gocov/gocov | ||||||
|  |           go get github.com/AlekSi/gocov-xml | ||||||
|  |           go get github.com/jstemmer/go-junit-report | ||||||
|  |       - name: Go unittests | ||||||
|  |         run: | | ||||||
|  |           go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... | go-junit-report > junit.xml | ||||||
|   ci-outpost-mark: |   ci-outpost-mark: | ||||||
|     needs: |     needs: | ||||||
|       - lint-golint |       - lint-golint | ||||||
|  |       - test-unittest | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - run: echo mark |       - run: echo mark | ||||||
| @ -50,7 +66,7 @@ jobs: | |||||||
|           - 'linux/amd64' |           - 'linux/amd64' | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - name: Set up QEMU |       - name: Set up QEMU | ||||||
|         uses: docker/setup-qemu-action@v1.2.0 |         uses: docker/setup-qemu-action@v1.2.0 | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
| @ -94,11 +110,11 @@ jobs: | |||||||
|         goos: [linux] |         goos: [linux] | ||||||
|         goarch: [amd64, arm64] |         goarch: [amd64, arm64] | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-go@v2 |       - uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										16
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -14,8 +14,8 @@ jobs: | |||||||
|   lint-eslint: |   lint-eslint: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -32,8 +32,8 @@ jobs: | |||||||
|   lint-prettier: |   lint-prettier: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -50,8 +50,8 @@ jobs: | |||||||
|   lint-lit-analyse: |   lint-lit-analyse: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -78,8 +78,8 @@ jobs: | |||||||
|       - ci-web-mark |       - ci-web-mark | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @ -28,7 +28,7 @@ jobs: | |||||||
|  |  | ||||||
|     steps: |     steps: | ||||||
|     - name: Checkout repository |     - name: Checkout repository | ||||||
|       uses: actions/checkout@v2 |       uses: actions/checkout@v3 | ||||||
|  |  | ||||||
|     # Initializes the CodeQL tools for scanning. |     # Initializes the CodeQL tools for scanning. | ||||||
|     - name: Initialize CodeQL |     - name: Initialize CodeQL | ||||||
|  | |||||||
							
								
								
									
										40
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										40
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,7 +9,7 @@ jobs: | |||||||
|   build-server: |   build-server: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - name: Set up QEMU |       - name: Set up QEMU | ||||||
|         uses: docker/setup-qemu-action@v1.2.0 |         uses: docker/setup-qemu-action@v1.2.0 | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
| @ -30,21 +30,12 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik:2021.12.5, |             beryju/authentik:2022.3.2, | ||||||
|             beryju/authentik:latest, |             beryju/authentik:latest, | ||||||
|             ghcr.io/goauthentik/server:2021.12.5, |             ghcr.io/goauthentik/server:2022.3.2, | ||||||
|             ghcr.io/goauthentik/server:latest |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|       - name: Building Docker Image (stable) |  | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }} |  | ||||||
|         run: | |  | ||||||
|           docker pull beryju/authentik:latest |  | ||||||
|           docker tag beryju/authentik:latest beryju/authentik:stable |  | ||||||
|           docker push beryju/authentik:stable |  | ||||||
|           docker pull ghcr.io/goauthentik/server:latest |  | ||||||
|           docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable |  | ||||||
|           docker push ghcr.io/goauthentik/server:stable |  | ||||||
|   build-outpost: |   build-outpost: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     strategy: |     strategy: | ||||||
| @ -54,7 +45,7 @@ jobs: | |||||||
|           - proxy |           - proxy | ||||||
|           - ldap |           - ldap | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-go@v2 |       - uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
| @ -78,21 +69,12 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-${{ matrix.type }}:2021.12.5, |             beryju/authentik-${{ matrix.type }}:2022.3.2, | ||||||
|             beryju/authentik-${{ matrix.type }}:latest, |             beryju/authentik-${{ matrix.type }}:latest, | ||||||
|             ghcr.io/goauthentik/${{ matrix.type }}:2021.12.5, |             ghcr.io/goauthentik/${{ matrix.type }}:2022.3.2, | ||||||
|             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 | ||||||
|       - name: Building Docker Image (stable) |  | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }} |  | ||||||
|         run: | |  | ||||||
|           docker pull beryju/authentik-${{ matrix.type }}:latest |  | ||||||
|           docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable |  | ||||||
|           docker push beryju/authentik-${{ matrix.type }}:stable |  | ||||||
|           docker pull ghcr.io/goauthentik/${{ matrix.type }}:latest |  | ||||||
|           docker tag ghcr.io/goauthentik/${{ matrix.type }}:latest ghcr.io/goauthentik/${{ matrix.type }}:stable |  | ||||||
|           docker push ghcr.io/goauthentik/${{ matrix.type }}:stable |  | ||||||
|   build-outpost-binary: |   build-outpost-binary: | ||||||
|     timeout-minutes: 120 |     timeout-minutes: 120 | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
| @ -105,11 +87,11 @@ jobs: | |||||||
|         goos: [linux, darwin] |         goos: [linux, darwin] | ||||||
|         goarch: [amd64, arm64] |         goarch: [amd64, arm64] | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-go@v2 |       - uses: actions/setup-go@v2 | ||||||
|         with: |         with: | ||||||
|           go-version: "^1.17" |           go-version: "^1.17" | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           cache: 'npm' |           cache: 'npm' | ||||||
| @ -139,7 +121,7 @@ jobs: | |||||||
|       - build-outpost-binary |       - build-outpost-binary | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - name: Run test suite in final docker images |       - name: Run test suite in final docker images | ||||||
|         run: | |         run: | | ||||||
|           echo "PG_PASS=$(openssl rand -base64 32)" >> .env |           echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
| @ -155,7 +137,7 @@ jobs: | |||||||
|       - build-outpost-binary |       - build-outpost-binary | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - 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 | ||||||
| @ -170,7 +152,7 @@ jobs: | |||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2021.12.5 |           version: authentik@2022.3.2 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|           sourcemaps: './web/dist' |           sourcemaps: './web/dist' | ||||||
|           url_prefix: '~/static/dist' |           url_prefix: '~/static/dist' | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -10,7 +10,7 @@ jobs: | |||||||
|     name: Create Release from Tag |     name: Create Release from Tag | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - name: Pre-release test |       - name: Pre-release test | ||||||
|         run: | |         run: | | ||||||
|           echo "PG_PASS=$(openssl rand -base64 32)" >> .env |           echo "PG_PASS=$(openssl rand -base64 32)" >> .env | ||||||
| @ -27,7 +27,7 @@ jobs: | |||||||
|           docker-compose run -u root server test |           docker-compose run -u root server test | ||||||
|       - name: Extract version number |       - name: Extract version number | ||||||
|         id: get_version |         id: get_version | ||||||
|         uses: actions/github-script@v5 |         uses: actions/github-script@v6 | ||||||
|         with: |         with: | ||||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} |           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|           script: | |           script: | | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							| @ -20,13 +20,13 @@ jobs: | |||||||
|   compile: |   compile: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v3 | ||||||
|       - id: cache-poetry |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.cache/pypoetry/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} |           key: ${{ runner.os }}-poetry-cache-v2-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -8,9 +8,9 @@ jobs: | |||||||
|   build: |   build: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v3 | ||||||
|       # Setup .npmrc file to publish to npm |       # Setup .npmrc file to publish to npm | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v3.0.0 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|           registry-url: 'https://registry.npmjs.org' |           registry-url: 'https://registry.npmjs.org' | ||||||
|  | |||||||
| @ -1 +0,0 @@ | |||||||
| 3.9.7 |  | ||||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -12,7 +12,8 @@ | |||||||
|         "totp", |         "totp", | ||||||
|         "webauthn", |         "webauthn", | ||||||
|         "traefik", |         "traefik", | ||||||
|         "passwordless" |         "passwordless", | ||||||
|  |         "kubernetes" | ||||||
|     ], |     ], | ||||||
|     "python.linting.pylintEnabled": true, |     "python.linting.pylintEnabled": true, | ||||||
|     "todo-tree.tree.showCountsInTree": true, |     "todo-tree.tree.showCountsInTree": true, | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ ENV NODE_ENV=production | |||||||
| RUN cd /work/web && npm i && npm run build | RUN cd /work/web && npm i && npm run build | ||||||
|  |  | ||||||
| # Stage 3: Build go proxy | # Stage 3: Build go proxy | ||||||
| FROM docker.io/golang:1.17.5-bullseye AS builder | FROM docker.io/golang:1.17.8-bullseye AS builder | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
|  |  | ||||||
| @ -32,7 +32,7 @@ COPY ./go.sum /work/go.sum | |||||||
| RUN go build -o /work/authentik ./cmd/server/main.go | RUN go build -o /work/authentik ./cmd/server/main.go | ||||||
|  |  | ||||||
| # Stage 4: Run | # Stage 4: Run | ||||||
| FROM docker.io/python:3.10.1-slim-bullseye | FROM docker.io/python:3.10.2-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. | ||||||
| @ -60,9 +60,9 @@ RUN apt-get update && \ | |||||||
|     apt-get clean && \ |     apt-get clean && \ | ||||||
|     rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ |     rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ | ||||||
|     adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ |     adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ | ||||||
|     mkdir -p /backups /certs /media && \ |     mkdir -p /certs /media && \ | ||||||
|     mkdir -p /authentik/.ssh && \ |     mkdir -p /authentik/.ssh && \ | ||||||
|     chown authentik:authentik /backups /certs /media /authentik/.ssh |     chown authentik:authentik /certs /media /authentik/.ssh | ||||||
|  |  | ||||||
| COPY ./authentik/ /authentik | COPY ./authentik/ /authentik | ||||||
| COPY ./pyproject.toml / | COPY ./pyproject.toml / | ||||||
|  | |||||||
							
								
								
									
										13
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								Makefile
									
									
									
									
									
								
							| @ -15,6 +15,9 @@ test-e2e-provider: | |||||||
| test-e2e-rest: | test-e2e-rest: | ||||||
| 	coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source* | 	coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source* | ||||||
|  |  | ||||||
|  | test-go: | ||||||
|  | 	go test -timeout 0 -v -race -cover ./... | ||||||
|  |  | ||||||
| test: | test: | ||||||
| 	coverage run manage.py test authentik | 	coverage run manage.py test authentik | ||||||
| 	coverage html | 	coverage html | ||||||
| @ -127,3 +130,13 @@ ci-pyright: ci--meta-debug | |||||||
|  |  | ||||||
| ci-pending-migrations: ci--meta-debug | ci-pending-migrations: ci--meta-debug | ||||||
| 	./manage.py makemigrations --check | 	./manage.py makemigrations --check | ||||||
|  |  | ||||||
|  | install: | ||||||
|  | 	poetry install | ||||||
|  | 	cd web && npm i | ||||||
|  | 	cd website && npm i | ||||||
|  |  | ||||||
|  | a: install | ||||||
|  | 	tmux -CC \ | ||||||
|  | 		new-session 'make run' \; \ | ||||||
|  | 		split-window 'make web-watch' | ||||||
|  | |||||||
| @ -57,4 +57,4 @@ DigitalOcean provides development and testing resources for authentik. | |||||||
|     </a> |     </a> | ||||||
| </p> | </p> | ||||||
|  |  | ||||||
| Netlify hosts the [goauthentik.io](goauthentik.io) site. | Netlify hosts the [goauthentik.io](https://goauthentik.io) site. | ||||||
|  | |||||||
| @ -6,8 +6,8 @@ | |||||||
|  |  | ||||||
| | Version    | Supported          | | | Version    | Supported          | | ||||||
| | ---------- | ------------------ | | | ---------- | ------------------ | | ||||||
| | 2021.10.x  | :white_check_mark: | | | 2022.2.x   | :white_check_mark: | | ||||||
| | 2021.12.x  | :white_check_mark: | | | 2022.3.x   | :white_check_mark: | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,3 +1,19 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "2021.12.5" | from os import environ | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
|  | __version__ = "2022.3.2" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_build_hash(fallback: Optional[str] = None) -> str: | ||||||
|  |     """Get build hash""" | ||||||
|  |     return environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_full_version() -> str: | ||||||
|  |     """Get full version, with build hash appended""" | ||||||
|  |     version = __version__ | ||||||
|  |     if (build_hash := get_build_hash()) != "": | ||||||
|  |         version += "." + build_hash | ||||||
|  |     return version | ||||||
|  | |||||||
| @ -12,10 +12,13 @@ from rest_framework.permissions import IsAdminUser | |||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ViewSet | from rest_framework.viewsets import ViewSet | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus | from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class TaskSerializer(PassiveSerializer): | class TaskSerializer(PassiveSerializer): | ||||||
|     """Serialize TaskInfo and TaskResult""" |     """Serialize TaskInfo and TaskResult""" | ||||||
| @ -89,6 +92,7 @@ class TaskViewSet(ViewSet): | |||||||
|         try: |         try: | ||||||
|             task_module = import_module(task.task_call_module) |             task_module = import_module(task.task_call_module) | ||||||
|             task_func = getattr(task_module, task.task_call_func) |             task_func = getattr(task_module, task.task_call_func) | ||||||
|  |             LOGGER.debug("Running task", task=task_func) | ||||||
|             task_func.delay(*task.task_call_args, **task.task_call_kwargs) |             task_func.delay(*task.task_call_args, **task.task_call_kwargs) | ||||||
|             messages.success( |             messages.success( | ||||||
|                 self.request, |                 self.request, | ||||||
| @ -96,6 +100,7 @@ class TaskViewSet(ViewSet): | |||||||
|             ) |             ) | ||||||
|             return Response(status=204) |             return Response(status=204) | ||||||
|         except (ImportError, AttributeError):  # pragma: no cover |         except (ImportError, AttributeError):  # pragma: no cover | ||||||
|  |             LOGGER.warning("Failed to run task, remove state", task=task) | ||||||
|             # if we get an import error, the module path has probably changed |             # if we get an import error, the module path has probably changed | ||||||
|             task.delete() |             task.delete() | ||||||
|             return Response(status=500) |             return Response(status=500) | ||||||
|  | |||||||
| @ -1,6 +1,4 @@ | |||||||
| """authentik administration overview""" | """authentik administration overview""" | ||||||
| from os import environ |  | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from packaging.version import parse | from packaging.version import parse | ||||||
| @ -10,7 +8,7 @@ from rest_framework.request import Request | |||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import __version__, get_build_hash | ||||||
| from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  |  | ||||||
| @ -25,7 +23,7 @@ class VersionSerializer(PassiveSerializer): | |||||||
|  |  | ||||||
|     def get_build_hash(self, _) -> str: |     def get_build_hash(self, _) -> str: | ||||||
|         """Get build hash, if version is not latest or released""" |         """Get build hash, if version is not latest or released""" | ||||||
|         return environ.get(ENV_GIT_HASH_KEY, "") |         return get_build_hash() | ||||||
|  |  | ||||||
|     def get_version_current(self, _) -> str: |     def get_version_current(self, _) -> str: | ||||||
|         """Get current version""" |         """Get current version""" | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| """authentik admin app config""" | """authentik admin app config""" | ||||||
|  | from importlib import import_module | ||||||
|  |  | ||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -13,3 +15,4 @@ class AuthentikAdminConfig(AppConfig): | |||||||
|         from authentik.admin.tasks import clear_update_notifications |         from authentik.admin.tasks import clear_update_notifications | ||||||
|  |  | ||||||
|         clear_update_notifications.delay() |         clear_update_notifications.delay() | ||||||
|  |         import_module("authentik.admin.signals") | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								authentik/admin/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/admin/signals.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | """admin signals""" | ||||||
|  | from django.dispatch import receiver | ||||||
|  |  | ||||||
|  | from authentik.admin.api.tasks import TaskInfo | ||||||
|  | from authentik.admin.api.workers import GAUGE_WORKERS | ||||||
|  | from authentik.root.celery import CELERY_APP | ||||||
|  | from authentik.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(monitoring_set) | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | def monitoring_set_workers(sender, **kwargs): | ||||||
|  |     """Set worker gauge""" | ||||||
|  |     count = len(CELERY_APP.control.ping(timeout=0.5)) | ||||||
|  |     GAUGE_WORKERS.set(count) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(monitoring_set) | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | def monitoring_set_tasks(sender, **kwargs): | ||||||
|  |     """Set task gauges""" | ||||||
|  |     for task in TaskInfo.all().values(): | ||||||
|  |         task.set_prom_metrics() | ||||||
| @ -1,6 +1,5 @@ | |||||||
| """authentik admin tasks""" | """authentik admin tasks""" | ||||||
| import re | import re | ||||||
| from os import environ |  | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.core.validators import URLValidator | from django.core.validators import URLValidator | ||||||
| @ -9,7 +8,7 @@ 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 ENV_GIT_HASH_KEY, __version__ | from authentik import __version__, get_build_hash | ||||||
| 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, | ||||||
| @ -36,7 +35,7 @@ def _set_prom_info(): | |||||||
|         { |         { | ||||||
|             "version": __version__, |             "version": __version__, | ||||||
|             "latest": cache.get(VERSION_CACHE_KEY, ""), |             "latest": cache.get(VERSION_CACHE_KEY, ""), | ||||||
|             "build_hash": environ.get(ENV_GIT_HASH_KEY, ""), |             "build_hash": get_build_hash(), | ||||||
|         } |         } | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -30,7 +30,7 @@ function getCookie(name) { | |||||||
| window.addEventListener('DOMContentLoaded', (event) => { | window.addEventListener('DOMContentLoaded', (event) => { | ||||||
|     const rapidocEl = document.querySelector('rapi-doc'); |     const rapidocEl = document.querySelector('rapi-doc'); | ||||||
|     rapidocEl.addEventListener('before-try', (e) => { |     rapidocEl.addEventListener('before-try', (e) => { | ||||||
|         e.detail.request.headers.append('X-CSRFToken', getCookie("authentik_csrf")); |         e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf")); | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  | |||||||
| @ -4,7 +4,5 @@ from django.urls import include, path | |||||||
| from authentik.api.v3.urls import urlpatterns as v3_urls | from authentik.api.v3.urls import urlpatterns as v3_urls | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     # TODO: Remove in 2022.1 |  | ||||||
|     path("v2beta/", include(v3_urls)), |  | ||||||
|     path("v3/", include(v3_urls)), |     path("v3/", include(v3_urls)), | ||||||
| ] | ] | ||||||
|  | |||||||
| @ -1,10 +1,9 @@ | |||||||
| """core Configs API""" | """core Configs API""" | ||||||
| from os import environ, path | from os import path | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db import models | from django.db import models | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME |  | ||||||
| from rest_framework.fields import ( | from rest_framework.fields import ( | ||||||
|     BooleanField, |     BooleanField, | ||||||
|     CharField, |     CharField, | ||||||
| @ -28,7 +27,6 @@ class Capabilities(models.TextChoices): | |||||||
|  |  | ||||||
|     CAN_SAVE_MEDIA = "can_save_media" |     CAN_SAVE_MEDIA = "can_save_media" | ||||||
|     CAN_GEO_IP = "can_geo_ip" |     CAN_GEO_IP = "can_geo_ip" | ||||||
|     CAN_BACKUP = "can_backup" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ErrorReportingConfigSerializer(PassiveSerializer): | class ErrorReportingConfigSerializer(PassiveSerializer): | ||||||
| @ -65,13 +63,6 @@ class ConfigView(APIView): | |||||||
|             caps.append(Capabilities.CAN_SAVE_MEDIA) |             caps.append(Capabilities.CAN_SAVE_MEDIA) | ||||||
|         if GEOIP_READER.enabled: |         if GEOIP_READER.enabled: | ||||||
|             caps.append(Capabilities.CAN_GEO_IP) |             caps.append(Capabilities.CAN_GEO_IP) | ||||||
|         if SERVICE_HOST_ENV_NAME in environ: |  | ||||||
|             # Running in k8s, only s3 backup is supported |  | ||||||
|             if CONFIG.y("postgresql.s3_backup"): |  | ||||||
|                 caps.append(Capabilities.CAN_BACKUP) |  | ||||||
|         else: |  | ||||||
|             # Running in compose, backup is always supported |  | ||||||
|             caps.append(Capabilities.CAN_BACKUP) |  | ||||||
|         return caps |         return caps | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: ConfigSerializer(many=False)}) |     @extend_schema(responses={200: ConfigSerializer(many=False)}) | ||||||
| @ -80,7 +71,7 @@ class ConfigView(APIView): | |||||||
|         config = ConfigSerializer( |         config = ConfigSerializer( | ||||||
|             { |             { | ||||||
|                 "error_reporting": { |                 "error_reporting": { | ||||||
|                     "enabled": CONFIG.y("error_reporting.enabled"), |                     "enabled": CONFIG.y("error_reporting.enabled") and not settings.DEBUG, | ||||||
|                     "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)), | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| """Application API Views""" | """Application API Views""" | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.db.models import QuerySet | from django.db.models import QuerySet | ||||||
| from django.http.response import HttpResponseBadRequest | from django.http.response import HttpResponseBadRequest | ||||||
| @ -7,7 +9,7 @@ from drf_spectacular.types import OpenApiTypes | |||||||
| from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema | ||||||
| from guardian.shortcuts import get_objects_for_user | from guardian.shortcuts import get_objects_for_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import ReadOnlyField | from rest_framework.fields import ReadOnlyField, SerializerMethodField | ||||||
| from rest_framework.parsers import MultiPartParser | from rest_framework.parsers import MultiPartParser | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @ -39,11 +41,16 @@ def user_app_cache_key(user_pk: str) -> str: | |||||||
| class ApplicationSerializer(ModelSerializer): | class ApplicationSerializer(ModelSerializer): | ||||||
|     """Application Serializer""" |     """Application Serializer""" | ||||||
|  |  | ||||||
|     launch_url = ReadOnlyField(source="get_launch_url") |     launch_url = SerializerMethodField() | ||||||
|     provider_obj = ProviderSerializer(source="get_provider", required=False) |     provider_obj = ProviderSerializer(source="get_provider", required=False) | ||||||
|  |  | ||||||
|     meta_icon = ReadOnlyField(source="get_meta_icon") |     meta_icon = ReadOnlyField(source="get_meta_icon") | ||||||
|  |  | ||||||
|  |     def get_launch_url(self, app: Application) -> Optional[str]: | ||||||
|  |         """Allow formatting of launch URL""" | ||||||
|  |         user = self.context["request"].user | ||||||
|  |         return app.get_launch_url(user) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = Application |         model = Application | ||||||
|  | |||||||
| @ -1,10 +1,9 @@ | |||||||
| """Tokens API Viewset""" | """Tokens API Viewset""" | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.http.response import Http404 |  | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import assign_perm, get_anonymous_user | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| @ -96,10 +95,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet): | |||||||
|  |  | ||||||
|     def perform_create(self, serializer: TokenSerializer): |     def perform_create(self, serializer: TokenSerializer): | ||||||
|         if not self.request.user.is_superuser: |         if not self.request.user.is_superuser: | ||||||
|             return serializer.save( |             instance = serializer.save( | ||||||
|                 user=self.request.user, |                 user=self.request.user, | ||||||
|                 expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True), |                 expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True), | ||||||
|             ) |             ) | ||||||
|  |             assign_perm("authentik_core.view_token_key", self.request.user, instance) | ||||||
|  |             return instance | ||||||
|         return super().perform_create(serializer) |         return super().perform_create(serializer) | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_token_key") |     @permission_required("authentik_core.view_token_key") | ||||||
| @ -114,7 +115,5 @@ class TokenViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def view_key(self, request: Request, identifier: str) -> Response: |     def view_key(self, request: Request, identifier: str) -> Response: | ||||||
|         """Return token key and log access""" |         """Return token key and log access""" | ||||||
|         token: Token = self.get_object() |         token: Token = self.get_object() | ||||||
|         if token.is_expired: |  | ||||||
|             raise Http404 |  | ||||||
|         Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request)  # noqa # nosec |         Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request)  # noqa # nosec | ||||||
|         return Response(TokenViewSerializer({"key": token.key}).data) |         return Response(TokenViewSerializer({"key": token.key}).data) | ||||||
|  | |||||||
| @ -24,7 +24,6 @@ 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, DictField, JSONField, SerializerMethodField | from rest_framework.fields import CharField, DictField, JSONField, SerializerMethodField | ||||||
| from rest_framework.permissions import IsAuthenticated |  | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ( | from rest_framework.serializers import ( | ||||||
| @ -46,9 +45,6 @@ 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_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
|     USER_ATTRIBUTE_CHANGE_EMAIL, |  | ||||||
|     USER_ATTRIBUTE_CHANGE_NAME, |  | ||||||
|     USER_ATTRIBUTE_CHANGE_USERNAME, |  | ||||||
|     USER_ATTRIBUTE_SA, |     USER_ATTRIBUTE_SA, | ||||||
|     USER_ATTRIBUTE_TOKEN_EXPIRING, |     USER_ATTRIBUTE_TOKEN_EXPIRING, | ||||||
|     Group, |     Group, | ||||||
| @ -57,7 +53,6 @@ from authentik.core.models import ( | |||||||
|     User, |     User, | ||||||
| ) | ) | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| 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 | ||||||
| @ -126,43 +121,6 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|                 "pk": group.pk, |                 "pk": group.pk, | ||||||
|             } |             } | ||||||
|  |  | ||||||
|     def validate_email(self, email: str): |  | ||||||
|         """Check if the user is allowed to change their email""" |  | ||||||
|         if self.instance.group_attributes().get( |  | ||||||
|             USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True) |  | ||||||
|         ): |  | ||||||
|             return email |  | ||||||
|         if email != self.instance.email: |  | ||||||
|             raise ValidationError("Not allowed to change email.") |  | ||||||
|         return email |  | ||||||
|  |  | ||||||
|     def validate_name(self, name: str): |  | ||||||
|         """Check if the user is allowed to change their name""" |  | ||||||
|         if self.instance.group_attributes().get( |  | ||||||
|             USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True) |  | ||||||
|         ): |  | ||||||
|             return name |  | ||||||
|         if name != self.instance.name: |  | ||||||
|             raise ValidationError("Not allowed to change name.") |  | ||||||
|         return name |  | ||||||
|  |  | ||||||
|     def validate_username(self, username: str): |  | ||||||
|         """Check if the user is allowed to change their username""" |  | ||||||
|         if self.instance.group_attributes().get( |  | ||||||
|             USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True) |  | ||||||
|         ): |  | ||||||
|             return username |  | ||||||
|         if username != self.instance.username: |  | ||||||
|             raise ValidationError("Not allowed to change username.") |  | ||||||
|         return username |  | ||||||
|  |  | ||||||
|     def save(self, **kwargs): |  | ||||||
|         if self.instance: |  | ||||||
|             attributes: dict = self.instance.attributes |  | ||||||
|             attributes.update(self.validated_data.get("attributes", {})) |  | ||||||
|             self.validated_data["attributes"] = attributes |  | ||||||
|         return super().save(**kwargs) |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = User |         model = User | ||||||
| @ -241,6 +199,7 @@ 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") | ||||||
|  |  | ||||||
|     groups_by_name = ModelMultipleChoiceFilter( |     groups_by_name = ModelMultipleChoiceFilter( | ||||||
|         field_name="ak_groups__name", |         field_name="ak_groups__name", | ||||||
| @ -290,7 +249,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|     queryset = User.objects.none() |     queryset = User.objects.none() | ||||||
|     ordering = ["username"] |     ordering = ["username"] | ||||||
|     serializer_class = UserSerializer |     serializer_class = UserSerializer | ||||||
|     search_fields = ["username", "name", "is_active", "email"] |     search_fields = ["username", "name", "is_active", "email", "uuid"] | ||||||
|     filterset_class = UsersFilter |     filterset_class = UsersFilter | ||||||
|  |  | ||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self):  # pragma: no cover | ||||||
| @ -407,26 +366,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|             update_session_auth_hash(self.request, user) |             update_session_auth_hash(self.request, user) | ||||||
|         return Response(status=204) |         return Response(status=204) | ||||||
|  |  | ||||||
|     @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)}) |  | ||||||
|     @action( |  | ||||||
|         methods=["PUT"], |  | ||||||
|         detail=False, |  | ||||||
|         pagination_class=None, |  | ||||||
|         filter_backends=[], |  | ||||||
|         permission_classes=[IsAuthenticated], |  | ||||||
|     ) |  | ||||||
|     def update_self(self, request: Request) -> Response: |  | ||||||
|         """Allow users to change information on their own profile""" |  | ||||||
|         data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data) |  | ||||||
|         if not data.is_valid(): |  | ||||||
|             return Response(data.errors, status=400) |  | ||||||
|         new_user = data.save() |  | ||||||
|         # If we're impersonating, we need to update that user object |  | ||||||
|         # since it caches the full object |  | ||||||
|         if SESSION_IMPERSONATE_USER in request.session: |  | ||||||
|             request.session[SESSION_IMPERSONATE_USER] = new_user |  | ||||||
|         return Response({"user": data.data}) |  | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) |     @permission_required("authentik_core.view_user", ["authentik_events.view_event"]) | ||||||
|     @extend_schema(responses={200: UserMetricsSerializer(many=False)}) |     @extend_schema(responses={200: UserMetricsSerializer(many=False)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[]) | ||||||
|  | |||||||
| @ -30,7 +30,7 @@ class InbuiltBackend(ModelBackend): | |||||||
|             return |             return | ||||||
|         # Since we can't directly pass other variables to signals, and we want to log the method |         # Since we can't directly pass other variables to signals, and we want to log the method | ||||||
|         # and the token used, we assume we're running in a flow and set a variable in the context |         # and the token used, we assume we're running in a flow and set a variable in the context | ||||||
|         flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN] |         flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan("")) | ||||||
|         flow_plan.context[PLAN_CONTEXT_METHOD] = method |         flow_plan.context[PLAN_CONTEXT_METHOD] = method | ||||||
|         flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs)) |         flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs)) | ||||||
|         request.session[SESSION_KEY_PLAN] = flow_plan |         request.session[SESSION_KEY_PLAN] = flow_plan | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ from django.db import models | |||||||
| from django.db.models import Q, QuerySet, options | from django.db.models import Q, QuerySet, options | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.templatetags.static import static | from django.templatetags.static import static | ||||||
| from django.utils.functional import cached_property | from django.utils.functional import SimpleLazyObject, cached_property | ||||||
| from django.utils.html import escape | from django.utils.html import escape | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| @ -284,13 +284,24 @@ class Application(PolicyBindingModel): | |||||||
|             return self.meta_icon.name |             return self.meta_icon.name | ||||||
|         return self.meta_icon.url |         return self.meta_icon.url | ||||||
|  |  | ||||||
|     def get_launch_url(self) -> Optional[str]: |     def get_launch_url(self, user: Optional["User"] = None) -> Optional[str]: | ||||||
|         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" |         """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" | ||||||
|  |         url = None | ||||||
|         if self.meta_launch_url: |         if self.meta_launch_url: | ||||||
|             return self.meta_launch_url |             url = self.meta_launch_url | ||||||
|         if provider := self.get_provider(): |         if provider := self.get_provider(): | ||||||
|             return provider.launch_url |             url = provider.launch_url | ||||||
|         return None |         if user and url: | ||||||
|  |             if isinstance(user, SimpleLazyObject): | ||||||
|  |                 user._setup() | ||||||
|  |                 user = user._wrapped | ||||||
|  |             try: | ||||||
|  |                 return url % user.__dict__ | ||||||
|  |             # pylint: disable=broad-except | ||||||
|  |             except Exception as exc: | ||||||
|  |                 LOGGER.warning("Failed to format launch url", exc=exc) | ||||||
|  |                 return url | ||||||
|  |         return url | ||||||
|  |  | ||||||
|     def get_provider(self) -> Optional[Provider]: |     def get_provider(self) -> Optional[Provider]: | ||||||
|         """Get casted provider instance""" |         """Get casted provider instance""" | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """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 | ||||||
| @ -11,6 +12,8 @@ from django.dispatch import receiver | |||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from prometheus_client import Gauge | from prometheus_client import Gauge | ||||||
|  |  | ||||||
|  | from authentik.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
| # Arguments: user: User, password: str | # Arguments: user: User, password: str | ||||||
| password_changed = Signal() | password_changed = Signal() | ||||||
|  |  | ||||||
| @ -20,6 +23,17 @@ 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, **_): | ||||||
| @ -27,11 +41,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_): | |||||||
|     from authentik.core.api.applications import user_app_cache_key |     from authentik.core.api.applications import user_app_cache_key | ||||||
|     from authentik.core.models import Application |     from authentik.core.models import Application | ||||||
|  |  | ||||||
|     GAUGE_MODELS.labels( |  | ||||||
|         model_name=sender._meta.model_name, |  | ||||||
|         app=sender._meta.app_label, |  | ||||||
|     ).set(sender.objects.count()) |  | ||||||
|  |  | ||||||
|     if sender != Application: |     if sender != Application: | ||||||
|         return |         return | ||||||
|     if not created:  # pragma: no cover |     if not created:  # pragma: no cover | ||||||
|  | |||||||
| @ -1,17 +1,7 @@ | |||||||
| """authentik core tasks""" | """authentik core tasks""" | ||||||
| from datetime import datetime |  | ||||||
| from io import StringIO |  | ||||||
| from os import environ |  | ||||||
|  |  | ||||||
| from boto3.exceptions import Boto3Error |  | ||||||
| from botocore.exceptions import BotoCoreError, ClientError |  | ||||||
| from dbbackup.db.exceptions import CommandConnectorError |  | ||||||
| from django.contrib.humanize.templatetags.humanize import naturaltime |  | ||||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
| from django.core import management |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import AuthenticatedSession, ExpiringModel | from authentik.core.models import AuthenticatedSession, ExpiringModel | ||||||
| @ -21,7 +11,6 @@ from authentik.events.monitored_tasks import ( | |||||||
|     TaskResultStatus, |     TaskResultStatus, | ||||||
|     prefill_task, |     prefill_task, | ||||||
| ) | ) | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -53,46 +42,3 @@ def clean_expired_models(self: MonitoredTask): | |||||||
|     LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount) |     LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount) | ||||||
|     messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}") |     messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}") | ||||||
|     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) |     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) | ||||||
|  |  | ||||||
|  |  | ||||||
| def should_backup() -> bool: |  | ||||||
|     """Check if we should be doing backups""" |  | ||||||
|     if SERVICE_HOST_ENV_NAME in environ and not CONFIG.y("postgresql.s3_backup.bucket"): |  | ||||||
|         LOGGER.info("Running in k8s and s3 backups are not configured, skipping") |  | ||||||
|         return False |  | ||||||
|     if not CONFIG.y_bool("postgresql.backup.enabled"): |  | ||||||
|         return False |  | ||||||
|     return True |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) |  | ||||||
| @prefill_task |  | ||||||
| def backup_database(self: MonitoredTask):  # pragma: no cover |  | ||||||
|     """Database backup""" |  | ||||||
|     self.result_timeout_hours = 25 |  | ||||||
|     if not should_backup(): |  | ||||||
|         self.set_status(TaskResult(TaskResultStatus.UNKNOWN, ["Backups are not configured."])) |  | ||||||
|         return |  | ||||||
|     try: |  | ||||||
|         start = datetime.now() |  | ||||||
|         out = StringIO() |  | ||||||
|         management.call_command("dbbackup", quiet=True, stdout=out) |  | ||||||
|         self.set_status( |  | ||||||
|             TaskResult( |  | ||||||
|                 TaskResultStatus.SUCCESSFUL, |  | ||||||
|                 [ |  | ||||||
|                     f"Successfully finished database backup {naturaltime(start)} {out.getvalue()}", |  | ||||||
|                 ], |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         LOGGER.info("Successfully backed up database.") |  | ||||||
|     except ( |  | ||||||
|         IOError, |  | ||||||
|         BotoCoreError, |  | ||||||
|         ClientError, |  | ||||||
|         Boto3Error, |  | ||||||
|         PermissionError, |  | ||||||
|         CommandConnectorError, |  | ||||||
|         ValueError, |  | ||||||
|     ) as exc: |  | ||||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) |  | ||||||
|  | |||||||
| @ -16,6 +16,7 @@ | |||||||
|         {% block head_before %} |         {% block head_before %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> |         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||||
|  |         <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}"> | ||||||
|         <script src="{% static 'dist/poly.js' %}" type="module"></script> |         <script src="{% static 'dist/poly.js' %}" type="module"></script> | ||||||
|         {% block head %} |         {% block head %} | ||||||
|         {% endblock %} |         {% endblock %} | ||||||
|  | |||||||
| @ -10,8 +10,8 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container></ak-message-container> | <ak-message-container data-refresh-on-locale="true"></ak-message-container> | ||||||
| <ak-interface-admin> | <ak-interface-admin data-refresh-on-locale="true"> | ||||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|  | |||||||
| @ -20,8 +20,8 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container></ak-message-container> | <ak-message-container data-refresh-on-locale="true"></ak-message-container> | ||||||
| <ak-flow-executor> | <ak-flow-executor data-refresh-on-locale="true"> | ||||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|  | |||||||
| @ -10,8 +10,8 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container></ak-message-container> | <ak-message-container data-refresh-on-locale="true"></ak-message-container> | ||||||
| <ak-interface-user> | <ak-interface-user data-refresh-on-locale="true"> | ||||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|  | |||||||
| @ -13,7 +13,9 @@ class TestApplicationsAPI(APITestCase): | |||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         self.user = create_test_admin_user() |         self.user = create_test_admin_user() | ||||||
|         self.allowed = Application.objects.create(name="allowed", slug="allowed") |         self.allowed = Application.objects.create( | ||||||
|  |             name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s" | ||||||
|  |         ) | ||||||
|         self.denied = Application.objects.create(name="denied", slug="denied") |         self.denied = Application.objects.create(name="denied", slug="denied") | ||||||
|         PolicyBinding.objects.create( |         PolicyBinding.objects.create( | ||||||
|             target=self.denied, |             target=self.denied, | ||||||
| @ -64,8 +66,8 @@ class TestApplicationsAPI(APITestCase): | |||||||
|                         "slug": "allowed", |                         "slug": "allowed", | ||||||
|                         "provider": None, |                         "provider": None, | ||||||
|                         "provider_obj": None, |                         "provider_obj": None, | ||||||
|                         "launch_url": None, |                         "launch_url": f"https://goauthentik.io/{self.user.username}", | ||||||
|                         "meta_launch_url": "", |                         "meta_launch_url": "https://goauthentik.io/%(username)s", | ||||||
|                         "meta_icon": None, |                         "meta_icon": None, | ||||||
|                         "meta_description": "", |                         "meta_description": "", | ||||||
|                         "meta_publisher": "", |                         "meta_publisher": "", | ||||||
| @ -100,8 +102,8 @@ class TestApplicationsAPI(APITestCase): | |||||||
|                         "slug": "allowed", |                         "slug": "allowed", | ||||||
|                         "provider": None, |                         "provider": None, | ||||||
|                         "provider_obj": None, |                         "provider_obj": None, | ||||||
|                         "launch_url": None, |                         "launch_url": f"https://goauthentik.io/{self.user.username}", | ||||||
|                         "meta_launch_url": "", |                         "meta_launch_url": "https://goauthentik.io/%(username)s", | ||||||
|                         "meta_icon": None, |                         "meta_icon": None, | ||||||
|                         "meta_description": "", |                         "meta_description": "", | ||||||
|                         "meta_publisher": "", |                         "meta_publisher": "", | ||||||
|  | |||||||
							
								
								
									
										67
									
								
								authentik/core/tests/test_applications_views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								authentik/core/tests/test_applications_views.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,67 @@ | |||||||
|  | """Test Applications API""" | ||||||
|  | from unittest.mock import MagicMock, patch | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  |  | ||||||
|  | from authentik.core.models import Application | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user, create_test_tenant | ||||||
|  | from authentik.flows.models import Flow, FlowDesignation | ||||||
|  | from authentik.flows.tests import FlowTestCase | ||||||
|  | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestApplicationsViews(FlowTestCase): | ||||||
|  |     """Test applications Views""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.user = create_test_admin_user() | ||||||
|  |         self.allowed = Application.objects.create( | ||||||
|  |             name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_check_redirect(self): | ||||||
|  |         """Test redirect""" | ||||||
|  |         empty_flow = Flow.objects.create( | ||||||
|  |             name="foo", | ||||||
|  |             slug="foo", | ||||||
|  |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|  |         ) | ||||||
|  |         tenant: Tenant = create_test_tenant() | ||||||
|  |         tenant.flow_authentication = empty_flow | ||||||
|  |         tenant.save() | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_core:application-launch", | ||||||
|  |                 kwargs={"application_slug": self.allowed.slug}, | ||||||
|  |             ), | ||||||
|  |             follow=True, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         with patch( | ||||||
|  |             "authentik.flows.stage.StageView.get_pending_user", MagicMock(return_value=self.user) | ||||||
|  |         ): | ||||||
|  |             response = self.client.post( | ||||||
|  |                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": empty_flow.slug}) | ||||||
|  |             ) | ||||||
|  |             self.assertEqual(response.status_code, 200) | ||||||
|  |             self.assertStageRedirects(response, f"https://goauthentik.io/{self.user.username}") | ||||||
|  |  | ||||||
|  |     def test_check_redirect_auth(self): | ||||||
|  |         """Test redirect""" | ||||||
|  |         self.client.force_login(self.user) | ||||||
|  |         empty_flow = Flow.objects.create( | ||||||
|  |             name="foo", | ||||||
|  |             slug="foo", | ||||||
|  |             designation=FlowDesignation.AUTHENTICATION, | ||||||
|  |         ) | ||||||
|  |         tenant: Tenant = create_test_tenant() | ||||||
|  |         tenant.flow_authentication = empty_flow | ||||||
|  |         tenant.save() | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_core:application-launch", | ||||||
|  |                 kwargs={"application_slug": self.allowed.slug}, | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.url, f"https://goauthentik.io/{self.user.username}") | ||||||
| @ -30,6 +30,7 @@ class TestTokenAPI(APITestCase): | |||||||
|         self.assertEqual(token.user, self.user) |         self.assertEqual(token.user, self.user) | ||||||
|         self.assertEqual(token.intent, TokenIntents.INTENT_API) |         self.assertEqual(token.intent, TokenIntents.INTENT_API) | ||||||
|         self.assertEqual(token.expiring, True) |         self.assertEqual(token.expiring, True) | ||||||
|  |         self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token)) | ||||||
|  |  | ||||||
|     def test_token_create_invalid(self): |     def test_token_create_invalid(self): | ||||||
|         """Test token creation endpoint (invalid data)""" |         """Test token creation endpoint (invalid data)""" | ||||||
|  | |||||||
| @ -2,12 +2,7 @@ | |||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import ( | from authentik.core.models import User | ||||||
|     USER_ATTRIBUTE_CHANGE_EMAIL, |  | ||||||
|     USER_ATTRIBUTE_CHANGE_NAME, |  | ||||||
|     USER_ATTRIBUTE_CHANGE_USERNAME, |  | ||||||
|     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_key | ||||||
| @ -22,51 +17,6 @@ class TestUsersAPI(APITestCase): | |||||||
|         self.admin = create_test_admin_user() |         self.admin = create_test_admin_user() | ||||||
|         self.user = User.objects.create(username="test-user") |         self.user = User.objects.create(username="test-user") | ||||||
|  |  | ||||||
|     def test_update_self(self): |  | ||||||
|         """Test update_self""" |  | ||||||
|         self.admin.attributes["foo"] = "bar" |  | ||||||
|         self.admin.save() |  | ||||||
|         self.admin.refresh_from_db() |  | ||||||
|         self.client.force_login(self.admin) |  | ||||||
|         response = self.client.put( |  | ||||||
|             reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} |  | ||||||
|         ) |  | ||||||
|         self.admin.refresh_from_db() |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|         self.assertEqual(self.admin.attributes["foo"], "bar") |  | ||||||
|         self.assertEqual(self.admin.username, "foo") |  | ||||||
|         self.assertEqual(self.admin.name, "foo") |  | ||||||
|  |  | ||||||
|     def test_update_self_name_denied(self): |  | ||||||
|         """Test update_self""" |  | ||||||
|         self.admin.attributes[USER_ATTRIBUTE_CHANGE_NAME] = False |  | ||||||
|         self.admin.save() |  | ||||||
|         self.client.force_login(self.admin) |  | ||||||
|         response = self.client.put( |  | ||||||
|             reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|  |  | ||||||
|     def test_update_self_username_denied(self): |  | ||||||
|         """Test update_self""" |  | ||||||
|         self.admin.attributes[USER_ATTRIBUTE_CHANGE_USERNAME] = False |  | ||||||
|         self.admin.save() |  | ||||||
|         self.client.force_login(self.admin) |  | ||||||
|         response = self.client.put( |  | ||||||
|             reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|  |  | ||||||
|     def test_update_self_email_denied(self): |  | ||||||
|         """Test update_self""" |  | ||||||
|         self.admin.attributes[USER_ATTRIBUTE_CHANGE_EMAIL] = False |  | ||||||
|         self.admin.save() |  | ||||||
|         self.client.force_login(self.admin) |  | ||||||
|         response = self.client.put( |  | ||||||
|             reverse("authentik_api:user-update-self"), data={"email": "foo", "name": "foo"} |  | ||||||
|         ) |  | ||||||
|         self.assertEqual(response.status_code, 400) |  | ||||||
|  |  | ||||||
|     def test_metrics(self): |     def test_metrics(self): | ||||||
|         """Test user's metrics""" |         """Test user's metrics""" | ||||||
|         self.client.force_login(self.admin) |         self.client.force_login(self.admin) | ||||||
|  | |||||||
| @ -29,4 +29,4 @@ class UserSettingSerializer(PassiveSerializer): | |||||||
|     component = CharField() |     component = CharField() | ||||||
|     title = CharField() |     title = CharField() | ||||||
|     configure_url = CharField(required=False) |     configure_url = CharField(required=False) | ||||||
|     icon_url = CharField() |     icon_url = CharField(required=False) | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie | |||||||
| from django.views.generic import RedirectView | from django.views.generic import RedirectView | ||||||
| from django.views.generic.base import TemplateView | from django.views.generic.base import TemplateView | ||||||
|  |  | ||||||
| from authentik.core.views import impersonate | from authentik.core.views import apps, impersonate | ||||||
| from authentik.core.views.interface import FlowInterfaceView | from authentik.core.views.interface import FlowInterfaceView | ||||||
| from authentik.core.views.session import EndSessionView | from authentik.core.views.session import EndSessionView | ||||||
|  |  | ||||||
| @ -15,6 +15,12 @@ urlpatterns = [ | |||||||
|         login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")), |         login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")), | ||||||
|         name="root-redirect", |         name="root-redirect", | ||||||
|     ), |     ), | ||||||
|  |     path( | ||||||
|  |         # We have to use this format since everything else uses applications/o or applications/saml | ||||||
|  |         "application/launch/<slug:application_slug>/", | ||||||
|  |         apps.RedirectToAppLaunch.as_view(), | ||||||
|  |         name="application-launch", | ||||||
|  |     ), | ||||||
|     # Impersonation |     # Impersonation | ||||||
|     path( |     path( | ||||||
|         "-/impersonation/<int:user_id>/", |         "-/impersonation/<int:user_id>/", | ||||||
|  | |||||||
							
								
								
									
										75
									
								
								authentik/core/views/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								authentik/core/views/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,75 @@ | |||||||
|  | """app views""" | ||||||
|  | from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | ||||||
|  | from django.shortcuts import get_object_or_404 | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from django.views import View | ||||||
|  |  | ||||||
|  | from authentik.core.models import Application | ||||||
|  | from authentik.flows.challenge import ( | ||||||
|  |     ChallengeResponse, | ||||||
|  |     ChallengeTypes, | ||||||
|  |     HttpChallengeResponse, | ||||||
|  |     RedirectChallenge, | ||||||
|  | ) | ||||||
|  | from authentik.flows.models import in_memory_stage | ||||||
|  | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||||
|  | from authentik.flows.stage import ChallengeStageView | ||||||
|  | from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||||
|  | from authentik.lib.utils.urls import redirect_with_qs | ||||||
|  | from authentik.stages.consent.stage import ( | ||||||
|  |     PLAN_CONTEXT_CONSENT_HEADER, | ||||||
|  |     PLAN_CONTEXT_CONSENT_PERMISSIONS, | ||||||
|  | ) | ||||||
|  | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RedirectToAppLaunch(View): | ||||||
|  |     """Application launch view, redirect to the launch URL""" | ||||||
|  |  | ||||||
|  |     def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||||
|  |         app = get_object_or_404(Application, slug=application_slug) | ||||||
|  |         # Check here if the application has any launch URL set, if not 404 | ||||||
|  |         launch = app.get_launch_url() | ||||||
|  |         if not launch: | ||||||
|  |             raise Http404 | ||||||
|  |         # Check if we're authenticated already, saves us the flow run | ||||||
|  |         if request.user.is_authenticated: | ||||||
|  |             return HttpResponseRedirect(app.get_launch_url(request.user)) | ||||||
|  |         # otherwise, do a custom flow plan that includes the application that's | ||||||
|  |         # being accessed, to improve usability | ||||||
|  |         tenant: Tenant = request.tenant | ||||||
|  |         flow = tenant.flow_authentication | ||||||
|  |         planner = FlowPlanner(flow) | ||||||
|  |         planner.allow_empty_flows = True | ||||||
|  |         plan = planner.plan( | ||||||
|  |             request, | ||||||
|  |             { | ||||||
|  |                 PLAN_CONTEXT_APPLICATION: app, | ||||||
|  |                 PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.") | ||||||
|  |                 % {"application": app.name}, | ||||||
|  |                 PLAN_CONTEXT_CONSENT_PERMISSIONS: [], | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         plan.insert_stage(in_memory_stage(RedirectToAppStage)) | ||||||
|  |         request.session[SESSION_KEY_PLAN] = plan | ||||||
|  |         return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RedirectToAppStage(ChallengeStageView): | ||||||
|  |     """Final stage to be inserted after the user logs in""" | ||||||
|  |  | ||||||
|  |     def get_challenge(self, *args, **kwargs) -> RedirectChallenge: | ||||||
|  |         app = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] | ||||||
|  |         launch = app.get_launch_url(self.get_pending_user()) | ||||||
|  |         # sanity check to ensure launch is still set | ||||||
|  |         if not launch: | ||||||
|  |             raise Http404 | ||||||
|  |         return RedirectChallenge( | ||||||
|  |             instance={ | ||||||
|  |                 "type": ChallengeTypes.REDIRECT.value, | ||||||
|  |                 "to": launch, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||||
|  |         return HttpChallengeResponse(self.get_challenge()) | ||||||
| @ -17,6 +17,7 @@ from rest_framework.request import Request | |||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ModelSerializer, ValidationError | from rest_framework.serializers import ModelSerializer, ValidationError | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| @ -26,6 +27,8 @@ from authentik.crypto.managed import MANAGED_KEY | |||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| class CertificateKeyPairSerializer(ModelSerializer): | class CertificateKeyPairSerializer(ModelSerializer): | ||||||
|     """CertificateKeyPair Serializer""" |     """CertificateKeyPair Serializer""" | ||||||
| @ -76,8 +79,11 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|     def validate_certificate_data(self, value: str) -> str: |     def validate_certificate_data(self, value: str) -> str: | ||||||
|         """Verify that input is a valid PEM x509 Certificate""" |         """Verify that input is a valid PEM x509 Certificate""" | ||||||
|         try: |         try: | ||||||
|             load_pem_x509_certificate(value.encode("utf-8"), default_backend()) |             # Cast to string to fully load and parse certificate | ||||||
|         except ValueError: |             # Prevents issues like https://github.com/goauthentik/authentik/issues/2082 | ||||||
|  |             str(load_pem_x509_certificate(value.encode("utf-8"), default_backend())) | ||||||
|  |         except ValueError as exc: | ||||||
|  |             LOGGER.warning("Failed to load certificate", exc=exc) | ||||||
|             raise ValidationError("Unable to load certificate.") |             raise ValidationError("Unable to load certificate.") | ||||||
|         return value |         return value | ||||||
|  |  | ||||||
| @ -86,12 +92,17 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|         # Since this field is optional, data can be empty. |         # Since this field is optional, data can be empty. | ||||||
|         if value != "": |         if value != "": | ||||||
|             try: |             try: | ||||||
|                 load_pem_private_key( |                 # Cast to string to fully load and parse certificate | ||||||
|                     str.encode("\n".join([x.strip() for x in value.split("\n")])), |                 # Prevents issues like https://github.com/goauthentik/authentik/issues/2082 | ||||||
|                     password=None, |                 str( | ||||||
|                     backend=default_backend(), |                     load_pem_private_key( | ||||||
|  |                         str.encode("\n".join([x.strip() for x in value.split("\n")])), | ||||||
|  |                         password=None, | ||||||
|  |                         backend=default_backend(), | ||||||
|  |                     ) | ||||||
|                 ) |                 ) | ||||||
|             except (ValueError, TypeError): |             except (ValueError, TypeError) as exc: | ||||||
|  |                 LOGGER.warning("Failed to load private key", exc=exc) | ||||||
|                 raise ValidationError("Unable to load private key (possibly encrypted?).") |                 raise ValidationError("Unable to load private key (possibly encrypted?).") | ||||||
|         return value |         return value | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,5 @@ | |||||||
| """events GeoIP Reader""" | """events GeoIP Reader""" | ||||||
| from datetime import datetime |  | ||||||
| from os import stat | from os import stat | ||||||
| from time import time |  | ||||||
| from typing import Optional, TypedDict | from typing import Optional, TypedDict | ||||||
|  |  | ||||||
| from geoip2.database import Reader | from geoip2.database import Reader | ||||||
| @ -46,14 +44,18 @@ class GeoIPReader: | |||||||
|             LOGGER.warning("Failed to load GeoIP database", exc=exc) |             LOGGER.warning("Failed to load GeoIP database", exc=exc) | ||||||
|  |  | ||||||
|     def __check_expired(self): |     def __check_expired(self): | ||||||
|         """Check if the geoip database has been opened longer than 8 hours, |         """Check if the modification date of the GeoIP database has | ||||||
|         and re-open it, as it will probably will have been re-downloaded""" |         changed, and reload it if so""" | ||||||
|         now = time() |         path = CONFIG.y("geoip") | ||||||
|         diff = datetime.fromtimestamp(now) - datetime.fromtimestamp(self.__last_mtime) |         try: | ||||||
|         diff_hours = diff.total_seconds() // 3600 |             mtime = stat(path).st_mtime | ||||||
|         if diff_hours >= 8: |             diff = self.__last_mtime < mtime | ||||||
|             LOGGER.info("GeoIP databased loaded too long, re-opening", diff=diff) |             if diff > 0: | ||||||
|             self.__open() |                 LOGGER.info("Found new GeoIP Database, reopening", diff=diff) | ||||||
|  |                 self.__open() | ||||||
|  |         except OSError as exc: | ||||||
|  |             LOGGER.warning("Failed to check GeoIP age", exc=exc) | ||||||
|  |             return | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def enabled(self) -> bool: |     def enabled(self) -> bool: | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from typing import Any, Optional | |||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from prometheus_client import Histogram | from 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 | ||||||
| @ -13,10 +13,9 @@ from authentik.core.models import User | |||||||
| from authentik.events.models import cleanse_dict | from authentik.events.models import cleanse_dict | ||||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowStageBinding, Stage | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, 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 | ||||||
| from authentik.root.monitoring import UpdatingGauge |  | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||||
| @ -27,10 +26,9 @@ 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 = UpdatingGauge( | GAUGE_FLOWS_CACHED = Gauge( | ||||||
|     "authentik_flows_cached", |     "authentik_flows_cached", | ||||||
|     "Cached flows", |     "Cached flows", | ||||||
|     update_func=lambda: len(cache.keys("flow_*") or []), |  | ||||||
| ) | ) | ||||||
| HIST_FLOWS_PLAN_TIME = Histogram( | HIST_FLOWS_PLAN_TIME = Histogram( | ||||||
|     "authentik_flows_plan_time", |     "authentik_flows_plan_time", | ||||||
| @ -158,20 +156,20 @@ class FlowPlanner: | |||||||
|             # User is passing so far, check if we have a cached plan |             # User is passing so far, check if we have a cached plan | ||||||
|             cached_plan_key = cache_key(self.flow, user) |             cached_plan_key = cache_key(self.flow, user) | ||||||
|             cached_plan = cache.get(cached_plan_key, None) |             cached_plan = cache.get(cached_plan_key, None) | ||||||
|             if cached_plan and self.use_cache: |             if self.flow.designation not in [FlowDesignation.STAGE_CONFIGURATION]: | ||||||
|                 self._logger.debug( |                 if cached_plan and self.use_cache: | ||||||
|                     "f(plan): taking plan from cache", |                     self._logger.debug( | ||||||
|                     key=cached_plan_key, |                         "f(plan): taking plan from cache", | ||||||
|                 ) |                         key=cached_plan_key, | ||||||
|                 # Reset the context as this isn't factored into caching |                     ) | ||||||
|                 cached_plan.context = default_context or {} |                     # Reset the context as this isn't factored into caching | ||||||
|                 return cached_plan |                     cached_plan.context = default_context or {} | ||||||
|  |                     return cached_plan | ||||||
|             self._logger.debug( |             self._logger.debug( | ||||||
|                 "f(plan): building plan", |                 "f(plan): building plan", | ||||||
|             ) |             ) | ||||||
|             plan = self._build_plan(user, request, default_context) |             plan = self._build_plan(user, request, default_context) | ||||||
|             cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT) |             cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT) | ||||||
|             GAUGE_FLOWS_CACHED.update() |  | ||||||
|             if not plan.bindings and not self.allow_empty_flows: |             if not plan.bindings and not self.allow_empty_flows: | ||||||
|                 raise EmptyFlowException() |                 raise EmptyFlowException() | ||||||
|             return plan |             return plan | ||||||
|  | |||||||
| @ -4,6 +4,9 @@ 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.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -14,6 +17,13 @@ def delete_cache_prefix(prefix: str) -> int: | |||||||
|     return len(keys) |     return len(keys) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(monitoring_set) | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | def monitoring_set_flows(sender, **kwargs): | ||||||
|  |     """set flow gauges""" | ||||||
|  |     GAUGE_FLOWS_CACHED.set(len(cache.keys("flow_*") or [])) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| @receiver(pre_delete) | @receiver(pre_delete) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
|  | |||||||
| @ -118,9 +118,12 @@ class ChallengeStageView(StageView): | |||||||
|         """Allow usage of placeholder in flow title.""" |         """Allow usage of placeholder in flow title.""" | ||||||
|         if not self.executor.plan: |         if not self.executor.plan: | ||||||
|             return self.executor.flow.title |             return self.executor.flow.title | ||||||
|         return self.executor.flow.title % { |         try: | ||||||
|             "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "") |             return self.executor.flow.title % { | ||||||
|         } |                 "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "") | ||||||
|  |             } | ||||||
|  |         except ValueError: | ||||||
|  |             return self.executor.flow.title | ||||||
|  |  | ||||||
|     def _get_challenge(self, *args, **kwargs) -> Challenge: |     def _get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         with Hub.current.start_span( |         with Hub.current.start_span( | ||||||
|  | |||||||
| @ -5,16 +5,6 @@ postgresql: | |||||||
|   user: authentik |   user: authentik | ||||||
|   port: 5432 |   port: 5432 | ||||||
|   password: 'env://POSTGRES_PASSWORD' |   password: 'env://POSTGRES_PASSWORD' | ||||||
|   backup: |  | ||||||
|     enabled: true |  | ||||||
|   s3_backup: |  | ||||||
|     access_key: "" |  | ||||||
|     secret_key: "" |  | ||||||
|     bucket: "" |  | ||||||
|     region: eu-central-1 |  | ||||||
|     host: "" |  | ||||||
|     location: "" |  | ||||||
|     insecure_skip_verify: false |  | ||||||
|  |  | ||||||
| web: | web: | ||||||
|   listen: 0.0.0.0:9000 |   listen: 0.0.0.0:9000 | ||||||
| @ -46,7 +36,7 @@ error_reporting: | |||||||
|   enabled: false |   enabled: false | ||||||
|   environment: customer |   environment: customer | ||||||
|   send_pii: false |   send_pii: false | ||||||
|   sample_rate: 0.5 |   sample_rate: 0.3 | ||||||
|  |  | ||||||
| # Global email settings | # Global email settings | ||||||
| email: | email: | ||||||
| @ -65,18 +55,15 @@ outposts: | |||||||
|   # %(version)s: Current version; 2021.4.1 |   # %(version)s: Current version; 2021.4.1 | ||||||
|   # %(build_hash)s: Build hash if you're running a beta version |   # %(build_hash)s: Build hash if you're running a beta version | ||||||
|   container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s |   container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s | ||||||
|  |   discover: true | ||||||
|  |  | ||||||
| cookie_domain: null | cookie_domain: null | ||||||
| disable_update_check: false | disable_update_check: false | ||||||
| disable_startup_analytics: false | disable_startup_analytics: false | ||||||
| avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar | avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar | ||||||
| geoip: "./GeoLite2-City.mmdb" | geoip: "/geoip/GeoLite2-City.mmdb" | ||||||
|  |  | ||||||
| footer_links: | footer_links: [] | ||||||
|   - name: Documentation |  | ||||||
|     href: https://goauthentik.io/docs/?utm_source=authentik |  | ||||||
|   - name: authentik Website |  | ||||||
|     href: https://goauthentik.io/?utm_source=authentik |  | ||||||
|  |  | ||||||
| default_user_change_name: true | default_user_change_name: true | ||||||
| default_user_change_email: true | default_user_change_email: true | ||||||
|  | |||||||
| @ -32,6 +32,7 @@ class BaseEvaluator: | |||||||
|         self._globals = { |         self._globals = { | ||||||
|             "regex_match": BaseEvaluator.expr_regex_match, |             "regex_match": BaseEvaluator.expr_regex_match, | ||||||
|             "regex_replace": BaseEvaluator.expr_regex_replace, |             "regex_replace": BaseEvaluator.expr_regex_replace, | ||||||
|  |             "list_flatten": BaseEvaluator.expr_flatten, | ||||||
|             "ak_is_group_member": BaseEvaluator.expr_is_group_member, |             "ak_is_group_member": BaseEvaluator.expr_is_group_member, | ||||||
|             "ak_user_by": BaseEvaluator.expr_user_by, |             "ak_user_by": BaseEvaluator.expr_user_by, | ||||||
|             "ak_logger": get_logger(), |             "ak_logger": get_logger(), | ||||||
| @ -40,6 +41,15 @@ class BaseEvaluator: | |||||||
|         self._context = {} |         self._context = {} | ||||||
|         self._filename = "BaseEvalautor" |         self._filename = "BaseEvalautor" | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def expr_flatten(value: list[Any] | Any) -> Optional[Any]: | ||||||
|  |         """Flatten `value` if its a list""" | ||||||
|  |         if isinstance(value, list): | ||||||
|  |             if len(value) < 1: | ||||||
|  |                 return None | ||||||
|  |             return value[0] | ||||||
|  |         return value | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def expr_regex_match(value: Any, regex: str) -> bool: |     def expr_regex_match(value: Any, regex: str) -> bool: | ||||||
|         """Expression Filter to run re.search""" |         """Expression Filter to run re.search""" | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								authentik/lib/merge.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								authentik/lib/merge.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | |||||||
|  | """merge utils""" | ||||||
|  | from deepmerge import Merger | ||||||
|  |  | ||||||
|  | MERGE_LIST_UNIQUE = Merger( | ||||||
|  |     [(list, ["append_unique"]), (dict, ["merge"]), (set, ["union"])], ["override"], ["override"] | ||||||
|  | ) | ||||||
| @ -3,8 +3,6 @@ from typing import 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 | ||||||
| from botocore.client import ClientError |  | ||||||
| from botocore.exceptions import BotoCoreError |  | ||||||
| from celery.exceptions import CeleryError | from celery.exceptions import CeleryError | ||||||
| from channels.middleware import BaseMiddleware | from channels.middleware import BaseMiddleware | ||||||
| from channels_redis.core import ChannelFull | from channels_redis.core import ChannelFull | ||||||
| @ -81,9 +79,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|         WorkerLostError, |         WorkerLostError, | ||||||
|         CeleryError, |         CeleryError, | ||||||
|         SoftTimeLimitExceeded, |         SoftTimeLimitExceeded, | ||||||
|         # S3 errors |  | ||||||
|         BotoCoreError, |  | ||||||
|         ClientError, |  | ||||||
|         # custom baseclass |         # custom baseclass | ||||||
|         SentryIgnoredException, |         SentryIgnoredException, | ||||||
|         # ldap errors |         # ldap errors | ||||||
| @ -101,8 +96,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|             return None |             return None | ||||||
|     if "logger" in event: |     if "logger" in event: | ||||||
|         if event["logger"] in [ |         if event["logger"] in [ | ||||||
|             "dbbackup", |  | ||||||
|             "botocore", |  | ||||||
|             "kombu", |             "kombu", | ||||||
|             "asyncio", |             "asyncio", | ||||||
|             "multiprocessing", |             "multiprocessing", | ||||||
| @ -111,6 +104,7 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|             "django_redis.cache", |             "django_redis.cache", | ||||||
|             "celery.backends.redis", |             "celery.backends.redis", | ||||||
|             "celery.worker", |             "celery.worker", | ||||||
|  |             "paramiko.transport", | ||||||
|         ]: |         ]: | ||||||
|             return None |             return None | ||||||
|     LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None)) |     LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None)) | ||||||
|  | |||||||
| @ -1,5 +1,4 @@ | |||||||
| """http helpers""" | """http helpers""" | ||||||
| from os import environ |  | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| @ -7,7 +6,7 @@ from requests.sessions import Session | |||||||
| from sentry_sdk.hub import Hub | from sentry_sdk.hub import Hub | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import get_full_version | ||||||
|  |  | ||||||
| OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" | ||||||
| OUTPOST_TOKEN_HEADER = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN"  # nosec | OUTPOST_TOKEN_HEADER = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN"  # nosec | ||||||
| @ -75,8 +74,7 @@ def get_client_ip(request: Optional[HttpRequest]) -> str: | |||||||
|  |  | ||||||
| def authentik_user_agent() -> str: | def authentik_user_agent() -> str: | ||||||
|     """Get a common user agent""" |     """Get a common user agent""" | ||||||
|     build = environ.get(ENV_GIT_HASH_KEY, "tagged") |     return f"authentik@{get_full_version()}" | ||||||
|     return f"authentik@{__version__} (build={build})" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_http_session() -> Session: | def get_http_session() -> Session: | ||||||
|  | |||||||
| @ -58,4 +58,6 @@ def get_env() -> str: | |||||||
|         return "compose" |         return "compose" | ||||||
|     if CONFIG.y_bool("debug"): |     if CONFIG.y_bool("debug"): | ||||||
|         return "dev" |         return "dev" | ||||||
|  |     if "AK_APPLIANCE" in os.environ: | ||||||
|  |         return os.environ["AK_APPLIANCE"] | ||||||
|     return "custom" |     return "custom" | ||||||
|  | |||||||
| @ -1,6 +1,4 @@ | |||||||
| """Outpost API Views""" | """Outpost API Views""" | ||||||
| from os import environ |  | ||||||
|  |  | ||||||
| from dacite.core import from_dict | from dacite.core import from_dict | ||||||
| from dacite.exceptions import DaciteError | from dacite.exceptions import DaciteError | ||||||
| from django_filters.filters import ModelMultipleChoiceFilter | from django_filters.filters import ModelMultipleChoiceFilter | ||||||
| @ -14,7 +12,7 @@ from rest_framework.response import Response | |||||||
| from rest_framework.serializers import JSONField, ModelSerializer, ValidationError | from rest_framework.serializers import JSONField, ModelSerializer, ValidationError | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY | from authentik import get_build_hash | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer, is_dict | from authentik.core.api.utils import PassiveSerializer, is_dict | ||||||
| @ -154,7 +152,7 @@ class OutpostViewSet(UsedByMixin, ModelViewSet): | |||||||
|                     "version_should": state.version_should, |                     "version_should": state.version_should, | ||||||
|                     "version_outdated": state.version_outdated, |                     "version_outdated": state.version_outdated, | ||||||
|                     "build_hash": state.build_hash, |                     "build_hash": state.build_hash, | ||||||
|                     "build_hash_should": environ.get(ENV_GIT_HASH_KEY, ""), |                     "build_hash_should": get_build_hash(), | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         return Response(OutpostHealthSerializer(states, many=True).data) |         return Response(OutpostHealthSerializer(states, many=True).data) | ||||||
|  | |||||||
| @ -55,6 +55,10 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|  |  | ||||||
|     first_msg = False |     first_msg = False | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self.logger = get_logger() | ||||||
|  |  | ||||||
|     def connect(self): |     def connect(self): | ||||||
|         super().connect() |         super().connect() | ||||||
|         uuid = self.scope["url_route"]["kwargs"]["pk"] |         uuid = self.scope["url_route"]["kwargs"]["pk"] | ||||||
| @ -65,7 +69,7 @@ class OutpostConsumer(AuthJsonConsumer): | |||||||
|         ) |         ) | ||||||
|         if not outpost: |         if not outpost: | ||||||
|             raise DenyConnection() |             raise DenyConnection() | ||||||
|         self.logger = get_logger().bind(outpost=outpost) |         self.logger = self.logger.bind(outpost=outpost) | ||||||
|         try: |         try: | ||||||
|             self.accept() |             self.accept() | ||||||
|         except RuntimeError as exc: |         except RuntimeError as exc: | ||||||
|  | |||||||
| @ -1,12 +1,11 @@ | |||||||
| """Base Controller""" | """Base Controller""" | ||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
| from os import environ |  | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| from structlog.testing import capture_logs | from structlog.testing import capture_logs | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import __version__, get_build_hash | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.outposts.models import ( | from authentik.outposts.models import ( | ||||||
| @ -102,5 +101,5 @@ class BaseController: | |||||||
|         return image_name_template % { |         return image_name_template % { | ||||||
|             "type": self.outpost.type, |             "type": self.outpost.type, | ||||||
|             "version": __version__, |             "version": __version__, | ||||||
|             "build_hash": environ.get(ENV_GIT_HASH_KEY, ""), |             "build_hash": get_build_hash(), | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -9,6 +9,7 @@ from docker import DockerClient as UpstreamDockerClient | |||||||
| from docker.errors import DockerException, NotFound | from docker.errors import DockerException, NotFound | ||||||
| from docker.models.containers import Container | from docker.models.containers import Container | ||||||
| from docker.utils.utils import kwargs_from_env | from docker.utils.utils import kwargs_from_env | ||||||
|  | from paramiko.ssh_exception import SSHException | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| from yaml import safe_dump | from yaml import safe_dump | ||||||
|  |  | ||||||
| @ -49,10 +50,13 @@ class DockerClient(UpstreamDockerClient, BaseClient): | |||||||
|                     authentication_kp=connection.tls_authentication, |                     authentication_kp=connection.tls_authentication, | ||||||
|                 ) |                 ) | ||||||
|                 tls_config = self.tls.write() |                 tls_config = self.tls.write() | ||||||
|             super().__init__( |             try: | ||||||
|                 base_url=connection.url, |                 super().__init__( | ||||||
|                 tls=tls_config, |                     base_url=connection.url, | ||||||
|             ) |                     tls=tls_config, | ||||||
|  |                 ) | ||||||
|  |             except SSHException as exc: | ||||||
|  |                 raise ServiceConnectionInvalid from exc | ||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
|         # Ensure the client actually works |         # Ensure the client actually works | ||||||
|         self.containers.list() |         self.containers.list() | ||||||
| @ -102,9 +106,12 @@ class DockerController(BaseController): | |||||||
|         ).lower() |         ).lower() | ||||||
|  |  | ||||||
|     def _get_labels(self) -> dict[str, str]: |     def _get_labels(self) -> dict[str, str]: | ||||||
|         return { |         labels = { | ||||||
|             "io.goauthentik.outpost-uuid": self.outpost.pk.hex, |             "io.goauthentik.outpost-uuid": self.outpost.pk.hex, | ||||||
|         } |         } | ||||||
|  |         if self.outpost.config.docker_labels: | ||||||
|  |             labels.update(self.outpost.config.docker_labels) | ||||||
|  |         return labels | ||||||
|  |  | ||||||
|     def _get_env(self) -> dict[str, str]: |     def _get_env(self) -> dict[str, str]: | ||||||
|         return { |         return { | ||||||
|  | |||||||
| @ -18,6 +18,7 @@ from kubernetes.client import ( | |||||||
|     V1SecretKeySelector, |     V1SecretKeySelector, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | from authentik import __version__, get_full_version | ||||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
| from authentik.outposts.controllers.k8s.triggers import NeedsUpdate | from authentik.outposts.controllers.k8s.triggers import NeedsUpdate | ||||||
| @ -52,15 +53,18 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|             raise NeedsUpdate() |             raise NeedsUpdate() | ||||||
|         super().reconcile(current, reference) |         super().reconcile(current, reference) | ||||||
|  |  | ||||||
|     def get_pod_meta(self) -> dict[str, str]: |     def get_pod_meta(self, **kwargs) -> dict[str, str]: | ||||||
|         """Get common object metadata""" |         """Get common object metadata""" | ||||||
|         return { |         kwargs.update( | ||||||
|             "app.kubernetes.io/name": "authentik-outpost", |             { | ||||||
|             "app.kubernetes.io/managed-by": "goauthentik.io", |                 "app.kubernetes.io/name": f"authentik-outpost-{self.outpost.type}", | ||||||
|             "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex, |                 "app.kubernetes.io/managed-by": "goauthentik.io", | ||||||
|             "goauthentik.io/outpost-name": slugify(self.controller.outpost.name), |                 "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex, | ||||||
|             "goauthentik.io/outpost-type": str(self.controller.outpost.type), |                 "goauthentik.io/outpost-name": slugify(self.controller.outpost.name), | ||||||
|         } |                 "goauthentik.io/outpost-type": str(self.controller.outpost.type), | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |         return kwargs | ||||||
|  |  | ||||||
|     def get_reference_object(self) -> V1Deployment: |     def get_reference_object(self) -> V1Deployment: | ||||||
|         """Get deployment object for outpost""" |         """Get deployment object for outpost""" | ||||||
| @ -77,13 +81,24 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | |||||||
|         meta = self.get_object_meta(name=self.name) |         meta = self.get_object_meta(name=self.name) | ||||||
|         image_name = self.controller.get_container_image() |         image_name = self.controller.get_container_image() | ||||||
|         image_pull_secrets = self.outpost.config.kubernetes_image_pull_secrets |         image_pull_secrets = self.outpost.config.kubernetes_image_pull_secrets | ||||||
|  |         version = get_full_version() | ||||||
|         return V1Deployment( |         return V1Deployment( | ||||||
|             metadata=meta, |             metadata=meta, | ||||||
|             spec=V1DeploymentSpec( |             spec=V1DeploymentSpec( | ||||||
|                 replicas=self.outpost.config.kubernetes_replicas, |                 replicas=self.outpost.config.kubernetes_replicas, | ||||||
|                 selector=V1LabelSelector(match_labels=self.get_pod_meta()), |                 selector=V1LabelSelector(match_labels=self.get_pod_meta()), | ||||||
|                 template=V1PodTemplateSpec( |                 template=V1PodTemplateSpec( | ||||||
|                     metadata=V1ObjectMeta(labels=self.get_pod_meta()), |                     metadata=V1ObjectMeta( | ||||||
|  |                         labels=self.get_pod_meta( | ||||||
|  |                             **{ | ||||||
|  |                                 # Support istio-specific labels, but also use the standard k8s | ||||||
|  |                                 # recommendations | ||||||
|  |                                 "app.kubernetes.io/version": version, | ||||||
|  |                                 "app": "authentik-outpost", | ||||||
|  |                                 "version": version, | ||||||
|  |                             } | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|                     spec=V1PodSpec( |                     spec=V1PodSpec( | ||||||
|                         image_pull_secrets=[ |                         image_pull_secrets=[ | ||||||
|                             V1ObjectReference(name=secret) for secret in image_pull_secrets |                             V1ObjectReference(name=secret) for secret in image_pull_secrets | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec | |||||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | from authentik.outposts.controllers.base import FIELD_MANAGER | ||||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
| from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler | from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler | ||||||
|  | from authentik.outposts.controllers.k8s.triggers import NeedsUpdate | ||||||
| from authentik.outposts.controllers.k8s.utils import compare_ports | from authentik.outposts.controllers.k8s.utils import compare_ports | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
| @ -25,6 +26,8 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): | |||||||
|         # after an authentik update. However the ports might have also changed during |         # after an authentik update. However the ports might have also changed during | ||||||
|         # the update, so this causes the service to be re-created with higher |         # the update, so this causes the service to be re-created with higher | ||||||
|         # priority than being updated. |         # priority than being updated. | ||||||
|  |         if current.spec.selector != reference.spec.selector: | ||||||
|  |             raise NeedsUpdate() | ||||||
|         super().reconcile(current, reference) |         super().reconcile(current, reference) | ||||||
|  |  | ||||||
|     def get_reference_object(self) -> V1Service: |     def get_reference_object(self) -> V1Service: | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
| from kubernetes.client.models.v1_container_port import V1ContainerPort | from kubernetes.client.models.v1_container_port import V1ContainerPort | ||||||
|  | from kubernetes.client.models.v1_service_port import V1ServicePort | ||||||
| from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME | from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.k8s.triggers import NeedsRecreate | from authentik.outposts.controllers.k8s.triggers import NeedsRecreate | ||||||
| @ -16,10 +17,31 @@ def get_namespace() -> str: | |||||||
|     return "default" |     return "default" | ||||||
|  |  | ||||||
|  |  | ||||||
| def compare_ports(current: list[V1ContainerPort], reference: list[V1ContainerPort]): | def compare_port( | ||||||
|  |     current: V1ServicePort | V1ContainerPort, reference: V1ServicePort | V1ContainerPort | ||||||
|  | ) -> bool: | ||||||
|  |     """Compare a single port""" | ||||||
|  |     if current.name != reference.name: | ||||||
|  |         return False | ||||||
|  |     if current.protocol != reference.protocol: | ||||||
|  |         return False | ||||||
|  |     if isinstance(current, V1ServicePort) and isinstance(reference, V1ServicePort): | ||||||
|  |         # We only care about the target port | ||||||
|  |         if current.target_port != reference.target_port: | ||||||
|  |             return False | ||||||
|  |     if isinstance(current, V1ContainerPort) and isinstance(reference, V1ContainerPort): | ||||||
|  |         # We only care about the target port | ||||||
|  |         if current.container_port != reference.container_port: | ||||||
|  |             return False | ||||||
|  |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def compare_ports( | ||||||
|  |     current: list[V1ServicePort | V1ContainerPort], reference: list[V1ServicePort | V1ContainerPort] | ||||||
|  | ): | ||||||
|     """Compare ports of a list""" |     """Compare ports of a list""" | ||||||
|     if len(current) != len(reference): |     if len(current) != len(reference): | ||||||
|         raise NeedsRecreate() |         raise NeedsRecreate() | ||||||
|     for port in reference: |     for port in reference: | ||||||
|         if port not in current: |         if not any(compare_port(port, current_port) for current_port in current): | ||||||
|             raise NeedsRecreate() |             raise NeedsRecreate() | ||||||
|  | |||||||
| @ -3,6 +3,8 @@ import os | |||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from tempfile import gettempdir | from tempfile import gettempdir | ||||||
|  |  | ||||||
|  | from docker.errors import DockerException | ||||||
|  |  | ||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
|  |  | ||||||
| HEADER = "### Managed by authentik" | HEADER = "### Managed by authentik" | ||||||
| @ -27,6 +29,8 @@ class DockerInlineSSH: | |||||||
|     def __init__(self, host: str, keypair: CertificateKeyPair) -> None: |     def __init__(self, host: str, keypair: CertificateKeyPair) -> None: | ||||||
|         self.host = host |         self.host = host | ||||||
|         self.keypair = keypair |         self.keypair = keypair | ||||||
|  |         if not self.keypair: | ||||||
|  |             raise DockerException("keypair must be set for SSH connections") | ||||||
|         self.config_path = Path("~/.ssh/config").expanduser() |         self.config_path = Path("~/.ssh/config").expanduser() | ||||||
|         self.header = f"{HEADER} - {self.host}\n" |         self.header = f"{HEADER} - {self.host}\n" | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """Outpost models""" | """Outpost models""" | ||||||
| from dataclasses import asdict, dataclass, field | from dataclasses import asdict, dataclass, field | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from os import environ |  | ||||||
| from typing import Iterable, Optional | from typing import Iterable, Optional | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| @ -17,7 +16,7 @@ from model_utils.managers import InheritanceManager | |||||||
| from packaging.version import LegacyVersion, Version, parse | from packaging.version import LegacyVersion, Version, parse | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import __version__, get_build_hash | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
|     USER_ATTRIBUTE_CAN_OVERRIDE_IP, |     USER_ATTRIBUTE_CAN_OVERRIDE_IP, | ||||||
|     USER_ATTRIBUTE_SA, |     USER_ATTRIBUTE_SA, | ||||||
| @ -61,6 +60,7 @@ class OutpostConfig: | |||||||
|  |  | ||||||
|     docker_network: Optional[str] = field(default=None) |     docker_network: Optional[str] = field(default=None) | ||||||
|     docker_map_ports: bool = field(default=True) |     docker_map_ports: bool = field(default=True) | ||||||
|  |     docker_labels: Optional[dict[str, str]] = field(default=None) | ||||||
|  |  | ||||||
|     container_image: Optional[str] = field(default=None) |     container_image: Optional[str] = field(default=None) | ||||||
|  |  | ||||||
| @ -414,7 +414,7 @@ class OutpostState: | |||||||
|         """Check if outpost version matches our version""" |         """Check if outpost version matches our version""" | ||||||
|         if not self.version: |         if not self.version: | ||||||
|             return False |             return False | ||||||
|         if self.build_hash != environ.get(ENV_GIT_HASH_KEY, ""): |         if self.build_hash != get_build_hash(): | ||||||
|             return False |             return False | ||||||
|         return parse(self.version) < OUR_VERSION |         return parse(self.version) < OUR_VERSION | ||||||
|  |  | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ from authentik.events.monitored_tasks import ( | |||||||
|     TaskResultStatus, |     TaskResultStatus, | ||||||
|     prefill_task, |     prefill_task, | ||||||
| ) | ) | ||||||
|  | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.reflection import path_to_class | from authentik.lib.utils.reflection import path_to_class | ||||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | from authentik.outposts.controllers.base import BaseController, ControllerException | ||||||
| from authentik.outposts.controllers.docker import DockerClient | from authentik.outposts.controllers.docker import DockerClient | ||||||
| @ -77,8 +78,12 @@ def outpost_service_connection_state(connection_pk: Any): | |||||||
|         cls = DockerClient |         cls = DockerClient | ||||||
|     if isinstance(connection, KubernetesServiceConnection): |     if isinstance(connection, KubernetesServiceConnection): | ||||||
|         cls = KubernetesClient |         cls = KubernetesClient | ||||||
|     with cls(connection) as client: |     try: | ||||||
|         state = client.fetch_state() |         with cls(connection) as client: | ||||||
|  |             state = client.fetch_state() | ||||||
|  |     except ServiceConnectionInvalid as exc: | ||||||
|  |         LOGGER.warning("Failed to get client status", exc=exc) | ||||||
|  |         return | ||||||
|     cache.set(connection.state_key, state, timeout=None) |     cache.set(connection.state_key, state, timeout=None) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -227,6 +232,9 @@ def _outpost_single_update(outpost: Outpost, layer=None): | |||||||
| @CELERY_APP.task() | @CELERY_APP.task() | ||||||
| def outpost_local_connection(): | def outpost_local_connection(): | ||||||
|     """Checks the local environment and create Service connections.""" |     """Checks the local environment and create Service connections.""" | ||||||
|  |     if not CONFIG.y_bool("outposts.discover"): | ||||||
|  |         LOGGER.debug("outpost integration discovery is disabled") | ||||||
|  |         return | ||||||
|     # Explicitly check against token filename, as that's |     # Explicitly check against token filename, as that's | ||||||
|     # only present when the integration is enabled |     # only present when the integration is enabled | ||||||
|     if Path(SERVICE_TOKEN_FILENAME).exists(): |     if Path(SERVICE_TOKEN_FILENAME).exists(): | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ from typing import Iterator, Optional | |||||||
|  |  | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from prometheus_client import Histogram | from 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 | ||||||
| @ -14,13 +14,11 @@ from authentik.core.models import User | |||||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | ||||||
| from authentik.policies.process import PolicyProcess, cache_key | from authentik.policies.process import PolicyProcess, cache_key | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
| from authentik.root.monitoring import UpdatingGauge |  | ||||||
|  |  | ||||||
| CURRENT_PROCESS = current_process() | CURRENT_PROCESS = current_process() | ||||||
| GAUGE_POLICIES_CACHED = UpdatingGauge( | GAUGE_POLICIES_CACHED = Gauge( | ||||||
|     "authentik_policies_cached", |     "authentik_policies_cached", | ||||||
|     "Cached Policies", |     "Cached Policies", | ||||||
|     update_func=lambda: len(cache.keys("policy_*") or []), |  | ||||||
| ) | ) | ||||||
| HIST_POLICIES_BUILD_TIME = Histogram( | HIST_POLICIES_BUILD_TIME = Histogram( | ||||||
|     "authentik_policies_build_time", |     "authentik_policies_build_time", | ||||||
|  | |||||||
| @ -45,7 +45,7 @@ class HaveIBeenPwendPolicy(Policy): | |||||||
|                 fields=request.context.keys(), |                 fields=request.context.keys(), | ||||||
|             ) |             ) | ||||||
|             return PolicyResult(False, _("Password not set in context")) |             return PolicyResult(False, _("Password not set in context")) | ||||||
|         password = request.context[self.password_field] |         password = str(request.context[self.password_field]) | ||||||
|  |  | ||||||
|         pw_hash = sha1(password.encode("utf-8")).hexdigest()  # nosec |         pw_hash = sha1(password.encode("utf-8")).hexdigest()  # nosec | ||||||
|         url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}" |         url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}" | ||||||
|  | |||||||
| @ -5,10 +5,19 @@ from django.dispatch import receiver | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.api.applications import user_app_cache_key | from authentik.core.api.applications import user_app_cache_key | ||||||
|  | from authentik.policies.engine import GAUGE_POLICIES_CACHED | ||||||
|  | from authentik.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(monitoring_set) | ||||||
|  | # pylint: disable=unused-argument | ||||||
|  | def monitoring_set_policies(sender, **kwargs): | ||||||
|  |     """set policy gauges""" | ||||||
|  |     GAUGE_POLICIES_CACHED.set(len(cache.keys("policy_*") or [])) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def invalidate_policy_cache(sender, instance, **_): | def invalidate_policy_cache(sender, instance, **_): | ||||||
|  | |||||||
| @ -2,9 +2,12 @@ | |||||||
|  |  | ||||||
| GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" | GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" | ||||||
| GRANT_TYPE_REFRESH_TOKEN = "refresh_token"  # nosec | GRANT_TYPE_REFRESH_TOKEN = "refresh_token"  # nosec | ||||||
|  | GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" | ||||||
|  |  | ||||||
| PROMPT_NONE = "none" | PROMPT_NONE = "none" | ||||||
| PROMPT_CONSNET = "consent" | PROMPT_CONSNET = "consent" | ||||||
| PROMPT_LOGIN = "login" | PROMPT_LOGIN = "login" | ||||||
|  |  | ||||||
| SCOPE_OPENID = "openid" | SCOPE_OPENID = "openid" | ||||||
| SCOPE_OPENID_PROFILE = "profile" | SCOPE_OPENID_PROFILE = "profile" | ||||||
| SCOPE_OPENID_EMAIL = "email" | SCOPE_OPENID_EMAIL = "email" | ||||||
|  | |||||||
| @ -168,7 +168,7 @@ class TokenError(OAuth2Error): | |||||||
|     https://tools.ietf.org/html/rfc6749#section-5.2 |     https://tools.ietf.org/html/rfc6749#section-5.2 | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     _errors = { |     errors = { | ||||||
|         "invalid_request": "The request is otherwise malformed", |         "invalid_request": "The request is otherwise malformed", | ||||||
|         "invalid_client": "Client authentication failed (e.g., unknown client, " |         "invalid_client": "Client authentication failed (e.g., unknown client, " | ||||||
|         "no client authentication included, or unsupported " |         "no client authentication included, or unsupported " | ||||||
| @ -188,7 +188,7 @@ class TokenError(OAuth2Error): | |||||||
|     def __init__(self, error): |     def __init__(self, error): | ||||||
|         super().__init__() |         super().__init__() | ||||||
|         self.error = error |         self.error = error | ||||||
|         self.description = self._errors[error] |         self.description = self.errors[error] | ||||||
|  |  | ||||||
|  |  | ||||||
| class BearerTokenError(OAuth2Error): | class BearerTokenError(OAuth2Error): | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ from dataclasses import asdict, dataclass, field | |||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from hashlib import sha256 | from hashlib import sha256 | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse, urlunparse | ||||||
|  |  | ||||||
| from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey | from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey | ||||||
| from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | ||||||
| @ -45,6 +45,13 @@ class GrantTypes(models.TextChoices): | |||||||
|     HYBRID = "hybrid" |     HYBRID = "hybrid" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ResponseMode(models.TextChoices): | ||||||
|  |     """https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#OAuth.Post""" | ||||||
|  |  | ||||||
|  |     QUERY = "query" | ||||||
|  |     FRAGMENT = "fragment" | ||||||
|  |  | ||||||
|  |  | ||||||
| class SubModes(models.TextChoices): | class SubModes(models.TextChoices): | ||||||
|     """Mode after which 'sub' attribute is generateed, for compatibility reasons""" |     """Mode after which 'sub' attribute is generateed, for compatibility reasons""" | ||||||
|  |  | ||||||
| @ -259,8 +266,8 @@ class OAuth2Provider(Provider): | |||||||
|         if self.redirect_uris == "": |         if self.redirect_uris == "": | ||||||
|             return None |             return None | ||||||
|         main_url = self.redirect_uris.split("\n", maxsplit=1)[0] |         main_url = self.redirect_uris.split("\n", maxsplit=1)[0] | ||||||
|         launch_url = urlparse(main_url) |         launch_url = urlparse(main_url)._replace(path="") | ||||||
|         return main_url.replace(launch_url.path, "") |         return urlunparse(launch_url) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def component(self) -> str: |     def component(self) -> str: | ||||||
|  | |||||||
| @ -43,7 +43,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             name="test", |             name="test", | ||||||
|             client_id="test", |             client_id="test", | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid/Foo", | ||||||
|         ) |         ) | ||||||
|         with self.assertRaises(AuthorizeError): |         with self.assertRaises(AuthorizeError): | ||||||
|             request = self.factory.get( |             request = self.factory.get( | ||||||
| @ -51,7 +51,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                 data={ |                 data={ | ||||||
|                     "response_type": "code", |                     "response_type": "code", | ||||||
|                     "client_id": "test", |                     "client_id": "test", | ||||||
|                     "redirect_uri": "http://local.invalid", |                     "redirect_uri": "http://local.invalid/Foo", | ||||||
|                     "request": "foo", |                     "request": "foo", | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
| @ -105,26 +105,30 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             name="test", |             name="test", | ||||||
|             client_id="test", |             client_id="test", | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid/Foo", | ||||||
|         ) |         ) | ||||||
|         request = self.factory.get( |         request = self.factory.get( | ||||||
|             "/", |             "/", | ||||||
|             data={ |             data={ | ||||||
|                 "response_type": "code", |                 "response_type": "code", | ||||||
|                 "client_id": "test", |                 "client_id": "test", | ||||||
|                 "redirect_uri": "http://local.invalid", |                 "redirect_uri": "http://local.invalid/Foo", | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             OAuthAuthorizationParams.from_request(request).grant_type, |             OAuthAuthorizationParams.from_request(request).grant_type, | ||||||
|             GrantTypes.AUTHORIZATION_CODE, |             GrantTypes.AUTHORIZATION_CODE, | ||||||
|         ) |         ) | ||||||
|  |         self.assertEqual( | ||||||
|  |             OAuthAuthorizationParams.from_request(request).redirect_uri, | ||||||
|  |             "http://local.invalid/Foo", | ||||||
|  |         ) | ||||||
|         request = self.factory.get( |         request = self.factory.get( | ||||||
|             "/", |             "/", | ||||||
|             data={ |             data={ | ||||||
|                 "response_type": "id_token", |                 "response_type": "id_token", | ||||||
|                 "client_id": "test", |                 "client_id": "test", | ||||||
|                 "redirect_uri": "http://local.invalid", |                 "redirect_uri": "http://local.invalid/Foo", | ||||||
|                 "scope": "openid", |                 "scope": "openid", | ||||||
|                 "state": "foo", |                 "state": "foo", | ||||||
|             }, |             }, | ||||||
| @ -140,7 +144,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                 data={ |                 data={ | ||||||
|                     "response_type": "id_token", |                     "response_type": "id_token", | ||||||
|                     "client_id": "test", |                     "client_id": "test", | ||||||
|                     "redirect_uri": "http://local.invalid", |                     "redirect_uri": "http://local.invalid/Foo", | ||||||
|                     "state": "foo", |                     "state": "foo", | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
| @ -153,7 +157,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             data={ |             data={ | ||||||
|                 "response_type": "code token", |                 "response_type": "code token", | ||||||
|                 "client_id": "test", |                 "client_id": "test", | ||||||
|                 "redirect_uri": "http://local.invalid", |                 "redirect_uri": "http://local.invalid/Foo", | ||||||
|                 "scope": "openid", |                 "scope": "openid", | ||||||
|                 "state": "foo", |                 "state": "foo", | ||||||
|             }, |             }, | ||||||
| @ -167,7 +171,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                 data={ |                 data={ | ||||||
|                     "response_type": "invalid", |                     "response_type": "invalid", | ||||||
|                     "client_id": "test", |                     "client_id": "test", | ||||||
|                     "redirect_uri": "http://local.invalid", |                     "redirect_uri": "http://local.invalid/Foo", | ||||||
|                 }, |                 }, | ||||||
|             ) |             ) | ||||||
|             OAuthAuthorizationParams.from_request(request) |             OAuthAuthorizationParams.from_request(request) | ||||||
|  | |||||||
| @ -0,0 +1,174 @@ | |||||||
|  | """Test token view""" | ||||||
|  | from json import loads | ||||||
|  |  | ||||||
|  | from django.test import RequestFactory | ||||||
|  | from django.urls import reverse | ||||||
|  | from jwt import decode | ||||||
|  |  | ||||||
|  | from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group, Token, TokenIntents | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||||
|  | from authentik.lib.generators import generate_id, generate_key | ||||||
|  | from authentik.managed.manager import ObjectManager | ||||||
|  | from authentik.policies.models import PolicyBinding | ||||||
|  | from authentik.providers.oauth2.constants import ( | ||||||
|  |     GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|  |     SCOPE_OPENID, | ||||||
|  |     SCOPE_OPENID_EMAIL, | ||||||
|  |     SCOPE_OPENID_PROFILE, | ||||||
|  | ) | ||||||
|  | from authentik.providers.oauth2.errors import TokenError | ||||||
|  | from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping | ||||||
|  | from authentik.providers.oauth2.tests.utils import OAuthTestCase | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestTokenClientCredentials(OAuthTestCase): | ||||||
|  |     """Test token (client_credentials) view""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         super().setUp() | ||||||
|  |         ObjectManager().run() | ||||||
|  |         self.factory = RequestFactory() | ||||||
|  |         self.provider = OAuth2Provider.objects.create( | ||||||
|  |             name="test", | ||||||
|  |             client_id=generate_id(), | ||||||
|  |             client_secret=generate_key(), | ||||||
|  |             authorization_flow=create_test_flow(), | ||||||
|  |             redirect_uris="http://testserver", | ||||||
|  |             signing_key=create_test_cert(), | ||||||
|  |         ) | ||||||
|  |         self.provider.property_mappings.set(ScopeMapping.objects.all()) | ||||||
|  |         self.app = Application.objects.create(name="test", slug="test", provider=self.provider) | ||||||
|  |         self.user = create_test_admin_user("sa") | ||||||
|  |         self.user.attributes[USER_ATTRIBUTE_SA] = True | ||||||
|  |         self.user.save() | ||||||
|  |         self.token = Token.objects.create( | ||||||
|  |             identifier="sa-token", | ||||||
|  |             user=self.user, | ||||||
|  |             intent=TokenIntents.INTENT_APP_PASSWORD, | ||||||
|  |             expiring=False, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_wrong_user(self): | ||||||
|  |         """test invalid username""" | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             { | ||||||
|  |                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|  |                 "scope": SCOPE_OPENID, | ||||||
|  |                 "client_id": self.provider.client_id, | ||||||
|  |                 "username": "saa", | ||||||
|  |                 "password": self.token.key, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), | ||||||
|  |             {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_wrong_token(self): | ||||||
|  |         """test invalid token""" | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             { | ||||||
|  |                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|  |                 "scope": SCOPE_OPENID, | ||||||
|  |                 "client_id": self.provider.client_id, | ||||||
|  |                 "username": "sa", | ||||||
|  |                 "password": self.token.key + "foo", | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), | ||||||
|  |             {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_non_sa(self): | ||||||
|  |         """test non service-account""" | ||||||
|  |         self.user.attributes[USER_ATTRIBUTE_SA] = False | ||||||
|  |         self.user.save() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             { | ||||||
|  |                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|  |                 "scope": SCOPE_OPENID, | ||||||
|  |                 "client_id": self.provider.client_id, | ||||||
|  |                 "username": "sa", | ||||||
|  |                 "password": self.token.key, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), | ||||||
|  |             {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_no_provider(self): | ||||||
|  |         """test no provider""" | ||||||
|  |         self.app.provider = None | ||||||
|  |         self.app.save() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             { | ||||||
|  |                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|  |                 "scope": SCOPE_OPENID, | ||||||
|  |                 "client_id": self.provider.client_id, | ||||||
|  |                 "username": "sa", | ||||||
|  |                 "password": self.token.key, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), | ||||||
|  |             {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_permission_denied(self): | ||||||
|  |         """test permission denied""" | ||||||
|  |         group = Group.objects.create(name="foo") | ||||||
|  |         PolicyBinding.objects.create( | ||||||
|  |             group=group, | ||||||
|  |             target=self.app, | ||||||
|  |             order=0, | ||||||
|  |         ) | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             { | ||||||
|  |                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|  |                 "scope": SCOPE_OPENID, | ||||||
|  |                 "client_id": self.provider.client_id, | ||||||
|  |                 "username": "sa", | ||||||
|  |                 "password": self.token.key, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             response.content.decode(), | ||||||
|  |             {"error": "invalid_grant", "error_description": TokenError.errors["invalid_grant"]}, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_successful(self): | ||||||
|  |         """test successful""" | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token"), | ||||||
|  |             { | ||||||
|  |                 "grant_type": GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|  |                 "scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}", | ||||||
|  |                 "client_id": self.provider.client_id, | ||||||
|  |                 "username": "sa", | ||||||
|  |                 "password": self.token.key, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         body = loads(response.content.decode()) | ||||||
|  |         self.assertEqual(body["token_type"], "bearer") | ||||||
|  |         _, alg = self.provider.get_jwt_key() | ||||||
|  |         jwt = decode( | ||||||
|  |             body["access_token"], | ||||||
|  |             key=self.provider.signing_key.public_key, | ||||||
|  |             algorithms=[alg], | ||||||
|  |             audience=self.provider.client_id, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(jwt["given_name"], self.user.name) | ||||||
|  |         self.assertEqual(jwt["preferred_username"], self.user.username) | ||||||
| @ -44,6 +44,7 @@ from authentik.providers.oauth2.models import ( | |||||||
|     AuthorizationCode, |     AuthorizationCode, | ||||||
|     GrantTypes, |     GrantTypes, | ||||||
|     OAuth2Provider, |     OAuth2Provider, | ||||||
|  |     ResponseMode, | ||||||
|     ResponseTypes, |     ResponseTypes, | ||||||
| ) | ) | ||||||
| from authentik.providers.oauth2.utils import HttpResponseRedirectScheme | from authentik.providers.oauth2.utils import HttpResponseRedirectScheme | ||||||
| @ -153,16 +154,26 @@ class OAuthAuthorizationParams: | |||||||
|     def check_redirect_uri(self): |     def check_redirect_uri(self): | ||||||
|         """Redirect URI validation.""" |         """Redirect URI validation.""" | ||||||
|         allowed_redirect_urls = self.provider.redirect_uris.split() |         allowed_redirect_urls = self.provider.redirect_uris.split() | ||||||
|         if not self.redirect_uri: |         # We don't want to actually lowercase the final URL we redirect to, | ||||||
|  |         # we only lowercase it for comparison | ||||||
|  |         redirect_uri = self.redirect_uri.lower() | ||||||
|  |         if not redirect_uri: | ||||||
|             LOGGER.warning("Missing redirect uri.") |             LOGGER.warning("Missing redirect uri.") | ||||||
|             raise RedirectUriError("", allowed_redirect_urls) |             raise RedirectUriError("", allowed_redirect_urls) | ||||||
|         if len(allowed_redirect_urls) < 1: |  | ||||||
|  |         if self.provider.redirect_uris == "": | ||||||
|  |             LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri) | ||||||
|  |             self.provider.redirect_uris = self.redirect_uri | ||||||
|  |             self.provider.save() | ||||||
|  |             allowed_redirect_urls = self.provider.redirect_uris.split() | ||||||
|  |  | ||||||
|  |         if self.provider.redirect_uris == "*": | ||||||
|             LOGGER.warning( |             LOGGER.warning( | ||||||
|                 "Provider has no allowed redirect_uri set, allowing all.", |                 "Provider has wildcard allowed redirect_uri set, allowing all.", | ||||||
|                 allow=self.redirect_uri.lower(), |                 allow=self.redirect_uri, | ||||||
|             ) |             ) | ||||||
|             return |             return | ||||||
|         if self.redirect_uri.lower() not in [x.lower() for x in allowed_redirect_urls]: |         if redirect_uri not in [x.lower() for x in allowed_redirect_urls]: | ||||||
|             LOGGER.warning( |             LOGGER.warning( | ||||||
|                 "Invalid redirect uri", |                 "Invalid redirect uri", | ||||||
|                 redirect_uri=self.redirect_uri, |                 redirect_uri=self.redirect_uri, | ||||||
| @ -292,13 +303,23 @@ class OAuthFulfillmentStage(StageView): | |||||||
|                 code = self.params.create_code(self.request) |                 code = self.params.create_code(self.request) | ||||||
|                 code.save(force_insert=True) |                 code.save(force_insert=True) | ||||||
|  |  | ||||||
|             if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE: |             query_dict = self.request.POST if self.request.method == "POST" else self.request.GET | ||||||
|  |             response_mode = ResponseMode.QUERY | ||||||
|  |             # Get response mode from url param, otherwise decide based on grant type | ||||||
|  |             if "response_mode" in query_dict: | ||||||
|  |                 response_mode = query_dict["response_mode"] | ||||||
|  |             elif self.params.grant_type == GrantTypes.AUTHORIZATION_CODE: | ||||||
|  |                 response_mode = ResponseMode.QUERY | ||||||
|  |             elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]: | ||||||
|  |                 response_mode = ResponseMode.FRAGMENT | ||||||
|  |  | ||||||
|  |             if response_mode == ResponseMode.QUERY: | ||||||
|                 query_params["code"] = code.code |                 query_params["code"] = code.code | ||||||
|                 query_params["state"] = [str(self.params.state) if self.params.state else ""] |                 query_params["state"] = [str(self.params.state) if self.params.state else ""] | ||||||
|  |  | ||||||
|                 uri = uri._replace(query=urlencode(query_params, doseq=True)) |                 uri = uri._replace(query=urlencode(query_params, doseq=True)) | ||||||
|                 return urlunsplit(uri) |                 return urlunsplit(uri) | ||||||
|             if self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]: |             if response_mode == ResponseMode.FRAGMENT: | ||||||
|                 query_fragment = self.create_implicit_response(code) |                 query_fragment = self.create_implicit_response(code) | ||||||
|  |  | ||||||
|                 uri = uri._replace( |                 uri = uri._replace( | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ from authentik.core.models import Application | |||||||
| from authentik.providers.oauth2.constants import ( | from authentik.providers.oauth2.constants import ( | ||||||
|     ACR_AUTHENTIK_DEFAULT, |     ACR_AUTHENTIK_DEFAULT, | ||||||
|     GRANT_TYPE_AUTHORIZATION_CODE, |     GRANT_TYPE_AUTHORIZATION_CODE, | ||||||
|  |     GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|     GRANT_TYPE_REFRESH_TOKEN, |     GRANT_TYPE_REFRESH_TOKEN, | ||||||
|     SCOPE_OPENID, |     SCOPE_OPENID, | ||||||
| ) | ) | ||||||
| @ -78,6 +79,7 @@ class ProviderInfoView(View): | |||||||
|                 GRANT_TYPE_AUTHORIZATION_CODE, |                 GRANT_TYPE_AUTHORIZATION_CODE, | ||||||
|                 GRANT_TYPE_REFRESH_TOKEN, |                 GRANT_TYPE_REFRESH_TOKEN, | ||||||
|                 GrantTypes.IMPLICIT, |                 GrantTypes.IMPLICIT, | ||||||
|  |                 GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|             ], |             ], | ||||||
|             "id_token_signing_alg_values_supported": [supported_alg], |             "id_token_signing_alg_values_supported": [supported_alg], | ||||||
|             # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes |             # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes | ||||||
|  | |||||||
| @ -8,10 +8,13 @@ from django.http import HttpRequest, HttpResponse | |||||||
| 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.models import USER_ATTRIBUTE_SA, Application, Token, TokenIntents, User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.utils.time import timedelta_from_string | from authentik.lib.utils.time import timedelta_from_string | ||||||
|  | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.providers.oauth2.constants import ( | from authentik.providers.oauth2.constants import ( | ||||||
|     GRANT_TYPE_AUTHORIZATION_CODE, |     GRANT_TYPE_AUTHORIZATION_CODE, | ||||||
|  |     GRANT_TYPE_CLIENT_CREDENTIALS, | ||||||
|     GRANT_TYPE_REFRESH_TOKEN, |     GRANT_TYPE_REFRESH_TOKEN, | ||||||
| ) | ) | ||||||
| from authentik.providers.oauth2.errors import TokenError, UserAuthError | from authentik.providers.oauth2.errors import TokenError, UserAuthError | ||||||
| @ -42,6 +45,7 @@ class TokenParams: | |||||||
|  |  | ||||||
|     authorization_code: Optional[AuthorizationCode] = None |     authorization_code: Optional[AuthorizationCode] = None | ||||||
|     refresh_token: Optional[RefreshToken] = None |     refresh_token: Optional[RefreshToken] = None | ||||||
|  |     user: Optional[User] = None | ||||||
|  |  | ||||||
|     code_verifier: Optional[str] = None |     code_verifier: Optional[str] = None | ||||||
|  |  | ||||||
| @ -66,7 +70,7 @@ class TokenParams: | |||||||
|             provider=provider, |             provider=provider, | ||||||
|             client_id=client_id, |             client_id=client_id, | ||||||
|             client_secret=client_secret, |             client_secret=client_secret, | ||||||
|             redirect_uri=request.POST.get("redirect_uri", ""), |             redirect_uri=request.POST.get("redirect_uri", "").lower(), | ||||||
|             grant_type=request.POST.get("grant_type", ""), |             grant_type=request.POST.get("grant_type", ""), | ||||||
|             state=request.POST.get("state", ""), |             state=request.POST.get("state", ""), | ||||||
|             scope=request.POST.get("scope", "").split(), |             scope=request.POST.get("scope", "").split(), | ||||||
| @ -75,69 +79,44 @@ class TokenParams: | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest): |     def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest): | ||||||
|         if self.provider.client_type == ClientTypes.CONFIDENTIAL: |         if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]: | ||||||
|             if self.provider.client_secret != self.client_secret: |             if ( | ||||||
|  |                 self.provider.client_type == ClientTypes.CONFIDENTIAL | ||||||
|  |                 and self.provider.client_secret != self.client_secret | ||||||
|  |             ): | ||||||
|                 LOGGER.warning( |                 LOGGER.warning( | ||||||
|                     "Invalid client secret: client does not have secret", |                     "Invalid client secret", | ||||||
|                     client_id=self.provider.client_id, |                     client_id=self.provider.client_id, | ||||||
|                     secret=self.provider.client_secret, |  | ||||||
|                 ) |                 ) | ||||||
|                 raise TokenError("invalid_client") |                 raise TokenError("invalid_client") | ||||||
|  |  | ||||||
|         if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: |         if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: | ||||||
|             self.__post_init_code(raw_code) |             self.__post_init_code(raw_code) | ||||||
|         elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN: |         elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN: | ||||||
|             if not raw_token: |             self.__post_init_refresh(raw_token, request) | ||||||
|                 LOGGER.warning("Missing refresh token") |         elif self.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS: | ||||||
|                 raise TokenError("invalid_grant") |             self.__post_init_client_credentials(request) | ||||||
|  |  | ||||||
|             try: |  | ||||||
|                 self.refresh_token = RefreshToken.objects.get( |  | ||||||
|                     refresh_token=raw_token, provider=self.provider |  | ||||||
|                 ) |  | ||||||
|                 if self.refresh_token.is_expired: |  | ||||||
|                     LOGGER.warning( |  | ||||||
|                         "Refresh token is expired", |  | ||||||
|                         token=raw_token, |  | ||||||
|                     ) |  | ||||||
|                     raise TokenError("invalid_grant") |  | ||||||
|                 # https://tools.ietf.org/html/rfc6749#section-6 |  | ||||||
|                 # Fallback to original token's scopes when none are given |  | ||||||
|                 if not self.scope: |  | ||||||
|                     self.scope = self.refresh_token.scope |  | ||||||
|             except RefreshToken.DoesNotExist: |  | ||||||
|                 LOGGER.warning( |  | ||||||
|                     "Refresh token does not exist", |  | ||||||
|                     token=raw_token, |  | ||||||
|                 ) |  | ||||||
|                 raise TokenError("invalid_grant") |  | ||||||
|             if self.refresh_token.revoked: |  | ||||||
|                 LOGGER.warning("Refresh token is revoked", token=raw_token) |  | ||||||
|                 Event.new( |  | ||||||
|                     action=EventAction.SUSPICIOUS_REQUEST, |  | ||||||
|                     message="Revoked refresh token was used", |  | ||||||
|                     token=raw_token, |  | ||||||
|                 ).from_http(request) |  | ||||||
|                 raise TokenError("invalid_grant") |  | ||||||
|         else: |         else: | ||||||
|             LOGGER.warning("Invalid grant type", grant_type=self.grant_type) |             LOGGER.warning("Invalid grant type", grant_type=self.grant_type) | ||||||
|             raise TokenError("unsupported_grant_type") |             raise TokenError("unsupported_grant_type") | ||||||
|  |  | ||||||
|     def __post_init_code(self, raw_code): |     def __post_init_code(self, raw_code: str): | ||||||
|         if not raw_code: |         if not raw_code: | ||||||
|             LOGGER.warning("Missing authorization code") |             LOGGER.warning("Missing authorization code") | ||||||
|             raise TokenError("invalid_grant") |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|         allowed_redirect_urls = self.provider.redirect_uris.split() |         allowed_redirect_urls = self.provider.redirect_uris.split() | ||||||
|         if len(allowed_redirect_urls) < 1: |         if self.provider.redirect_uris == "*": | ||||||
|             LOGGER.warning( |             LOGGER.warning( | ||||||
|                 "Provider has no allowed redirect_uri set, allowing all.", |                 "Provider has wildcard allowed redirect_uri set, allowing all.", | ||||||
|                 allow=self.redirect_uri.lower(), |                 redirect=self.redirect_uri, | ||||||
|             ) |             ) | ||||||
|         elif self.redirect_uri.lower() not in [x.lower() for x in allowed_redirect_urls]: |         # At this point, no provider should have a blank redirect_uri, in case they do | ||||||
|  |         # this will check an empty array and raise an error | ||||||
|  |         elif self.redirect_uri not in [x.lower() for x in allowed_redirect_urls]: | ||||||
|             LOGGER.warning( |             LOGGER.warning( | ||||||
|                 "Invalid redirect uri", |                 "Invalid redirect uri", | ||||||
|                 uri=self.redirect_uri, |                 redirect=self.redirect_uri, | ||||||
|                 expected=self.provider.redirect_uris.split(), |                 expected=self.provider.redirect_uris.split(), | ||||||
|             ) |             ) | ||||||
|             raise TokenError("invalid_client") |             raise TokenError("invalid_client") | ||||||
| @ -173,6 +152,77 @@ class TokenParams: | |||||||
|                 LOGGER.warning("Code challenge not matching") |                 LOGGER.warning("Code challenge not matching") | ||||||
|                 raise TokenError("invalid_grant") |                 raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |     def __post_init_refresh(self, raw_token: str, request: HttpRequest): | ||||||
|  |         if not raw_token: | ||||||
|  |             LOGGER.warning("Missing refresh token") | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             self.refresh_token = RefreshToken.objects.get( | ||||||
|  |                 refresh_token=raw_token, provider=self.provider | ||||||
|  |             ) | ||||||
|  |             if self.refresh_token.is_expired: | ||||||
|  |                 LOGGER.warning( | ||||||
|  |                     "Refresh token is expired", | ||||||
|  |                     token=raw_token, | ||||||
|  |                 ) | ||||||
|  |                 raise TokenError("invalid_grant") | ||||||
|  |             # https://tools.ietf.org/html/rfc6749#section-6 | ||||||
|  |             # Fallback to original token's scopes when none are given | ||||||
|  |             if not self.scope: | ||||||
|  |                 self.scope = self.refresh_token.scope | ||||||
|  |         except RefreshToken.DoesNotExist: | ||||||
|  |             LOGGER.warning( | ||||||
|  |                 "Refresh token does not exist", | ||||||
|  |                 token=raw_token, | ||||||
|  |             ) | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |         if self.refresh_token.revoked: | ||||||
|  |             LOGGER.warning("Refresh token is revoked", token=raw_token) | ||||||
|  |             Event.new( | ||||||
|  |                 action=EventAction.SUSPICIOUS_REQUEST, | ||||||
|  |                 message="Revoked refresh token was used", | ||||||
|  |                 token=raw_token, | ||||||
|  |             ).from_http(request) | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |     def __post_init_client_credentials(self, request: HttpRequest): | ||||||
|  |         # Authenticate user based on credentials | ||||||
|  |         username = request.POST.get("username") | ||||||
|  |         password = request.POST.get("password") | ||||||
|  |         user = User.objects.filter(username=username).first() | ||||||
|  |         if not user: | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |         token: Token = Token.filter_not_expired( | ||||||
|  |             key=password, intent=TokenIntents.INTENT_APP_PASSWORD | ||||||
|  |         ).first() | ||||||
|  |         if not token or token.user.uid != user.uid: | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |         self.user = user | ||||||
|  |         if not self.user.attributes.get(USER_ATTRIBUTE_SA, False): | ||||||
|  |             # Non-service accounts are not allowed | ||||||
|  |             LOGGER.info("Non-service-account tried to use client credentials", user=self.user) | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |         Event.new( | ||||||
|  |             action=EventAction.LOGIN, | ||||||
|  |             PLAN_CONTEXT_METHOD="token", | ||||||
|  |             PLAN_CONTEXT_METHOD_ARGS={ | ||||||
|  |                 "identifier": token.identifier, | ||||||
|  |             }, | ||||||
|  |         ).from_http(request, user=user) | ||||||
|  |  | ||||||
|  |         # Authorize user access | ||||||
|  |         app = Application.objects.filter(provider=self.provider).first() | ||||||
|  |         if not app or not app.provider: | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |         engine = PolicyEngine(app, self.user, request) | ||||||
|  |         engine.build() | ||||||
|  |         result = engine.result | ||||||
|  |         if not result.passing: | ||||||
|  |             LOGGER.info("User not authenticated for application", user=self.user, app=app) | ||||||
|  |             raise TokenError("invalid_grant") | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenView(View): | class TokenView(View): | ||||||
|     """Generate tokens for clients""" |     """Generate tokens for clients""" | ||||||
| @ -206,11 +256,14 @@ class TokenView(View): | |||||||
|             self.params = TokenParams.parse(request, self.provider, client_id, client_secret) |             self.params = TokenParams.parse(request, self.provider, client_id, client_secret) | ||||||
|  |  | ||||||
|             if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: |             if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: | ||||||
|                 LOGGER.info("Converting authorization code to refresh token") |                 LOGGER.debug("Converting authorization code to refresh token") | ||||||
|                 return TokenResponse(self.create_code_response()) |                 return TokenResponse(self.create_code_response()) | ||||||
|             if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: |             if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: | ||||||
|                 LOGGER.info("Refreshing refresh token") |                 LOGGER.debug("Refreshing refresh token") | ||||||
|                 return TokenResponse(self.create_refresh_response()) |                 return TokenResponse(self.create_refresh_response()) | ||||||
|  |             if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS: | ||||||
|  |                 LOGGER.debug("Client credentials grant") | ||||||
|  |                 return TokenResponse(self.create_client_credentials_response()) | ||||||
|             raise ValueError(f"Invalid grant_type: {self.params.grant_type}") |             raise ValueError(f"Invalid grant_type: {self.params.grant_type}") | ||||||
|         except TokenError as error: |         except TokenError as error: | ||||||
|             return TokenResponse(error.create_dict(), status=400) |             return TokenResponse(error.create_dict(), status=400) | ||||||
| @ -290,3 +343,30 @@ class TokenView(View): | |||||||
|             ), |             ), | ||||||
|             "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), |             "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |     def create_client_credentials_response(self) -> dict[str, Any]: | ||||||
|  |         """See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4""" | ||||||
|  |         provider: OAuth2Provider = self.params.provider | ||||||
|  |  | ||||||
|  |         refresh_token: RefreshToken = provider.create_refresh_token( | ||||||
|  |             user=self.params.user, | ||||||
|  |             scope=self.params.scope, | ||||||
|  |             request=self.request, | ||||||
|  |         ) | ||||||
|  |         refresh_token.id_token = refresh_token.create_id_token( | ||||||
|  |             user=self.params.user, | ||||||
|  |             request=self.request, | ||||||
|  |         ) | ||||||
|  |         refresh_token.id_token.at_hash = refresh_token.at_hash | ||||||
|  |  | ||||||
|  |         # Store the refresh_token. | ||||||
|  |         refresh_token.save() | ||||||
|  |  | ||||||
|  |         return { | ||||||
|  |             "access_token": refresh_token.access_token, | ||||||
|  |             "token_type": "bearer", | ||||||
|  |             "expires_in": int( | ||||||
|  |                 timedelta_from_string(refresh_token.provider.token_validity).total_seconds() | ||||||
|  |             ), | ||||||
|  |             "id_token": self.params.provider.encode(refresh_token.id_token.to_dict()), | ||||||
|  |         } | ||||||
|  | |||||||
| @ -12,4 +12,8 @@ class AuthentikProviderProxyConfig(AppConfig): | |||||||
|     verbose_name = "authentik Providers.Proxy" |     verbose_name = "authentik Providers.Proxy" | ||||||
|  |  | ||||||
|     def ready(self) -> None: |     def ready(self) -> None: | ||||||
|  |         from authentik.providers.proxy.tasks import proxy_set_defaults | ||||||
|  |  | ||||||
|         import_module("authentik.providers.proxy.managed") |         import_module("authentik.providers.proxy.managed") | ||||||
|  |  | ||||||
|  |         proxy_set_defaults.delay() | ||||||
|  | |||||||
| @ -23,15 +23,17 @@ class ProxyDockerController(DockerController): | |||||||
|             proxy_provider: ProxyProvider |             proxy_provider: ProxyProvider | ||||||
|             external_host_name = urlparse(proxy_provider.external_host) |             external_host_name = urlparse(proxy_provider.external_host) | ||||||
|             hosts.append(f"`{external_host_name.netloc}`") |             hosts.append(f"`{external_host_name.netloc}`") | ||||||
|         traefik_name = f"ak-outpost-{self.outpost.pk.hex}" |         traefik_name = self.name | ||||||
|         labels = super()._get_labels() |         labels = super()._get_labels() | ||||||
|         labels["traefik.enable"] = "true" |         labels["traefik.enable"] = "true" | ||||||
|         labels[f"traefik.http.routers.{traefik_name}-router.rule"] = f"Host({','.join(hosts)})" |         labels[ | ||||||
|  |             f"traefik.http.routers.{traefik_name}-router.rule" | ||||||
|  |         ] = f"Host({','.join(hosts)}) && PathPrefix(`/outpost.goauthentik.io`)" | ||||||
|         labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true" |         labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true" | ||||||
|         labels[f"traefik.http.routers.{traefik_name}-router.service"] = f"{traefik_name}-service" |         labels[f"traefik.http.routers.{traefik_name}-router.service"] = f"{traefik_name}-service" | ||||||
|         labels[ |         labels[ | ||||||
|             f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path" |             f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path" | ||||||
|         ] = "/akprox/ping" |         ] = "/outpost.goauthentik.io/ping" | ||||||
|         labels[ |         labels[ | ||||||
|             f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.port" |             f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.port" | ||||||
|         ] = "9300" |         ] = "9300" | ||||||
|  | |||||||
| @ -92,6 +92,8 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]): | |||||||
|             # Buffer sizes for large headers with JWTs |             # Buffer sizes for large headers with JWTs | ||||||
|             "nginx.ingress.kubernetes.io/proxy-buffers-number": "4", |             "nginx.ingress.kubernetes.io/proxy-buffers-number": "4", | ||||||
|             "nginx.ingress.kubernetes.io/proxy-buffer-size": "16k", |             "nginx.ingress.kubernetes.io/proxy-buffer-size": "16k", | ||||||
|  |             # Enable TLS in traefik | ||||||
|  |             "traefik.ingress.kubernetes.io/router.tls": "true", | ||||||
|         } |         } | ||||||
|         annotations.update(self.controller.outpost.config.kubernetes_ingress_annotations) |         annotations.update(self.controller.outpost.config.kubernetes_ingress_annotations) | ||||||
|         return annotations |         return annotations | ||||||
| @ -126,7 +128,7 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]): | |||||||
|                                         port=V1ServiceBackendPort(name="http"), |                                         port=V1ServiceBackendPort(name="http"), | ||||||
|                                     ), |                                     ), | ||||||
|                                 ), |                                 ), | ||||||
|                                 path="/akprox", |                                 path="/outpost.goauthentik.io", | ||||||
|                                 path_type="ImplementationSpecific", |                                 path_type="ImplementationSpecific", | ||||||
|                             ) |                             ) | ||||||
|                         ] |                         ] | ||||||
|  | |||||||
| @ -119,15 +119,11 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]) | |||||||
|             ), |             ), | ||||||
|             spec=TraefikMiddlewareSpec( |             spec=TraefikMiddlewareSpec( | ||||||
|                 forwardAuth=TraefikMiddlewareSpecForwardAuth( |                 forwardAuth=TraefikMiddlewareSpecForwardAuth( | ||||||
|                     address=f"http://{self.name}.{self.namespace}:9000/akprox/auth/traefik", |                     address=( | ||||||
|  |                         f"http://{self.name}.{self.namespace}:9000/" | ||||||
|  |                         "outpost.goauthentik.io/auth/traefik" | ||||||
|  |                     ), | ||||||
|                     authResponseHeaders=[ |                     authResponseHeaders=[ | ||||||
|                         # Legacy headers, remove after 2022.1 |  | ||||||
|                         "X-Auth-Username", |  | ||||||
|                         "X-Auth-Groups", |  | ||||||
|                         "X-Forwarded-Email", |  | ||||||
|                         "X-Forwarded-Preferred-Username", |  | ||||||
|                         "X-Forwarded-User", |  | ||||||
|                         # New headers, unique prefix |  | ||||||
|                         "X-authentik-username", |                         "X-authentik-username", | ||||||
|                         "X-authentik-groups", |                         "X-authentik-groups", | ||||||
|                         "X-authentik-email", |                         "X-authentik-email", | ||||||
|  | |||||||
| @ -27,7 +27,7 @@ def get_cookie_secret(): | |||||||
|  |  | ||||||
|  |  | ||||||
| def _get_callback_url(uri: str) -> str: | def _get_callback_url(uri: str) -> str: | ||||||
|     return urljoin(uri, "/akprox/callback") |     return urljoin(uri, "outpost.goauthentik.io/callback") | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProxyMode(models.TextChoices): | class ProxyMode(models.TextChoices): | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								authentik/providers/proxy/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								authentik/providers/proxy/tasks.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | |||||||
|  | """proxy provider tasks""" | ||||||
|  | from authentik.providers.proxy.models import ProxyProvider | ||||||
|  | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @CELERY_APP.task() | ||||||
|  | def proxy_set_defaults(): | ||||||
|  |     """Ensure correct defaults are set for all providers""" | ||||||
|  |     for provider in ProxyProvider.objects.all(): | ||||||
|  |         provider.set_oauth_defaults() | ||||||
|  |         provider.save() | ||||||
| @ -15,6 +15,7 @@ from authentik.providers.saml.processors.request_parser import AuthNRequestParse | |||||||
| from authentik.sources.saml.exceptions import MismatchedRequestID | from authentik.sources.saml.exceptions import MismatchedRequestID | ||||||
| from authentik.sources.saml.models import SAMLSource | from authentik.sources.saml.models import SAMLSource | ||||||
| from authentik.sources.saml.processors.constants import ( | from authentik.sources.saml.processors.constants import ( | ||||||
|  |     SAML_BINDING_REDIRECT, | ||||||
|     SAML_NAME_ID_FORMAT_EMAIL, |     SAML_NAME_ID_FORMAT_EMAIL, | ||||||
|     SAML_NAME_ID_FORMAT_UNSPECIFIED, |     SAML_NAME_ID_FORMAT_UNSPECIFIED, | ||||||
| ) | ) | ||||||
| @ -98,6 +99,9 @@ class TestAuthNRequest(TestCase): | |||||||
|  |  | ||||||
|         # First create an AuthNRequest |         # First create an AuthNRequest | ||||||
|         request_proc = RequestProcessor(self.source, http_request, "test_state") |         request_proc = RequestProcessor(self.source, http_request, "test_state") | ||||||
|  |         auth_n = request_proc.get_auth_n() | ||||||
|  |         self.assertEqual(auth_n.attrib["ProtocolBinding"], SAML_BINDING_REDIRECT) | ||||||
|  |  | ||||||
|         request = request_proc.build_auth_n() |         request = request_proc.build_auth_n() | ||||||
|         # Now we check the ID and signature |         # Now we check the ID and signature | ||||||
|         parsed_request = AuthNRequestParser(self.provider).parse( |         parsed_request = AuthNRequestParser(self.provider).parse( | ||||||
|  | |||||||
| @ -1,37 +1,17 @@ | |||||||
| """Metrics view""" | """Metrics view""" | ||||||
| from base64 import b64encode | from base64 import b64encode | ||||||
| from typing import Callable |  | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db import connections | from django.db import connections | ||||||
| from django.db.utils import OperationalError | from django.db.utils import OperationalError | ||||||
|  | from django.dispatch import Signal | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.views import View | from django.views import View | ||||||
| from django_prometheus.exports import ExportToDjangoView | from django_prometheus.exports import ExportToDjangoView | ||||||
| from django_redis import get_redis_connection | from django_redis import get_redis_connection | ||||||
| from prometheus_client import Gauge |  | ||||||
| from redis.exceptions import RedisError | from redis.exceptions import RedisError | ||||||
|  |  | ||||||
| from authentik.admin.api.workers import GAUGE_WORKERS | monitoring_set = Signal() | ||||||
| from authentik.events.monitored_tasks import TaskInfo |  | ||||||
| from authentik.root.celery import CELERY_APP |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UpdatingGauge(Gauge): |  | ||||||
|     """Gauge which fetches its own value from an update function. |  | ||||||
|  |  | ||||||
|     Update function is called on instantiate""" |  | ||||||
|  |  | ||||||
|     def __init__(self, *args, update_func: Callable, **kwargs): |  | ||||||
|         super().__init__(*args, **kwargs) |  | ||||||
|         self._update_func = update_func |  | ||||||
|         self.update() |  | ||||||
|  |  | ||||||
|     def update(self): |  | ||||||
|         """Set value from update function""" |  | ||||||
|         val = self._update_func() |  | ||||||
|         if val: |  | ||||||
|             self.set(val) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MetricsView(View): | class MetricsView(View): | ||||||
| @ -49,11 +29,7 @@ class MetricsView(View): | |||||||
|             response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"' |             response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"' | ||||||
|             return response |             return response | ||||||
|  |  | ||||||
|         count = len(CELERY_APP.control.ping(timeout=0.5)) |         monitoring_set.send_robust(self) | ||||||
|         GAUGE_WORKERS.set(count) |  | ||||||
|  |  | ||||||
|         for task in TaskInfo.all().values(): |  | ||||||
|             task.set_prom_metrics() |  | ||||||
|  |  | ||||||
|         return ExportToDjangoView(request) |         return ExportToDjangoView(request) | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,14 +1,4 @@ | |||||||
| """ | """root settings for authentik""" | ||||||
| Django settings for authentik project. |  | ||||||
|  |  | ||||||
| Generated by 'django-admin startproject' using Django 2.1.3. |  | ||||||
|  |  | ||||||
| For more information on this file, see |  | ||||||
| https://docs.djangoproject.com/en/2.1/topics/settings/ |  | ||||||
|  |  | ||||||
| For the full list of settings and their values, see |  | ||||||
| https://docs.djangoproject.com/en/2.1/ref/settings/ |  | ||||||
| """ |  | ||||||
|  |  | ||||||
| import importlib | import importlib | ||||||
| import logging | import logging | ||||||
| @ -16,26 +6,23 @@ import os | |||||||
| import sys | import sys | ||||||
| from hashlib import sha512 | from hashlib import sha512 | ||||||
| from json import dumps | from json import dumps | ||||||
| from tempfile import gettempdir |  | ||||||
| from time import time | from time import time | ||||||
| from urllib.parse import quote | from urllib.parse import quote_plus | ||||||
|  |  | ||||||
| import structlog | import structlog | ||||||
| from celery.schedules import crontab | from celery.schedules import crontab | ||||||
| from sentry_sdk import init as sentry_init | from sentry_sdk import init as sentry_init | ||||||
| from sentry_sdk.api import set_tag | from sentry_sdk.api import set_tag | ||||||
| from sentry_sdk.integrations.boto3 import Boto3Integration |  | ||||||
| from sentry_sdk.integrations.celery import CeleryIntegration | from sentry_sdk.integrations.celery import CeleryIntegration | ||||||
| from sentry_sdk.integrations.django import DjangoIntegration | from sentry_sdk.integrations.django import DjangoIntegration | ||||||
| from sentry_sdk.integrations.redis import RedisIntegration | from sentry_sdk.integrations.redis import RedisIntegration | ||||||
| from sentry_sdk.integrations.threading import ThreadingIntegration | from sentry_sdk.integrations.threading import ThreadingIntegration | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import ENV_GIT_HASH_KEY, __version__, get_build_hash | ||||||
| from authentik.core.middleware import structlog_add_request_id | from authentik.core.middleware import structlog_add_request_id | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.logging import add_process_id | from authentik.lib.logging import add_process_id | ||||||
| from authentik.lib.sentry import before_send | from authentik.lib.sentry import before_send | ||||||
| from authentik.lib.utils.http import get_http_session |  | ||||||
| from authentik.lib.utils.reflection import get_env | from authentik.lib.utils.reflection import get_env | ||||||
| from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP | from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP | ||||||
|  |  | ||||||
| @ -75,6 +62,7 @@ AUTH_USER_MODEL = "authentik_core.User" | |||||||
|  |  | ||||||
| _cookie_suffix = "_debug" if DEBUG else "" | _cookie_suffix = "_debug" if DEBUG else "" | ||||||
| CSRF_COOKIE_NAME = "authentik_csrf" | CSRF_COOKIE_NAME = "authentik_csrf" | ||||||
|  | CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF" | ||||||
| LANGUAGE_COOKIE_NAME = f"authentik_language{_cookie_suffix}" | LANGUAGE_COOKIE_NAME = f"authentik_language{_cookie_suffix}" | ||||||
| SESSION_COOKIE_NAME = f"authentik_session{_cookie_suffix}" | SESSION_COOKIE_NAME = f"authentik_session{_cookie_suffix}" | ||||||
| SESSION_COOKIE_DOMAIN = CONFIG.y("cookie_domain", None) | SESSION_COOKIE_DOMAIN = CONFIG.y("cookie_domain", None) | ||||||
| @ -148,7 +136,6 @@ INSTALLED_APPS = [ | |||||||
|     "guardian", |     "guardian", | ||||||
|     "django_prometheus", |     "django_prometheus", | ||||||
|     "channels", |     "channels", | ||||||
|     "dbbackup", |  | ||||||
| ] | ] | ||||||
|  |  | ||||||
| GUARDIAN_MONKEY_PATCH = False | GUARDIAN_MONKEY_PATCH = False | ||||||
| @ -164,9 +151,6 @@ SPECTACULAR_SETTINGS = { | |||||||
|         { |         { | ||||||
|             "url": "/api/v3/", |             "url": "/api/v3/", | ||||||
|         }, |         }, | ||||||
|         { |  | ||||||
|             "url": "/api/v2beta/", |  | ||||||
|         }, |  | ||||||
|     ], |     ], | ||||||
|     "CONTACT": { |     "CONTACT": { | ||||||
|         "email": "hello@beryju.org", |         "email": "hello@beryju.org", | ||||||
| @ -222,7 +206,7 @@ if CONFIG.y_bool("redis.tls", False): | |||||||
|     REDIS_CELERY_TLS_REQUIREMENTS = f"?ssl_cert_reqs={CONFIG.y('redis.tls_reqs')}" |     REDIS_CELERY_TLS_REQUIREMENTS = f"?ssl_cert_reqs={CONFIG.y('redis.tls_reqs')}" | ||||||
| _redis_url = ( | _redis_url = ( | ||||||
|     f"{REDIS_PROTOCOL_PREFIX}:" |     f"{REDIS_PROTOCOL_PREFIX}:" | ||||||
|     f"{quote(CONFIG.y('redis.password'))}@{quote(CONFIG.y('redis.host'))}:" |     f"{quote_plus(CONFIG.y('redis.password'))}@{quote_plus(CONFIG.y('redis.host'))}:" | ||||||
|     f"{int(CONFIG.y('redis.port'))}" |     f"{int(CONFIG.y('redis.port'))}" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @ -349,6 +333,7 @@ LOCALE_PATHS = ["./locale"] | |||||||
| # Celery settings | # Celery settings | ||||||
| # Add a 10 minute timeout to all Celery tasks. | # Add a 10 minute timeout to all Celery tasks. | ||||||
| CELERY_TASK_SOFT_TIME_LIMIT = 600 | CELERY_TASK_SOFT_TIME_LIMIT = 600 | ||||||
|  | CELERY_WORKER_MAX_TASKS_PER_CHILD = 50 | ||||||
| CELERY_BEAT_SCHEDULE = { | CELERY_BEAT_SCHEDULE = { | ||||||
|     "clean_expired_models": { |     "clean_expired_models": { | ||||||
|         "task": "authentik.core.tasks.clean_expired_models", |         "task": "authentik.core.tasks.clean_expired_models", | ||||||
| @ -357,7 +342,7 @@ CELERY_BEAT_SCHEDULE = { | |||||||
|     }, |     }, | ||||||
|     "db_backup": { |     "db_backup": { | ||||||
|         "task": "authentik.core.tasks.backup_database", |         "task": "authentik.core.tasks.backup_database", | ||||||
|         "schedule": crontab(hour="*/24"), |         "schedule": crontab(hour="*/24", minute=0), | ||||||
|         "options": {"queue": "authentik_scheduled"}, |         "options": {"queue": "authentik_scheduled"}, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
| @ -370,38 +355,8 @@ CELERY_RESULT_BACKEND = ( | |||||||
|     f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}" |     f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| # Database backup |  | ||||||
| DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage" |  | ||||||
| DBBACKUP_STORAGE_OPTIONS = {"location": "./backups" if DEBUG else "/backups"} |  | ||||||
| DBBACKUP_FILENAME_TEMPLATE = f"authentik-backup-{__version__}-{{datetime}}.sql" |  | ||||||
| DBBACKUP_CONNECTOR_MAPPING = { |  | ||||||
|     "django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector", |  | ||||||
| } |  | ||||||
| DBBACKUP_TMP_DIR = gettempdir() if DEBUG else "/tmp"  # nosec |  | ||||||
| DBBACKUP_CLEANUP_KEEP = 30 |  | ||||||
| if CONFIG.y("postgresql.s3_backup.bucket", "") != "": |  | ||||||
|     DBBACKUP_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" |  | ||||||
|     DBBACKUP_STORAGE_OPTIONS = { |  | ||||||
|         "access_key": CONFIG.y("postgresql.s3_backup.access_key"), |  | ||||||
|         "secret_key": CONFIG.y("postgresql.s3_backup.secret_key"), |  | ||||||
|         "bucket_name": CONFIG.y("postgresql.s3_backup.bucket"), |  | ||||||
|         "region_name": CONFIG.y("postgresql.s3_backup.region", "eu-central-1"), |  | ||||||
|         "default_acl": "private", |  | ||||||
|         "endpoint_url": CONFIG.y("postgresql.s3_backup.host"), |  | ||||||
|         "location": CONFIG.y("postgresql.s3_backup.location", ""), |  | ||||||
|         "verify": not CONFIG.y_bool("postgresql.s3_backup.insecure_skip_verify", False), |  | ||||||
|     } |  | ||||||
|     j_print( |  | ||||||
|         "Database backup to S3 is configured", |  | ||||||
|         host=CONFIG.y("postgresql.s3_backup.host"), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
| # Sentry integration | # Sentry integration | ||||||
| SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8" | SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8" | ||||||
| # Default to empty string as that is what docker has |  | ||||||
| build_hash = os.environ.get(ENV_GIT_HASH_KEY, "") |  | ||||||
| if build_hash == "": |  | ||||||
|     build_hash = "tagged" |  | ||||||
|  |  | ||||||
| env = get_env() | env = get_env() | ||||||
| _ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False) | _ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False) | ||||||
| @ -413,7 +368,6 @@ if _ERROR_REPORTING: | |||||||
|             DjangoIntegration(transaction_style="function_name"), |             DjangoIntegration(transaction_style="function_name"), | ||||||
|             CeleryIntegration(), |             CeleryIntegration(), | ||||||
|             RedisIntegration(), |             RedisIntegration(), | ||||||
|             Boto3Integration(), |  | ||||||
|             ThreadingIntegration(propagate_hub=True), |             ThreadingIntegration(propagate_hub=True), | ||||||
|         ], |         ], | ||||||
|         before_send=before_send, |         before_send=before_send, | ||||||
| @ -422,35 +376,14 @@ if _ERROR_REPORTING: | |||||||
|         environment=CONFIG.y("error_reporting.environment", "customer"), |         environment=CONFIG.y("error_reporting.environment", "customer"), | ||||||
|         send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False), |         send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False), | ||||||
|     ) |     ) | ||||||
|     set_tag("authentik.build_hash", build_hash) |     set_tag("authentik.build_hash", get_build_hash("tagged")) | ||||||
|     set_tag("authentik.env", env) |     set_tag("authentik.env", env) | ||||||
|     set_tag("authentik.component", "backend") |     set_tag("authentik.component", "backend") | ||||||
|     set_tag("authentik.uuid", sha512(SECRET_KEY.encode("ascii")).hexdigest()[:16]) |     set_tag("authentik.uuid", sha512(str(SECRET_KEY).encode("ascii")).hexdigest()[:16]) | ||||||
|     j_print( |     j_print( | ||||||
|         "Error reporting is enabled", |         "Error reporting is enabled", | ||||||
|         env=CONFIG.y("error_reporting.environment", "customer"), |         env=CONFIG.y("error_reporting.environment", "customer"), | ||||||
|     ) |     ) | ||||||
| if not CONFIG.y_bool("disable_startup_analytics", False): |  | ||||||
|     should_send = env not in ["dev", "ci"] |  | ||||||
|     if should_send: |  | ||||||
|         try: |  | ||||||
|             get_http_session().post( |  | ||||||
|                 "https://goauthentik.io/api/event", |  | ||||||
|                 json={ |  | ||||||
|                     "domain": "authentik", |  | ||||||
|                     "name": "pageview", |  | ||||||
|                     "referrer": f"{__version__} ({build_hash})", |  | ||||||
|                     "url": f"http://localhost/{env}?utm_source={__version__}&utm_medium={env}", |  | ||||||
|                 }, |  | ||||||
|                 headers={ |  | ||||||
|                     "User-Agent": sha512(SECRET_KEY.encode("ascii")).hexdigest()[:16], |  | ||||||
|                     "Content-Type": "application/json", |  | ||||||
|                 }, |  | ||||||
|                 timeout=5, |  | ||||||
|             ) |  | ||||||
|         # pylint: disable=bare-except |  | ||||||
|         except:  # nosec |  | ||||||
|             pass |  | ||||||
|  |  | ||||||
| # Static files (CSS, JavaScript, Images) | # Static files (CSS, JavaScript, Images) | ||||||
| # https://docs.djangoproject.com/en/2.1/howto/static-files/ | # https://docs.djangoproject.com/en/2.1/howto/static-files/ | ||||||
| @ -532,12 +465,9 @@ _LOGGING_HANDLER_MAP = { | |||||||
|     "urllib3": "WARNING", |     "urllib3": "WARNING", | ||||||
|     "websockets": "WARNING", |     "websockets": "WARNING", | ||||||
|     "daphne": "WARNING", |     "daphne": "WARNING", | ||||||
|     "dbbackup": "ERROR", |  | ||||||
|     "kubernetes": "INFO", |     "kubernetes": "INFO", | ||||||
|     "asyncio": "WARNING", |     "asyncio": "WARNING", | ||||||
|     "aioredis": "WARNING", |     "aioredis": "WARNING", | ||||||
|     "s3transfer": "WARNING", |  | ||||||
|     "botocore": "WARNING", |  | ||||||
| } | } | ||||||
| for handler_name, level in _LOGGING_HANDLER_MAP.items(): | for handler_name, level in _LOGGING_HANDLER_MAP.items(): | ||||||
|     # pyright: reportGeneralTypeIssues=false |     # pyright: reportGeneralTypeIssues=false | ||||||
|  | |||||||
| @ -35,21 +35,21 @@ class LDAPProviderManager(ObjectManager): | |||||||
|                 "goauthentik.io/sources/ldap/ms-userprincipalname", |                 "goauthentik.io/sources/ldap/ms-userprincipalname", | ||||||
|                 name="authentik default Active Directory Mapping: userPrincipalName", |                 name="authentik default Active Directory Mapping: userPrincipalName", | ||||||
|                 object_field="attributes.upn", |                 object_field="attributes.upn", | ||||||
|                 expression="return ldap.get('userPrincipalName')", |                 expression="return list_flatten(ldap.get('userPrincipalName'))", | ||||||
|             ), |             ), | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
|                 LDAPPropertyMapping, |                 LDAPPropertyMapping, | ||||||
|                 "goauthentik.io/sources/ldap/ms-givenName", |                 "goauthentik.io/sources/ldap/ms-givenName", | ||||||
|                 name="authentik default Active Directory Mapping: givenName", |                 name="authentik default Active Directory Mapping: givenName", | ||||||
|                 object_field="attributes.givenName", |                 object_field="attributes.givenName", | ||||||
|                 expression="return ldap.get('givenName')", |                 expression="return list_flatten(ldap.get('givenName'))", | ||||||
|             ), |             ), | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
|                 LDAPPropertyMapping, |                 LDAPPropertyMapping, | ||||||
|                 "goauthentik.io/sources/ldap/ms-sn", |                 "goauthentik.io/sources/ldap/ms-sn", | ||||||
|                 name="authentik default Active Directory Mapping: sn", |                 name="authentik default Active Directory Mapping: sn", | ||||||
|                 object_field="attributes.sn", |                 object_field="attributes.sn", | ||||||
|                 expression="return ldap.get('sn')", |                 expression="return list_flatten(ldap.get('sn'))", | ||||||
|             ), |             ), | ||||||
|             # OpenLDAP specific mappings |             # OpenLDAP specific mappings | ||||||
|             EnsureExists( |             EnsureExists( | ||||||
|  | |||||||
| @ -1,13 +1,13 @@ | |||||||
| """Sync LDAP Users and groups into authentik""" | """Sync LDAP Users and groups into authentik""" | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from deepmerge import always_merger |  | ||||||
| from django.db.models.base import Model | from django.db.models.base import Model | ||||||
| from django.db.models.query import QuerySet | from django.db.models.query import QuerySet | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
|  |  | ||||||
| from authentik.core.exceptions import PropertyMappingExpressionException | from authentik.core.exceptions import PropertyMappingExpressionException | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  | from authentik.lib.merge import MERGE_LIST_UNIQUE | ||||||
| from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME | from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME | ||||||
| from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource | from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource | ||||||
|  |  | ||||||
| @ -123,8 +123,8 @@ class BaseLDAPSynchronizer: | |||||||
|                 continue |                 continue | ||||||
|             setattr(instance, key, value) |             setattr(instance, key, value) | ||||||
|         final_atttributes = {} |         final_atttributes = {} | ||||||
|         always_merger.merge(final_atttributes, instance.attributes) |         MERGE_LIST_UNIQUE.merge(final_atttributes, instance.attributes) | ||||||
|         always_merger.merge(final_atttributes, data.get("attributes", {})) |         MERGE_LIST_UNIQUE.merge(final_atttributes, data.get("attributes", {})) | ||||||
|         instance.attributes = final_atttributes |         instance.attributes = final_atttributes | ||||||
|         instance.save() |         instance.save() | ||||||
|         return (instance, False) |         return (instance, False) | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|             uniq = self._flatten(attributes[self._source.object_uniqueness_field]) |             uniq = self._flatten(attributes[self._source.object_uniqueness_field]) | ||||||
|             try: |             try: | ||||||
|                 defaults = self.build_group_properties(group_dn, **attributes) |                 defaults = self.build_group_properties(group_dn, **attributes) | ||||||
|  |                 defaults["parent"] = self._source.sync_parent_group | ||||||
|                 self._logger.debug("Creating group with attributes", **defaults) |                 self._logger.debug("Creating group with attributes", **defaults) | ||||||
|                 if "name" not in defaults: |                 if "name" not in defaults: | ||||||
|                     raise IntegrityError("Name was not set by propertymappings") |                     raise IntegrityError("Name was not set by propertymappings") | ||||||
| @ -47,7 +48,6 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): | |||||||
|                     Group, |                     Group, | ||||||
|                     { |                     { | ||||||
|                         f"attributes__{LDAP_UNIQUENESS}": uniq, |                         f"attributes__{LDAP_UNIQUENESS}": uniq, | ||||||
|                         "parent": self._source.sync_parent_group, |  | ||||||
|                     }, |                     }, | ||||||
|                     defaults, |                     defaults, | ||||||
|                 ) |                 ) | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from ldap3.core.exceptions import LDAPException | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus | ||||||
|  | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.reflection import class_to_path, path_to_class | from authentik.lib.utils.reflection import class_to_path, path_to_class | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
| from authentik.sources.ldap.models import LDAPSource | from authentik.sources.ldap.models import LDAPSource | ||||||
| @ -52,5 +53,5 @@ def ldap_sync(self: MonitoredTask, source_pk: str, sync_class: str): | |||||||
|         ) |         ) | ||||||
|     except LDAPException as exc: |     except LDAPException as exc: | ||||||
|         # No explicit event is created here as .set_status with an error will do that |         # No explicit event is created here as .set_status with an error will do that | ||||||
|         LOGGER.debug(exc) |         LOGGER.warning(exception_to_string(exc)) | ||||||
|         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) |         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ from django.db.models import Q | |||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.generators import generate_key | from authentik.lib.generators import generate_key | ||||||
| from authentik.managed.manager import ObjectManager | from authentik.managed.manager import ObjectManager | ||||||
| @ -24,7 +25,7 @@ class LDAPSyncTests(TestCase): | |||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         ObjectManager().run() |         ObjectManager().run() | ||||||
|         self.source = LDAPSource.objects.create( |         self.source: LDAPSource = LDAPSource.objects.create( | ||||||
|             name="ldap", |             name="ldap", | ||||||
|             slug="ldap", |             slug="ldap", | ||||||
|             base_dn="dc=goauthentik,dc=io", |             base_dn="dc=goauthentik,dc=io", | ||||||
| @ -120,6 +121,9 @@ class LDAPSyncTests(TestCase): | |||||||
|         self.source.property_mappings_group.set( |         self.source.property_mappings_group.set( | ||||||
|             LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name") |             LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name") | ||||||
|         ) |         ) | ||||||
|  |         _user = create_test_admin_user() | ||||||
|  |         parent_group = Group.objects.get(name=_user.username) | ||||||
|  |         self.source.sync_parent_group = parent_group | ||||||
|         connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) |         connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) | ||||||
|         with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): |         with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): | ||||||
|             self.source.save() |             self.source.save() | ||||||
| @ -127,8 +131,9 @@ class LDAPSyncTests(TestCase): | |||||||
|             group_sync.sync() |             group_sync.sync() | ||||||
|             membership_sync = MembershipLDAPSynchronizer(self.source) |             membership_sync = MembershipLDAPSynchronizer(self.source) | ||||||
|             membership_sync.sync() |             membership_sync.sync() | ||||||
|             group = Group.objects.filter(name="test-group") |             group: Group = Group.objects.filter(name="test-group").first() | ||||||
|             self.assertTrue(group.exists()) |             self.assertIsNotNone(group) | ||||||
|  |             self.assertEqual(group.parent, parent_group) | ||||||
|  |  | ||||||
|     def test_sync_groups_openldap(self): |     def test_sync_groups_openldap(self): | ||||||
|         """Test group sync""" |         """Test group sync""" | ||||||
|  | |||||||
| @ -17,6 +17,7 @@ AUTHENTIK_SOURCES_OAUTH_TYPES = [ | |||||||
|     "authentik.sources.oauth.types.okta", |     "authentik.sources.oauth.types.okta", | ||||||
|     "authentik.sources.oauth.types.reddit", |     "authentik.sources.oauth.types.reddit", | ||||||
|     "authentik.sources.oauth.types.twitter", |     "authentik.sources.oauth.types.twitter", | ||||||
|  |     "authentik.sources.oauth.types.mailcow", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -44,7 +44,7 @@ class BaseOAuthClient: | |||||||
|             response = self.do_request("get", profile_url, token=token) |             response = self.do_request("get", profile_url, token=token) | ||||||
|             response.raise_for_status() |             response.raise_for_status() | ||||||
|         except RequestException as exc: |         except RequestException as exc: | ||||||
|             LOGGER.warning("Unable to fetch user profile", exc=exc) |             LOGGER.warning("Unable to fetch user profile", exc=exc, body=response.text) | ||||||
|             return None |             return None | ||||||
|         else: |         else: | ||||||
|             return response.json() |             return response.json() | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ def update_empty_urls(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|  |  | ||||||
|     for source in OAuthSource.objects.using(db_alias).all(): |     for source in OAuthSource.objects.using(db_alias).all(): | ||||||
|         changed = False |         changed = False | ||||||
|         if source.access_token_url == "": |         if source.access_token_url == "":  # nosec | ||||||
|             source.access_token_url = None |             source.access_token_url = None | ||||||
|             changed = True |             changed = True | ||||||
|         if source.authorization_url == "": |         if source.authorization_url == "": | ||||||
| @ -20,7 +20,7 @@ def update_empty_urls(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|         if source.profile_url == "": |         if source.profile_url == "": | ||||||
|             source.profile_url = None |             source.profile_url = None | ||||||
|             changed = True |             changed = True | ||||||
|         if source.request_token_url == "": |         if source.request_token_url == "":  # nosec | ||||||
|             source.request_token_url = None |             source.request_token_url = None | ||||||
|             changed = True |             changed = True | ||||||
|  |  | ||||||
|  | |||||||
| @ -111,6 +111,16 @@ class GitHubOAuthSource(OAuthSource): | |||||||
|         verbose_name_plural = _("GitHub OAuth Sources") |         verbose_name_plural = _("GitHub OAuth Sources") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MailcowOAuthSource(OAuthSource): | ||||||
|  |     """Social Login using Mailcow.""" | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         abstract = True | ||||||
|  |         verbose_name = _("Mailcow OAuth Source") | ||||||
|  |         verbose_name_plural = _("Mailcow OAuth Sources") | ||||||
|  |  | ||||||
|  |  | ||||||
| class TwitterOAuthSource(OAuthSource): | class TwitterOAuthSource(OAuthSource): | ||||||
|     """Social Login using Twitter.com""" |     """Social Login using Twitter.com""" | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										38
									
								
								authentik/sources/oauth/tests/test_type_mailcow.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								authentik/sources/oauth/tests/test_type_mailcow.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | |||||||
|  | """Mailcow Type tests""" | ||||||
|  | from django.test import TestCase | ||||||
|  |  | ||||||
|  | from authentik.sources.oauth.models import OAuthSource | ||||||
|  | from authentik.sources.oauth.types.mailcow import MailcowOAuth2Callback | ||||||
|  |  | ||||||
|  | # https://community.mailcow.email/d/13-mailcow-oauth-json-format/2 | ||||||
|  | MAILCOW_USER = { | ||||||
|  |     "success": True, | ||||||
|  |     "username": "email@example.com", | ||||||
|  |     "identifier": "email@example.com", | ||||||
|  |     "email": "email@example.com", | ||||||
|  |     "full_name": "Example User", | ||||||
|  |     "displayName": "Example User", | ||||||
|  |     "created": "2020-05-15 11:33:08", | ||||||
|  |     "modified": "2020-05-15 12:23:31", | ||||||
|  |     "active": 1, | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestTypeMailcow(TestCase): | ||||||
|  |     """OAuth Source tests""" | ||||||
|  |  | ||||||
|  |     def setUp(self): | ||||||
|  |         self.source = OAuthSource.objects.create( | ||||||
|  |             name="test", | ||||||
|  |             slug="test", | ||||||
|  |             provider_type="mailcow", | ||||||
|  |             authorization_url="", | ||||||
|  |             profile_url="", | ||||||
|  |             consumer_key="", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_enroll_context(self): | ||||||
|  |         """Test mailcow Enrollment context""" | ||||||
|  |         ak_context = MailcowOAuth2Callback().get_user_enroll_context(MAILCOW_USER) | ||||||
|  |         self.assertEqual(ak_context["email"], MAILCOW_USER["email"]) | ||||||
|  |         self.assertEqual(ak_context["name"], MAILCOW_USER["full_name"]) | ||||||
| @ -37,7 +37,7 @@ class AzureADClient(OAuth2Client): | |||||||
|             ) |             ) | ||||||
|             response.raise_for_status() |             response.raise_for_status() | ||||||
|         except RequestException as exc: |         except RequestException as exc: | ||||||
|             LOGGER.warning("Unable to fetch user profile", exc=exc) |             LOGGER.warning("Unable to fetch user profile", exc=exc, body=response.text) | ||||||
|             return None |             return None | ||||||
|         else: |         else: | ||||||
|             return response.json() |             return response.json() | ||||||
|  | |||||||
							
								
								
									
										69
									
								
								authentik/sources/oauth/types/mailcow.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								authentik/sources/oauth/types/mailcow.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,69 @@ | |||||||
|  | """Mailcow OAuth Views""" | ||||||
|  | from typing import Any, Optional | ||||||
|  |  | ||||||
|  | from requests.exceptions import RequestException | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.sources.oauth.clients.oauth2 import OAuth2Client | ||||||
|  | from authentik.sources.oauth.types.manager import MANAGER, SourceType | ||||||
|  | from authentik.sources.oauth.views.callback import OAuthCallback | ||||||
|  | from authentik.sources.oauth.views.redirect import OAuthRedirect | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MailcowOAuthRedirect(OAuthRedirect): | ||||||
|  |     """Mailcow OAuth2 Redirect""" | ||||||
|  |  | ||||||
|  |     def get_additional_parameters(self, source):  # pragma: no cover | ||||||
|  |         return { | ||||||
|  |             "scope": ["profile"], | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MailcowOAuth2Client(OAuth2Client): | ||||||
|  |     """MailcowOAuth2Client, for some reason, mailcow does not like the default headers""" | ||||||
|  |  | ||||||
|  |     def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]: | ||||||
|  |         "Fetch user profile information." | ||||||
|  |         profile_url = self.source.type.profile_url or "" | ||||||
|  |         if self.source.type.urls_customizable and self.source.profile_url: | ||||||
|  |             profile_url = self.source.profile_url | ||||||
|  |         try: | ||||||
|  |             response = self.session.request( | ||||||
|  |                 "get", | ||||||
|  |                 f"{profile_url}?access_token={token['access_token']}", | ||||||
|  |             ) | ||||||
|  |             response.raise_for_status() | ||||||
|  |         except RequestException as exc: | ||||||
|  |             LOGGER.warning("Unable to fetch user profile", exc=exc, body=response.text) | ||||||
|  |             return None | ||||||
|  |         else: | ||||||
|  |             return response.json() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MailcowOAuth2Callback(OAuthCallback): | ||||||
|  |     """Mailcow OAuth2 Callback""" | ||||||
|  |  | ||||||
|  |     client_class = MailcowOAuth2Client | ||||||
|  |  | ||||||
|  |     def get_user_enroll_context( | ||||||
|  |         self, | ||||||
|  |         info: dict[str, Any], | ||||||
|  |     ) -> dict[str, Any]: | ||||||
|  |         return { | ||||||
|  |             "email": info.get("email"), | ||||||
|  |             "name": info.get("full_name"), | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @MANAGER.type() | ||||||
|  | class MailcowType(SourceType): | ||||||
|  |     """Mailcow Type definition""" | ||||||
|  |  | ||||||
|  |     callback_view = MailcowOAuth2Callback | ||||||
|  |     redirect_view = MailcowOAuthRedirect | ||||||
|  |     name = "Mailcow" | ||||||
|  |     slug = "mailcow" | ||||||
|  |  | ||||||
|  |     urls_customizable = True | ||||||
| @ -18,6 +18,8 @@ from authentik.sources.saml.processors.constants import ( | |||||||
|     RSA_SHA256, |     RSA_SHA256, | ||||||
|     RSA_SHA384, |     RSA_SHA384, | ||||||
|     RSA_SHA512, |     RSA_SHA512, | ||||||
|  |     SAML_BINDING_POST, | ||||||
|  |     SAML_BINDING_REDIRECT, | ||||||
|     SAML_NAME_ID_FORMAT_EMAIL, |     SAML_NAME_ID_FORMAT_EMAIL, | ||||||
|     SAML_NAME_ID_FORMAT_PERSISTENT, |     SAML_NAME_ID_FORMAT_PERSISTENT, | ||||||
|     SAML_NAME_ID_FORMAT_TRANSIENT, |     SAML_NAME_ID_FORMAT_TRANSIENT, | ||||||
| @ -37,6 +39,15 @@ class SAMLBindingTypes(models.TextChoices): | |||||||
|     POST = "POST", _("POST Binding") |     POST = "POST", _("POST Binding") | ||||||
|     POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation") |     POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def uri(self) -> str: | ||||||
|  |         """Convert database field to URI""" | ||||||
|  |         return { | ||||||
|  |             SAMLBindingTypes.POST: SAML_BINDING_POST, | ||||||
|  |             SAMLBindingTypes.POST_AUTO: SAML_BINDING_POST, | ||||||
|  |             SAMLBindingTypes.REDIRECT: SAML_BINDING_REDIRECT, | ||||||
|  |         }[self] | ||||||
|  |  | ||||||
|  |  | ||||||
| class SAMLNameIDPolicy(models.TextChoices): | class SAMLNameIDPolicy(models.TextChoices): | ||||||
|     """SAML NameID Policies""" |     """SAML NameID Policies""" | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ from lxml.etree import Element  # nosec | |||||||
| from authentik.providers.saml.utils import get_random_id | from authentik.providers.saml.utils import get_random_id | ||||||
| from authentik.providers.saml.utils.encoding import deflate_and_base64_encode | from authentik.providers.saml.utils.encoding import deflate_and_base64_encode | ||||||
| from authentik.providers.saml.utils.time import get_time_string | from authentik.providers.saml.utils.time import get_time_string | ||||||
| from authentik.sources.saml.models import SAMLSource | from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource | ||||||
| from authentik.sources.saml.processors.constants import ( | from authentik.sources.saml.processors.constants import ( | ||||||
|     DIGEST_ALGORITHM_TRANSLATION_MAP, |     DIGEST_ALGORITHM_TRANSLATION_MAP, | ||||||
|     NS_MAP, |     NS_MAP, | ||||||
| @ -62,7 +62,7 @@ class RequestProcessor: | |||||||
|         auth_n_request.attrib["Destination"] = self.source.sso_url |         auth_n_request.attrib["Destination"] = self.source.sso_url | ||||||
|         auth_n_request.attrib["ID"] = self.request_id |         auth_n_request.attrib["ID"] = self.request_id | ||||||
|         auth_n_request.attrib["IssueInstant"] = self.issue_instant |         auth_n_request.attrib["IssueInstant"] = self.issue_instant | ||||||
|         auth_n_request.attrib["ProtocolBinding"] = self.source.binding_type |         auth_n_request.attrib["ProtocolBinding"] = SAMLBindingTypes(self.source.binding_type).uri | ||||||
|         auth_n_request.attrib["Version"] = "2.0" |         auth_n_request.attrib["Version"] = "2.0" | ||||||
|         # Create issuer object |         # Create issuer object | ||||||
|         auth_n_request.append(self.get_issuer()) |         auth_n_request.append(self.get_issuer()) | ||||||
|  | |||||||
| @ -61,7 +61,7 @@ class StaticDeviceViewSet( | |||||||
| ): | ): | ||||||
|     """Viewset for static authenticator devices""" |     """Viewset for static authenticator devices""" | ||||||
|  |  | ||||||
|     queryset = StaticDevice.objects.all() |     queryset = StaticDevice.objects.filter(confirmed=True) | ||||||
|     serializer_class = StaticDeviceSerializer |     serializer_class = StaticDeviceSerializer | ||||||
|     permission_classes = [OwnerPermissions] |     permission_classes = [OwnerPermissions] | ||||||
|     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] |     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] | ||||||
|  | |||||||
| @ -55,7 +55,7 @@ class AuthenticatorStaticStageView(ChallengeStageView): | |||||||
|         stage: AuthenticatorStaticStage = self.executor.current_stage |         stage: AuthenticatorStaticStage = self.executor.current_stage | ||||||
|  |  | ||||||
|         if SESSION_STATIC_DEVICE not in self.request.session: |         if SESSION_STATIC_DEVICE not in self.request.session: | ||||||
|             device = StaticDevice(user=user, confirmed=True, name="Static Token") |             device = StaticDevice(user=user, confirmed=False, name="Static Token") | ||||||
|             tokens = [] |             tokens = [] | ||||||
|             for _ in range(0, stage.token_count): |             for _ in range(0, stage.token_count): | ||||||
|                 tokens.append(StaticToken(device=device, token=StaticToken.random_token())) |                 tokens.append(StaticToken(device=device, token=StaticToken.random_token())) | ||||||
| @ -66,6 +66,7 @@ class AuthenticatorStaticStageView(ChallengeStageView): | |||||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||||
|         """Verify OTP Token""" |         """Verify OTP Token""" | ||||||
|         device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE] |         device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE] | ||||||
|  |         device.confirmed = True | ||||||
|         device.save() |         device.save() | ||||||
|         for token in self.request.session[SESSION_STATIC_TOKENS]: |         for token in self.request.session[SESSION_STATIC_TOKENS]: | ||||||
|             token.save() |             token.save() | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	