Compare commits
	
		
			891 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e0f48a30b7 | |||
| 973f14d911 | |||
| e8978adc1b | |||
| 3ca8d9c968 | |||
| 42636142fa | |||
| 57c459348f | |||
| 493b34cf0d | |||
| f0493f418b | |||
| d45a292652 | |||
| b21ea360db | |||
| 6816f8b851 | |||
| de714f0390 | |||
| 800df332b5 | |||
| 16c194d2dc | |||
| 53100a72fe | |||
| ec4c3f44cb | |||
| f10bd432b3 | |||
| 4de927ba5b | |||
| 74e578c2bf | |||
| e584fd1344 | |||
| 0e02925a3d | |||
| 5b837c3ccc | |||
| 2580371f94 | |||
| 4e9be85353 | |||
| 79508e1965 | |||
| 3a88dde545 | |||
| 31fc4d1cb9 | |||
| 09cd8f8f63 | |||
| d824b09365 | |||
| cabbd18880 | |||
| c9dda17c68 | |||
| bb8559ee18 | |||
| 5ae32e525c | |||
| 0832145a01 | |||
| 4167276c8f | |||
| afb84c7bc5 | |||
| 82b2c7e3f0 | |||
| fc8004db2b | |||
| ddfc943bba | |||
| 8c0c12292e | |||
| 803490d98b | |||
| 16835ab478 | |||
| 572b8d87b5 | |||
| 31d2ea65fd | |||
| f4ac2f50e2 | |||
| 969a3f0ddd | |||
| 4e18f47f28 | |||
| f10286edf8 | |||
| d789dcc28f | |||
| 715a71427e | |||
| 84c21d16cf | |||
| 2e4e17adb7 | |||
| 00cbaaf672 | |||
| 74e4e8f6aa | |||
| d78fda990a | |||
| 10d949f7a9 | |||
| 6661af032d | |||
| fb5e4a3af8 | |||
| 1dfad83a34 | |||
| 70025c648c | |||
| 676b77aa7c | |||
| e35e096266 | |||
| 7af12d4fec | |||
| 8d6db0fabf | |||
| 8ddcf99bf7 | |||
| e25f6aea8c | |||
| b1a9eda1d3 | |||
| 2c15ab9995 | |||
| b3c51e426d | |||
| 71578af47f | |||
| 6c985acb36 | |||
| d878d2140e | |||
| 4766d6ff3d | |||
| 3a64d97040 | |||
| 2275ba3add | |||
| 9f7c941426 | |||
| 34ae9e6dab | |||
| bf683514ee | |||
| 9b58bdb447 | |||
| 4237f20ccd | |||
| 2408719a47 | |||
| b33fef7929 | |||
| 73b9847e7d | |||
| a7e4eb021d | |||
| 11306770ad | |||
| 5235e00d3c | |||
| 7834146efc | |||
| d4379ecd31 | |||
| 7492608ace | |||
| 7eef501446 | |||
| b73de96aa6 | |||
| a7adeb917e | |||
| 4ee2f951da | |||
| 01c5235e82 | |||
| 0ce4f9fe12 | |||
| 2f4f951818 | |||
| a6c214e8fa | |||
| 57f8b108c4 | |||
| 7c1fe1243f | |||
| 3f69dd34ba | |||
| c81431895a | |||
| 560c979d26 | |||
| c5cc8842ec | |||
| 2a881d241d | |||
| 6291834573 | |||
| eeea36acea | |||
| e95b9da586 | |||
| f4a53c89ef | |||
| 20493252e2 | |||
| 2210497569 | |||
| 2addf71f37 | |||
| de11181890 | |||
| 66e3bc6b58 | |||
| 612679e8df | |||
| c9072f7403 | |||
| cacacb06af | |||
| 7da87a53b7 | |||
| 9f894881ca | |||
| dad24c03ff | |||
| fb8d67a9d9 | |||
| 029d58191e | |||
| 75404f1345 | |||
| ba1b23c879 | |||
| ae8cf00a21 | |||
| d9ffb23a80 | |||
| dab5f4c768 | |||
| cd6632fca6 | |||
| ea1741838c | |||
| 8256fa8c0b | |||
| 486a930163 | |||
| 8a58a31bd6 | |||
| deb0d3f7bc | |||
| 10208b45b6 | |||
| 25f987ba2b | |||
| f23111beff | |||
| 0f693158b6 | |||
| e51226432f | |||
| b1fbcef98a | |||
| ce56192412 | |||
| 70d72f340f | |||
| 7524e114d9 | |||
| 4d7dab92bc | |||
| a36e3aa3a4 | |||
| fceab788d2 | |||
| d55d44d664 | |||
| 88cc38394e | |||
| ea1696a275 | |||
| 552d26eb98 | |||
| 90a5c84ac8 | |||
| b55c3a687d | |||
| e786244988 | |||
| 68f1fbebf4 | |||
| 9180d448df | |||
| 67470590c2 | |||
| fe2e850303 | |||
| a7a3c158ea | |||
| 98d0986ac8 | |||
| bedf7fbcaa | |||
| 1f35f73c66 | |||
| 8ea02e4cc9 | |||
| f399b32135 | |||
| 0032f535da | |||
| 3c349b1f22 | |||
| 17326615b7 | |||
| f5dbdbd48b | |||
| 277c2f4aad | |||
| d38f944435 | |||
| ba3e0a0586 | |||
| 7581c84a37 | |||
| 86b450c6d1 | |||
| e43e42139a | |||
| 0b90cfcec4 | |||
| cefe3fa6dd | |||
| 24da24b5d5 | |||
| f996f9d4e3 | |||
| 5411412626 | |||
| f9050f9192 | |||
| bc75c07e65 | |||
| c02b943612 | |||
| 7b39718bd1 | |||
| e9621bae06 | |||
| 0eaabbc0f3 | |||
| 5e3628bea6 | |||
| 290ebef8e3 | |||
| 46ab1d20df | |||
| 48e68d6852 | |||
| cde056825e | |||
| de25b64f2b | |||
| 32f0c6abe1 | |||
| 960210f351 | |||
| 7c300f0858 | |||
| ed3859800c | |||
| 06b7f62a40 | |||
| 45b7c349f1 | |||
| 7bef6f7153 | |||
| d32e40b1f8 | |||
| cec47c3cfc | |||
| 4d773274d4 | |||
| 3ea2b16a12 | |||
| 974ddc07f7 | |||
| 2f64b76eba | |||
| a113778ca7 | |||
| 06caaa7c80 | |||
| b50ac96605 | |||
| 166b98fa34 | |||
| 6d0e0cbe5a | |||
| b339452843 | |||
| 4f04ab7a5f | |||
| 35bcd5d174 | |||
| 644ff4a90c | |||
| 05d45383be | |||
| 702fdfedb7 | |||
| 2a0af8750d | |||
| 770316a49f | |||
| 85d349e776 | |||
| f29344e91f | |||
| 9900cc5c81 | |||
| 3af48a81e2 | |||
| 5bebf26908 | |||
| eea831fb5a | |||
| 2e4a9219a2 | |||
| 7f1098ce9b | |||
| 6cd6224d2b | |||
| 43d85f8696 | |||
| ef8b26db13 | |||
| ebfa7c8dce | |||
| e295f18e78 | |||
| cef5c2b084 | |||
| e24a9e3119 | |||
| 264a170a7e | |||
| 8e1c2d7fc0 | |||
| 6c7f4197a1 | |||
| 1cd3866855 | |||
| 6a9c95c593 | |||
| 80adafdb48 | |||
| 72f5a4c460 | |||
| fb6242d2d3 | |||
| b9773d39c0 | |||
| 0e8d9aa45d | |||
| fc45d35699 | |||
| 7e8044619c | |||
| cf57660772 | |||
| 66a04aeec5 | |||
| 73338bdf32 | |||
| 059da74d1c | |||
| 45b8b1e198 | |||
| 5e43eb9838 | |||
| 11607622a3 | |||
| 133fc38c05 | |||
| f51ab7a878 | |||
| c89b8a5f7c | |||
| 31ad09c391 | |||
| 05b3c4ddb3 | |||
| d52cc30341 | |||
| d2e9683411 | |||
| a4c28a28b4 | |||
| 6232333a52 | |||
| a1203cf4b2 | |||
| 8427fb87f6 | |||
| e3578eb7ae | |||
| 5990b8d4de | |||
| 3b31b7ce83 | |||
| 4d9b362dbf | |||
| 7bd93ed18e | |||
| 477ff85109 | |||
| fae8b80ceb | |||
| df92f01719 | |||
| 9dd6b7d436 | |||
| 14f85ec980 | |||
| ff611f21cd | |||
| a1b6e09e8a | |||
| 02b5742228 | |||
| c5cc84c8b6 | |||
| 109ada570f | |||
| b9436c281a | |||
| 89f2f920cf | |||
| abd0d585a6 | |||
| ee74281537 | |||
| 5488db3574 | |||
| 61f92095a5 | |||
| 3a9f081e1b | |||
| a237ae3363 | |||
| 523621daa2 | |||
| 309d80a921 | |||
| 1bd41116a4 | |||
| a7b85aeda2 | |||
| 142861e3ee | |||
| 02411bb543 | |||
| c4453f38a2 | |||
| 250e23408e | |||
| 6f3eb4c068 | |||
| 58a4b20297 | |||
| 6d3e067a2b | |||
| 6db2bf2a21 | |||
| 6893948fa0 | |||
| 6317a8c5d0 | |||
| bc39320f86 | |||
| 2001cf0e04 | |||
| 712c5df5b1 | |||
| 8057c63cb4 | |||
| 7816a3075a | |||
| 1679e94956 | |||
| 8ecac59eca | |||
| af504e13a2 | |||
| 8183a51b72 | |||
| ab25610643 | |||
| 127ebed5c6 | |||
| 716923e17a | |||
| c6bb6709fd | |||
| fb4e0723ee | |||
| 8ecacb319c | |||
| 2a5926608f | |||
| 763c3fcfe0 | |||
| 1b346866da | |||
| 25a88c17d1 | |||
| 6f6ae7831e | |||
| 0062872e18 | |||
| e49fb3295f | |||
| 0e89353ac9 | |||
| b8f98881fa | |||
| f887850b95 | |||
| 2ec4b4ec98 | |||
| c98e4196bd | |||
| 3b41c662ed | |||
| 65522186f1 | |||
| 9f5a3c396d | |||
| 53e2b2c784 | |||
| a5cd9fa141 | |||
| 039a1e544e | |||
| 0768b201a7 | |||
| c1c55a6005 | |||
| 0144e1ad72 | |||
| 2d5c45543b | |||
| 9b57f0b81d | |||
| 9d476a42d1 | |||
| 2c816e6162 | |||
| 934cfa483c | |||
| 50308510b4 | |||
| dbcb4d46ba | |||
| bb89b9b572 | |||
| 6600da7d98 | |||
| 1a0f72d0a8 | |||
| a265dd54cc | |||
| a603f42cc0 | |||
| d9a788aac8 | |||
| 7c6185b581 | |||
| 41a1305555 | |||
| 75f252b530 | |||
| c526e5fb9a | |||
| 7aa903d715 | |||
| b826eb264e | |||
| a9519a4a68 | |||
| a4960064c9 | |||
| 94bddb9886 | |||
| f38702f361 | |||
| c49fac39b1 | |||
| b3390f0ab4 | |||
| 7666c246c3 | |||
| bf4cbb25fe | |||
| a925418f60 | |||
| ffd61d0e60 | |||
| 13cc33c39c | |||
| 71d112bdcf | |||
| c58fe18b97 | |||
| d2c06c40ea | |||
| 590c7f4c9d | |||
| 9a48c2fd9a | |||
| be5a6c0310 | |||
| 92106ca4bf | |||
| 56f1204c9b | |||
| f6f93640c5 | |||
| b8c76eaf1c | |||
| 9dbbd4eff6 | |||
| 2908be5272 | |||
| 349a5b2d00 | |||
| 63e3667e82 | |||
| 92f2a82c03 | |||
| dcf074650e | |||
| 1324ec5146 | |||
| 0f556fe8a3 | |||
| 19371dad65 | |||
| acf1ad91d9 | |||
| a74419214c | |||
| 7bd8110984 | |||
| aa5623772c | |||
| 50ede4cc2c | |||
| 879ad27602 | |||
| 37a63d104f | |||
| bc6aef7af2 | |||
| 2498e72f5d | |||
| c61442c121 | |||
| 2d66837742 | |||
| 90e7fbe238 | |||
| 4447f737e8 | |||
| c13c747263 | |||
| cac23f2fa4 | |||
| 788ea46d8c | |||
| c285c6b476 | |||
| a7cf364e43 | |||
| 06dee5d5d8 | |||
| 3cf0f07baf | |||
| e177ab33e0 | |||
| 9e7c9ae649 | |||
| f016095891 | |||
| 5a465fbc36 | |||
| 7cd80a903a | |||
| dd00351bc7 | |||
| 5fca7d11b8 | |||
| 0ff59636f7 | |||
| c4751e4b59 | |||
| e5ebe390d2 | |||
| 7f4bd27b85 | |||
| b66626f9c4 | |||
| a51a18f3a3 | |||
| b13d6deda8 | |||
| 23123c43ee | |||
| 8ce918d527 | |||
| 626006725e | |||
| f9ce41229d | |||
| ae6a406b1d | |||
| 45c1a603e7 | |||
| 330219e76f | |||
| 583271d5ed | |||
| 176360fdd7 | |||
| 0db17b9729 | |||
| 9f9ee66cc4 | |||
| ab2bd622a8 | |||
| 6bd27d27ec | |||
| 8d2a3b67b9 | |||
| a5233f89b2 | |||
| 8b6292b3de | |||
| cbed5a6522 | |||
| 589f806b7c | |||
| 07dc648470 | |||
| 41f6d3b6e7 | |||
| ec8490e105 | |||
| 69668a2a05 | |||
| d0f1daf025 | |||
| d38fd603dd | |||
| ba5374f6e1 | |||
| 7152d7ee01 | |||
| ab07113530 | |||
| a7d7b46747 | |||
| dde1dabf97 | |||
| 1f05484e3c | |||
| 9a44088d2b | |||
| b351ae12c5 | |||
| 759bf59780 | |||
| 10cb60f48e | |||
| 99be97206b | |||
| ef9f08553c | |||
| 4fb71a6bdd | |||
| 3ab7588b73 | |||
| cac1f242dc | |||
| 0bac738090 | |||
| d0d3072c50 | |||
| 1324d03815 | |||
| 34e2bbc41d | |||
| ea2dbb2f33 | |||
| c55f2ad10a | |||
| 2cde40aeee | |||
| a30b32fbbf | |||
| 1745306cc6 | |||
| 8925787a13 | |||
| 968b7ec17a | |||
| 6600d5bf69 | |||
| a4278833d8 | |||
| 942905b9b1 | |||
| 81056c3889 | |||
| 36b694fc41 | |||
| 2d9f216658 | |||
| 8d7bb7da17 | |||
| 965db6eaf5 | |||
| 9bdd6f23a4 | |||
| 675ad7710c | |||
| 9939db13c3 | |||
| 03e134b296 | |||
| 465750276c | |||
| 9b13191646 | |||
| 634ea61b50 | |||
| 0fcb4936a2 | |||
| 934e62d5be | |||
| c5e9197b19 | |||
| 0b7ebf0e07 | |||
| ddca8ef3ca | |||
| 709581f5a8 | |||
| 72e41c03f5 | |||
| 40503d06b7 | |||
| 1df8790050 | |||
| 3c23ad340f | |||
| f9f2e00913 | |||
| 8362507bdf | |||
| a2181c3bf0 | |||
| a07ded0dae | |||
| 3b0b9301ee | |||
| 919f293fc7 | |||
| c4df2e5a50 | |||
| 4d1500e0f3 | |||
| 281bd4c69a | |||
| e4678aa032 | |||
| ff1c4d555a | |||
| 4a3e34d40a | |||
| 6939898bbe | |||
| 549607c5ed | |||
| f61acdfbfd | |||
| e3572bad76 | |||
| 8f99891a9d | |||
| 99d5262d41 | |||
| 97a3c2d88b | |||
| e91ff4566d | |||
| dc942b2f4c | |||
| a3fccbdaff | |||
| bdf9f26d07 | |||
| 901cea1453 | |||
| 37b57ac28f | |||
| e9aa37ba67 | |||
| 9a0aa4c79b | |||
| 34ab68a169 | |||
| 52cf4890cf | |||
| 8e5d03cb86 | |||
| 2190fa555b | |||
| ae1edde17b | |||
| 3ad1c3f212 | |||
| 3665e2fefa | |||
| 3dbe35cf9e | |||
| 65ec444e52 | |||
| c7f0ea8a4b | |||
| 0620324702 | |||
| 5a802bcf83 | |||
| 00c8054893 | |||
| dc2538f59d | |||
| 5a0e78c698 | |||
| fd4e8a59f4 | |||
| dd1a6a81c8 | |||
| 84dfbcaaae | |||
| e649e9fb03 | |||
| 266ef66a6f | |||
| 842fdb0b0c | |||
| a270a84aae | |||
| 36f7cad23b | |||
| e441ac1e43 | |||
| 24f2932777 | |||
| a6c6f22221 | |||
| abd5db8ad4 | |||
| 124ce80694 | |||
| 4352960f83 | |||
| 4e2443d60b | |||
| 34a8408a4f | |||
| 17b65adcc5 | |||
| 6f8d129dea | |||
| 59f339beda | |||
| ce1c400022 | |||
| c99afe0ad4 | |||
| ff9ff18c11 | |||
| 4d11d82c6e | |||
| b4d750174f | |||
| fd44765ff4 | |||
| 190ebb27e4 | |||
| fb3c04d0c7 | |||
| 3ba8de61e0 | |||
| d4d2be84a3 | |||
| 96ea7ae09c | |||
| 172bfceb31 | |||
| 932b19999e | |||
| 0f1cc86e71 | |||
| 788fd00390 | |||
| f602e202b8 | |||
| 9b60fcb08b | |||
| a293a14f2a | |||
| 65bfa589eb | |||
| defca51d24 | |||
| d862028134 | |||
| c19d7c37aa | |||
| 6fb3102d25 | |||
| 51e3453dca | |||
| 6f58fdf158 | |||
| 5d4051f547 | |||
| 219b8d1a57 | |||
| c7d4e69669 | |||
| cd629dfbaa | |||
| 8eaaaae2a7 | |||
| 3d0a853449 | |||
| c2f8ff55cf | |||
| 4b52697cfe | |||
| 80fae44f47 | |||
| afd7af557d | |||
| 73eb97ca6e | |||
| ebe90d8886 | |||
| a1a1b113b1 | |||
| 9adf8e88ba | |||
| 72d87ee51d | |||
| 9654285535 | |||
| 6e47e69c62 | |||
| 1ba89a02ee | |||
| 1fb3642701 | |||
| 847d97b813 | |||
| 253060def2 | |||
| 2e70ea799a | |||
| 7364914ae8 | |||
| 1f1d322958 | |||
| e4841ce1a4 | |||
| af30b781b6 | |||
| 5f490c563e | |||
| e33a5528f7 | |||
| d4de243e3b | |||
| 317117ee68 | |||
| 40d03a6124 | |||
| 9cfeeb35ba | |||
| b7d828702d | |||
| 19dfeec782 | |||
| 07eef2869f | |||
| f7fd31cc84 | |||
| 465d9c2b93 | |||
| 04aae8f584 | |||
| bbca90c93a | |||
| dda1d4e0fb | |||
| f072c600cc | |||
| 65b8a5bb8d | |||
| 92537a6c8d | |||
| 72836ecd9d | |||
| 251a97c77e | |||
| 7f7046f0e4 | |||
| 20e59158c2 | |||
| 9a9e55ae32 | |||
| 481260a5ca | |||
| 436adcce2e | |||
| cd3f02fd3b | |||
| 7abfd24150 | |||
| d3feab9463 | |||
| 189427609f | |||
| d76a9c211a | |||
| ef7d9c4d35 | |||
| 70c25692eb | |||
| 71b31a2812 | |||
| d4493c0ee9 | |||
| 3208358a03 | |||
| a6a8eddf7c | |||
| 8c0a87b710 | |||
| 2f88c435fb | |||
| 5cad59a9f8 | |||
| 5ac6a6910e | |||
| d751a7fc4c | |||
| f1fd223bc7 | |||
| e75712fa09 | |||
| 1b87375661 | |||
| 545a114450 | |||
| 02b06838e2 | |||
| 6868b7722c | |||
| 1e303b515b | |||
| 34a9a6a389 | |||
| 7a1935b4e2 | |||
| bf60b33d03 | |||
| 9bb50fd556 | |||
| 5e7521915a | |||
| 7b0cda3a6a | |||
| db5279f952 | |||
| 9fc072e4df | |||
| 55ea9afeec | |||
| 9485f0b8cc | |||
| fabdb6448f | |||
| e629079352 | |||
| e6dfa8294e | |||
| e5a5a5c603 | |||
| 4d07da5ffa | |||
| 5b4f34fd5f | |||
| 2e05047151 | |||
| 459a6ea437 | |||
| ea7f9f291f | |||
| 241d790e69 | |||
| 83e08f12ae | |||
| 6526659b51 | |||
| 6c3b7c8d3e | |||
| d51ecc4554 | |||
| ef63e35ad2 | |||
| 4e9176ed2e | |||
| d1296e9cc7 | |||
| d85e0593f1 | |||
| 20c1f15dc0 | |||
| c864f4e312 | |||
| 202ad1a3ac | |||
| 979a5f800e | |||
| c151faeff6 | |||
| b3a3852a54 | |||
| e401b4e74e | |||
| 9538ad5710 | |||
| 49bf82a0a4 | |||
| e6fdec4c8e | |||
| 73b87a5e3d | |||
| 303b847cdc | |||
| 0386c0dd7b | |||
| 7f1b9cdeb2 | |||
| 252bb04dd3 | |||
| 3fbcfb48fb | |||
| 69f7198976 | |||
| c74c8b2083 | |||
| 63d4f598e4 | |||
| ded6b6f937 | |||
| 225099b1a1 | |||
| 6b7a32548d | |||
| c71d415456 | |||
| c03f0d1d7c | |||
| ac9cac302c | |||
| 701c140cfd | |||
| ca5761652c | |||
| 553872e8dd | |||
| adc9b67a9c | |||
| fa2ff5fc2b | |||
| d5cab5d580 | |||
| 9e3b5d313b | |||
| be8b2bf6f6 | |||
| 3f8cd7ff13 | |||
| b266a2cdfb | |||
| 9a15a66d85 | |||
| 446f104c90 | |||
| 2cad9a3d07 | |||
| ee48b8c225 | |||
| a91649a7d4 | |||
| ca89201bd8 | |||
| e3a8fc0746 | |||
| 5e3a6b802b | |||
| e8d9f992b9 | |||
| 260b2c8ca8 | |||
| 751e77fa9e | |||
| 86c2a5d69d | |||
| 1a02049104 | |||
| 32934fcd38 | |||
| d84d7c26ca | |||
| 2f6e6a3123 | |||
| 36b674349a | |||
| 038ef67745 | |||
| 53831fa354 | |||
| be39673f29 | |||
| 0f8dbfcc9c | |||
| ba57bf4fa2 | |||
| b1c9126832 | |||
| e674f03064 | |||
| 08451c15f4 | |||
| 99d161e212 | |||
| 940ccf9ea8 | |||
| 08cce2ca4e | |||
| 4acbda2b77 | |||
| 83cfb5f8c2 | |||
| 0d370ef0a9 | |||
| a335ca0895 | |||
| 8a666535a8 | |||
| e6431593f7 | |||
| 928c2bf0d6 | |||
| 68388e9551 | |||
| 5d26fa0403 | |||
| 42f9ba8efe | |||
| 0440ad7c09 | |||
| 3ebc531ae2 | |||
| ca3b5fa2a2 | |||
| 0f0a5b0621 | |||
| 51835887ab | |||
| 09bcbcc2ac | |||
| 8a76d6a21b | |||
| 48ab436444 | |||
| 18a53a9e23 | |||
| 6725569ba8 | |||
| 812be495a5 | |||
| dbc3df1f63 | |||
| 07b001bc2b | |||
| c012bed379 | |||
| d330e9ee7f | |||
| be21a5d172 | |||
| ea2f623955 | |||
| 6fc38436f4 | |||
| 35faf269db | |||
| e56c3fc54c | |||
| 5891fb3ad6 | |||
| 1041718e27 | |||
| 2507c0eec9 | |||
| 5ea9601062 | |||
| c0e6a6c614 | |||
| 4523550422 | |||
| 988cf15b71 | |||
| 6ae660aea4 | |||
| f201ce8059 | |||
| 59624ed45c | |||
| 3e78baf2d7 | |||
| 08c67b2a2c | |||
| 01d29134b9 | |||
| 55250e88e5 | |||
| f1b100c8a5 | |||
| 19708bc67b | |||
| 40a885aaaa | |||
| c529340d6c | |||
| c317efa14c | |||
| 379fcf9c1f | |||
| e10a7b48b7 | |||
| 3e666de91d | |||
| 333758d91f | |||
| 50678a9e2e | |||
| eb8f52b870 | |||
| 3ee90712b2 | |||
| e4eadf8080 | |||
| 26ebaf16fc | |||
| d0ed372af0 | |||
| cc8b2d7dfe | |||
| 61a212371f | |||
| 9ce49c2089 | |||
| 34c45900c2 | |||
| bf7d110af3 | |||
| e7b498e8b4 | |||
| b55cb2b40c | |||
| 25c001f2cd | |||
| 2a409215d3 | |||
| ad8ee83697 | |||
| 1efd09fcd5 | |||
| 35f0e6b88d | |||
| bb2c4423b0 | |||
| ad9f29566b | |||
| e76bb6bc13 | |||
| a68642779d | |||
| 3c04fcaa9f | |||
| 5955d28073 | |||
| a6fb6161d7 | |||
| 6b0e0610c6 | |||
| d7631e8af0 | |||
| 6e625f7400 | |||
| f54ead2b45 | |||
| c4e4e17f93 | |||
| 43c87f87c3 | |||
| 4da0c81f44 | |||
| 9b70aaa717 | |||
| 5769eb277c | |||
| 26f60b3e85 | |||
| 7d8ed06539 | |||
| 4d858c64e0 | |||
| 6f0792ccfe | |||
| 04f06e00ff | |||
| 776c3128b8 | |||
| e9e0992dce | |||
| 69af788b0f | |||
| ceace0282b | |||
| ccef7b4233 | |||
| cad6c42fdd | |||
| d2abe6d455 | |||
| 68d120b3b4 | |||
| 48c0c0baca | |||
| 7b29a1e485 | |||
| fe28d216fe | |||
| e36fb6641e | |||
| 972471ce79 | |||
| 38edd76949 | |||
| cd07c12c1b | |||
| 3ce8b836dc | |||
| d27dfcc1e3 | |||
| 1d5958a78f | |||
| b6e0a1d8f4 | |||
| 2a122845d9 | |||
| 21c7787eed | |||
| fae4d34131 | |||
| 7ff7bfeb58 | |||
| 983604265b | |||
| f8d6daa928 | |||
| 6fc26aca72 | |||
| 29da7dd8d6 | |||
| 91ca90f700 | |||
| b3c8ffb96c | |||
| b35d9ae8b0 | |||
| 302b047f1a | |||
| dcd80c6d63 | |||
| d741ed430a | |||
| 8436738b0f | |||
| 5b150657f5 | |||
| f89479caf3 | |||
| 2f3bf5efe7 | |||
| 5fb07acf54 | |||
| 99d0d4e8de | |||
| afc5dc5543 | |||
| 9341787fe7 | |||
| 6c9b3ebd2b | |||
| a525d6c3a9 | |||
| b59b9314e4 | |||
| 7687b744cc | |||
| 9fb41b8d10 | |||
| 51ffdcb5cb | |||
| 4d6cd4c57d | |||
| 41c5f01422 | |||
| e567cd5580 | |||
| 5f81909bab | |||
| d03b43605e | |||
| ea187d4e81 | |||
| 502ac51fa7 | |||
| 4bc6fd28d4 | |||
| 820c9e7d06 | |||
| e5a8714e6a | |||
| d56d6ea3a9 | |||
| 4f5e1fb86b | 
@ -1,5 +1,5 @@
 | 
				
			|||||||
[bumpversion]
 | 
					[bumpversion]
 | 
				
			||||||
current_version = 2021.4.6
 | 
					current_version = 2021.6.1-rc6
 | 
				
			||||||
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>.*)
 | 
				
			||||||
@ -19,26 +19,18 @@ values =
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:website/docs/installation/docker-compose.md]
 | 
					[bumpversion:file:website/docs/installation/docker-compose.md]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:website/docs/installation/kubernetes.md]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[bumpversion:file:docker-compose.yml]
 | 
					[bumpversion:file:docker-compose.yml]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:helm/values.yaml]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[bumpversion:file:helm/README.md]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[bumpversion:file:helm/Chart.yaml]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[bumpversion:file:.github/workflows/release.yml]
 | 
					[bumpversion:file:.github/workflows/release.yml]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:authentik/__init__.py]
 | 
					[bumpversion:file:authentik/__init__.py]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[bumpversion:file:internal/constants/constants.go]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:outpost/pkg/version.go]
 | 
					[bumpversion:file:outpost/pkg/version.go]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:web/src/constants.ts]
 | 
					[bumpversion:file:web/src/constants.ts]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:web/nginx.conf]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
 | 
					[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]
 | 
					[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,4 @@
 | 
				
			|||||||
env
 | 
					env
 | 
				
			||||||
helm
 | 
					 | 
				
			||||||
static
 | 
					static
 | 
				
			||||||
htmlcov
 | 
					htmlcov
 | 
				
			||||||
*.env.yml
 | 
					*.env.yml
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										27
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					---
 | 
				
			||||||
 | 
					name: Question
 | 
				
			||||||
 | 
					about: Ask a question about a feature or specific configuration
 | 
				
			||||||
 | 
					title: ''
 | 
				
			||||||
 | 
					labels: question
 | 
				
			||||||
 | 
					assignees: ''
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Describe your question/**
 | 
				
			||||||
 | 
					A clear and concise description of what you're trying to do.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Relevant infos**
 | 
				
			||||||
 | 
					i.e. Version of other software you're using, specifics of your setup
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Screenshots**
 | 
				
			||||||
 | 
					If applicable, add screenshots to help explain your problem.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Logs**
 | 
				
			||||||
 | 
					Output of docker-compose logs or kubectl logs respectively
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Version and Deployment (please complete the following information):**
 | 
				
			||||||
 | 
					 - authentik version: [e.g. 0.10.0-stable]
 | 
				
			||||||
 | 
					 - Deployment: [e.g. docker-compose, helm]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Additional context**
 | 
				
			||||||
 | 
					Add any other context about the problem here.
 | 
				
			||||||
							
								
								
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@ -1,5 +1,13 @@
 | 
				
			|||||||
version: 2
 | 
					version: 2
 | 
				
			||||||
updates:
 | 
					updates:
 | 
				
			||||||
 | 
					- package-ecosystem: "github-actions"
 | 
				
			||||||
 | 
					  directory: "/"
 | 
				
			||||||
 | 
					  schedule:
 | 
				
			||||||
 | 
					    interval: daily
 | 
				
			||||||
 | 
					    time: "04:00"
 | 
				
			||||||
 | 
					  open-pull-requests-limit: 10
 | 
				
			||||||
 | 
					  assignees:
 | 
				
			||||||
 | 
					  - BeryJu
 | 
				
			||||||
- package-ecosystem: gomod
 | 
					- package-ecosystem: gomod
 | 
				
			||||||
  directory: "/outpost"
 | 
					  directory: "/outpost"
 | 
				
			||||||
  schedule:
 | 
					  schedule:
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										13
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					# Number of days of inactivity before an issue becomes stale
 | 
				
			||||||
 | 
					daysUntilStale: 60
 | 
				
			||||||
 | 
					# Number of days of inactivity before a stale issue is closed
 | 
				
			||||||
 | 
					daysUntilClose: 7
 | 
				
			||||||
 | 
					# Issues with these labels will never be considered stale
 | 
				
			||||||
 | 
					exemptLabels:
 | 
				
			||||||
 | 
					  - pinned
 | 
				
			||||||
 | 
					  - security
 | 
				
			||||||
 | 
					# Comment to post when marking an issue as stale. Set to `false` to disable
 | 
				
			||||||
 | 
					markComment: >
 | 
				
			||||||
 | 
					  This issue has been automatically marked as stale because it has not had
 | 
				
			||||||
 | 
					  recent activity. It will be closed if no further activity occurs. Thank you
 | 
				
			||||||
 | 
					  for your contributions.
 | 
				
			||||||
							
								
								
									
										182
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										182
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@ -3,90 +3,147 @@ name: authentik-on-release
 | 
				
			|||||||
on:
 | 
					on:
 | 
				
			||||||
  release:
 | 
					  release:
 | 
				
			||||||
    types: [published, created]
 | 
					    types: [published, created]
 | 
				
			||||||
 | 
					  push:
 | 
				
			||||||
 | 
					    branches:
 | 
				
			||||||
 | 
					      - version-*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  # Build
 | 
					  # Build
 | 
				
			||||||
  build-server:
 | 
					  build-server:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v1
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
 | 
					      - name: Set up QEMU
 | 
				
			||||||
 | 
					        uses: docker/setup-qemu-action@v1.2.0
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v1
 | 
				
			||||||
      - name: Docker Login Registry
 | 
					      - name: Docker Login Registry
 | 
				
			||||||
        env:
 | 
					        uses: docker/login-action@v1
 | 
				
			||||||
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
 | 
					        with:
 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
					          username: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
 | 
					          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
      - name: Building Docker Image
 | 
					      - name: Building Docker Image
 | 
				
			||||||
        run: docker build
 | 
					        uses: docker/build-push-action@v2
 | 
				
			||||||
          --no-cache
 | 
					        with:
 | 
				
			||||||
          -t beryju/authentik:2021.4.6
 | 
					          push: ${{ github.event_name == 'release' }}
 | 
				
			||||||
          -t beryju/authentik:latest
 | 
					          tags: |
 | 
				
			||||||
          -f Dockerfile .
 | 
					            beryju/authentik:2021.6.1-rc6,
 | 
				
			||||||
      - name: Push Docker Container to Registry (versioned)
 | 
					            beryju/authentik:latest,
 | 
				
			||||||
        run: docker push beryju/authentik:2021.4.6
 | 
					            ghcr.io/goauthentik/server:2021.6.1-rc6,
 | 
				
			||||||
      - name: Push Docker Container to Registry (latest)
 | 
					            ghcr.io/goauthentik/server:latest
 | 
				
			||||||
        run: docker push beryju/authentik:latest
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
 | 
					          context: .
 | 
				
			||||||
 | 
					      - name: Building Docker Image (stable)
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v2
 | 
				
			||||||
 | 
					        if: ${{ github.event_name == 'release' && !contains('2021.6.1-rc6', 'rc') }}
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          push: true
 | 
				
			||||||
 | 
					          tags: |
 | 
				
			||||||
 | 
					            beryju/authentik:stable,
 | 
				
			||||||
 | 
					            ghcr.io/goauthentik/server:stable
 | 
				
			||||||
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
 | 
					          context: .
 | 
				
			||||||
  build-proxy:
 | 
					  build-proxy:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v1
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-go@v2
 | 
					      - uses: actions/setup-go@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          go-version: "^1.15"
 | 
					          go-version: "^1.15"
 | 
				
			||||||
      - name: prepare go api client
 | 
					      - name: Set up QEMU
 | 
				
			||||||
        run: |
 | 
					        uses: docker/setup-qemu-action@v1.2.0
 | 
				
			||||||
          cd outpost
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
          go get -u github.com/go-swagger/go-swagger/cmd/swagger
 | 
					        uses: docker/setup-buildx-action@v1
 | 
				
			||||||
          swagger generate client -f ../swagger.yaml -A authentik -t pkg/
 | 
					 | 
				
			||||||
          go build -v .
 | 
					 | 
				
			||||||
      - name: Docker Login Registry
 | 
					      - name: Docker Login Registry
 | 
				
			||||||
        env:
 | 
					        uses: docker/login-action@v1
 | 
				
			||||||
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
 | 
					        with:
 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
					          username: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
 | 
					          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
      - name: Building Docker Image
 | 
					      - name: Building Docker Image
 | 
				
			||||||
        run: |
 | 
					        uses: docker/build-push-action@v2
 | 
				
			||||||
          cd outpost/
 | 
					        with:
 | 
				
			||||||
          docker build \
 | 
					          push: ${{ github.event_name == 'release' }}
 | 
				
			||||||
          --no-cache \
 | 
					          tags: |
 | 
				
			||||||
          -t beryju/authentik-proxy:2021.4.6 \
 | 
					            beryju/authentik-proxy:2021.6.1-rc6,
 | 
				
			||||||
          -t beryju/authentik-proxy:latest \
 | 
					            beryju/authentik-proxy:latest,
 | 
				
			||||||
          -f proxy.Dockerfile .
 | 
					            ghcr.io/goauthentik/proxy:2021.6.1-rc6,
 | 
				
			||||||
      - name: Push Docker Container to Registry (versioned)
 | 
					            ghcr.io/goauthentik/proxy:latest
 | 
				
			||||||
        run: docker push beryju/authentik-proxy:2021.4.6
 | 
					          file: outpost/proxy.Dockerfile
 | 
				
			||||||
      - name: Push Docker Container to Registry (latest)
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
        run: docker push beryju/authentik-proxy:latest
 | 
					      - name: Building Docker Image (stable)
 | 
				
			||||||
  build-static:
 | 
					        uses: docker/build-push-action@v2
 | 
				
			||||||
 | 
					        if: ${{ github.event_name == 'release' && !contains('2021.6.1-rc6', 'rc') }}
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          push: true
 | 
				
			||||||
 | 
					          tags: |
 | 
				
			||||||
 | 
					            beryju/authentik-proxy:stable,
 | 
				
			||||||
 | 
					            ghcr.io/goauthentik/proxy:stable
 | 
				
			||||||
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
 | 
					          context: .
 | 
				
			||||||
 | 
					  build-ldap:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v1
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - name: prepare ts api client
 | 
					      - uses: actions/setup-go@v2
 | 
				
			||||||
        run: |
 | 
					        with:
 | 
				
			||||||
          docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
 | 
					          go-version: "^1.15"
 | 
				
			||||||
 | 
					      - name: Set up QEMU
 | 
				
			||||||
 | 
					        uses: docker/setup-qemu-action@v1.2.0
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v1
 | 
				
			||||||
      - name: Docker Login Registry
 | 
					      - name: Docker Login Registry
 | 
				
			||||||
        env:
 | 
					        uses: docker/login-action@v1
 | 
				
			||||||
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
 | 
					        with:
 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
					          username: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
 | 
					          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
      - name: Building Docker Image
 | 
					      - name: Building Docker Image
 | 
				
			||||||
        run: |
 | 
					        uses: docker/build-push-action@v2
 | 
				
			||||||
          cd web/
 | 
					        with:
 | 
				
			||||||
          docker build \
 | 
					          push: ${{ github.event_name == 'release' }}
 | 
				
			||||||
          --no-cache \
 | 
					          tags: |
 | 
				
			||||||
          -t beryju/authentik-static:2021.4.6 \
 | 
					            beryju/authentik-ldap:2021.6.1-rc6,
 | 
				
			||||||
          -t beryju/authentik-static:latest \
 | 
					            beryju/authentik-ldap:latest,
 | 
				
			||||||
          -f Dockerfile .
 | 
					            ghcr.io/goauthentik/ldap:2021.6.1-rc6,
 | 
				
			||||||
      - name: Push Docker Container to Registry (versioned)
 | 
					            ghcr.io/goauthentik/ldap:latest
 | 
				
			||||||
        run: docker push beryju/authentik-static:2021.4.6
 | 
					          file: outpost/ldap.Dockerfile
 | 
				
			||||||
      - name: Push Docker Container to Registry (latest)
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
        run: docker push beryju/authentik-static:latest
 | 
					      - name: Building Docker Image (stable)
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v2
 | 
				
			||||||
 | 
					        if: ${{ github.event_name == 'release' && !contains('2021.6.1-rc6', 'rc') }}
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          push: true
 | 
				
			||||||
 | 
					          tags: |
 | 
				
			||||||
 | 
					            beryju/authentik-ldap:stable,
 | 
				
			||||||
 | 
					            ghcr.io/goauthentik/ldap:stable
 | 
				
			||||||
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
 | 
					          context: .
 | 
				
			||||||
  test-release:
 | 
					  test-release:
 | 
				
			||||||
 | 
					    if: ${{ github.event_name == 'release' }}
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - build-server
 | 
					      - build-server
 | 
				
			||||||
      - build-static
 | 
					 | 
				
			||||||
      - build-proxy
 | 
					      - build-proxy
 | 
				
			||||||
 | 
					      - build-ldap
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v1
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - name: Run test suite in final docker images
 | 
					      - name: Run test suite in final docker images
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          sudo apt-get install -y pwgen
 | 
					          sudo apt-get install -y pwgen
 | 
				
			||||||
@ -95,20 +152,21 @@ jobs:
 | 
				
			|||||||
          docker-compose pull -q
 | 
					          docker-compose pull -q
 | 
				
			||||||
          docker-compose up --no-start
 | 
					          docker-compose up --no-start
 | 
				
			||||||
          docker-compose start postgresql redis
 | 
					          docker-compose start postgresql redis
 | 
				
			||||||
          docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
 | 
					          docker-compose run -u root server test
 | 
				
			||||||
  sentry-release:
 | 
					  sentry-release:
 | 
				
			||||||
 | 
					    if: ${{ github.event_name == 'release' }}
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - test-release
 | 
					      - test-release
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v1
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - name: Create a Sentry.io release
 | 
					      - name: Create a Sentry.io release
 | 
				
			||||||
        uses: tclindner/sentry-releases-action@v1.2.0
 | 
					        uses: getsentry/action-release@v1
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
 | 
					          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
 | 
				
			||||||
          SENTRY_ORG: beryjuorg
 | 
					          SENTRY_ORG: beryjuorg
 | 
				
			||||||
          SENTRY_PROJECT: authentik
 | 
					          SENTRY_PROJECT: authentik
 | 
				
			||||||
          SENTRY_URL: https://sentry.beryju.org
 | 
					          SENTRY_URL: https://sentry.beryju.org
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          tagName: 2021.4.6
 | 
					          version: authentik@2021.6.1-rc6
 | 
				
			||||||
          environment: beryjuorg-prod
 | 
					          environment: beryjuorg-prod
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										29
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/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@master
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - name: Pre-release test
 | 
					      - name: Pre-release test
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          sudo apt-get install -y pwgen
 | 
					          sudo apt-get install -y pwgen
 | 
				
			||||||
@ -20,30 +20,21 @@ jobs:
 | 
				
			|||||||
          docker-compose pull -q
 | 
					          docker-compose pull -q
 | 
				
			||||||
          docker build \
 | 
					          docker build \
 | 
				
			||||||
            --no-cache \
 | 
					            --no-cache \
 | 
				
			||||||
            -t beryju/authentik:latest \
 | 
					            -t ghcr.io/goauthentik/server:latest \
 | 
				
			||||||
            -f Dockerfile .
 | 
					            -f Dockerfile .
 | 
				
			||||||
          docker-compose up --no-start
 | 
					          docker-compose up --no-start
 | 
				
			||||||
          docker-compose start postgresql redis
 | 
					          docker-compose start postgresql redis
 | 
				
			||||||
          docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
 | 
					          docker-compose run -u root server test
 | 
				
			||||||
      - name: Install Helm
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          apt update && apt install -y curl
 | 
					 | 
				
			||||||
          curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash
 | 
					 | 
				
			||||||
      - name: Helm package
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          helm dependency update helm/
 | 
					 | 
				
			||||||
          helm package helm/
 | 
					 | 
				
			||||||
          mv authentik-*.tgz authentik-chart.tgz
 | 
					 | 
				
			||||||
      - name: Extract version number
 | 
					      - name: Extract version number
 | 
				
			||||||
        id: get_version
 | 
					        id: get_version
 | 
				
			||||||
        uses: actions/github-script@0.2.0
 | 
					        uses: actions/github-script@v4.0.2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          script: |
 | 
					          script: |
 | 
				
			||||||
            return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
 | 
					            return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
 | 
				
			||||||
      - name: Create Release
 | 
					      - name: Create Release
 | 
				
			||||||
        id: create_release
 | 
					        id: create_release
 | 
				
			||||||
        uses: actions/create-release@v1.0.0
 | 
					        uses: actions/create-release@v1.1.4
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
					          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
@ -51,13 +42,3 @@ jobs:
 | 
				
			|||||||
          release_name: Release ${{ steps.get_version.outputs.result }}
 | 
					          release_name: Release ${{ steps.get_version.outputs.result }}
 | 
				
			||||||
          draft: true
 | 
					          draft: true
 | 
				
			||||||
          prerelease: false
 | 
					          prerelease: false
 | 
				
			||||||
      - name: Upload packaged Helm Chart
 | 
					 | 
				
			||||||
        id: upload-release-asset
 | 
					 | 
				
			||||||
        uses: actions/upload-release-asset@v1.0.1
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          upload_url: ${{ steps.create_release.outputs.upload_url }}
 | 
					 | 
				
			||||||
          asset_path: ./authentik-chart.tgz
 | 
					 | 
				
			||||||
          asset_name: authentik-chart.tgz
 | 
					 | 
				
			||||||
          asset_content_type: application/gzip
 | 
					 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -193,12 +193,10 @@ pip-selfcheck.json
 | 
				
			|||||||
local.env.yml
 | 
					local.env.yml
 | 
				
			||||||
.vscode/
 | 
					.vscode/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Helm ###
 | 
					 | 
				
			||||||
# Chart dependencies
 | 
					 | 
				
			||||||
**/charts/*.tgz
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Selenium Screenshots
 | 
					# Selenium Screenshots
 | 
				
			||||||
selenium_screenshots/
 | 
					selenium_screenshots/
 | 
				
			||||||
backups/
 | 
					backups/
 | 
				
			||||||
media/
 | 
					media/
 | 
				
			||||||
*mmdb
 | 
					*mmdb
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.idea/
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										61
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										61
									
								
								Dockerfile
									
									
									
									
									
								
							@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					# Stage 1: Lock python dependencies
 | 
				
			||||||
FROM python:3.9-slim-buster as locker
 | 
					FROM python:3.9-slim-buster as locker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./Pipfile /app/
 | 
					COPY ./Pipfile /app/
 | 
				
			||||||
@ -7,8 +8,47 @@ WORKDIR /app/
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
RUN pip install pipenv && \
 | 
					RUN pip install pipenv && \
 | 
				
			||||||
    pipenv lock -r > requirements.txt && \
 | 
					    pipenv lock -r > requirements.txt && \
 | 
				
			||||||
    pipenv lock -rd > requirements-dev.txt
 | 
					    pipenv lock -r --dev-only > requirements-dev.txt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Stage 2: Build web API
 | 
				
			||||||
 | 
					FROM openapitools/openapi-generator-cli as api-builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY ./schema.yml /local/schema.yml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN	docker-entrypoint.sh generate \
 | 
				
			||||||
 | 
					    -i /local/schema.yml \
 | 
				
			||||||
 | 
					    -g typescript-fetch \
 | 
				
			||||||
 | 
					    -o /local/web/api \
 | 
				
			||||||
 | 
					    --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Stage 3: Build webui
 | 
				
			||||||
 | 
					FROM node as npm-builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY ./web /static/
 | 
				
			||||||
 | 
					COPY --from=api-builder /local/web/api /static/api
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENV NODE_ENV=production
 | 
				
			||||||
 | 
					RUN cd /static && npm i && npm run build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Stage 4: Build go proxy
 | 
				
			||||||
 | 
					FROM golang:1.16.5 AS builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR /work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY --from=npm-builder /static/robots.txt /work/web/robots.txt
 | 
				
			||||||
 | 
					COPY --from=npm-builder /static/security.txt /work/web/security.txt
 | 
				
			||||||
 | 
					COPY --from=npm-builder /static/dist/ /work/web/dist/
 | 
				
			||||||
 | 
					COPY --from=npm-builder /static/authentik/ /work/web/authentik/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY ./cmd /work/cmd
 | 
				
			||||||
 | 
					COPY ./web/static.go /work/web/static.go
 | 
				
			||||||
 | 
					COPY ./internal /work/internal
 | 
				
			||||||
 | 
					COPY ./go.mod /work/go.mod
 | 
				
			||||||
 | 
					COPY ./go.sum /work/go.sum
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN go build -o /work/authentik ./cmd/server/main.go
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Stage 5: Run
 | 
				
			||||||
FROM python:3.9-slim-buster
 | 
					FROM python:3.9-slim-buster
 | 
				
			||||||
 | 
					
 | 
				
			||||||
WORKDIR /
 | 
					WORKDIR /
 | 
				
			||||||
@ -19,34 +59,29 @@ ARG GIT_BUILD_HASH
 | 
				
			|||||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
 | 
					ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN apt-get update && \
 | 
					RUN apt-get update && \
 | 
				
			||||||
    apt-get install -y --no-install-recommends curl ca-certificates gnupg && \
 | 
					    apt-get install -y --no-install-recommends curl ca-certificates gnupg git runit && \
 | 
				
			||||||
    curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
 | 
					    curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
 | 
				
			||||||
    echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
 | 
					    echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
 | 
				
			||||||
    apt-get update && \
 | 
					    apt-get update && \
 | 
				
			||||||
    apt-get install -y --no-install-recommends postgresql-client-12 postgresql-client-11 build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
 | 
					    apt-get install -y --no-install-recommends libpq-dev postgresql-client build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
 | 
				
			||||||
    apt-get clean && \
 | 
					 | 
				
			||||||
    pip install -r /requirements.txt --no-cache-dir && \
 | 
					    pip install -r /requirements.txt --no-cache-dir && \
 | 
				
			||||||
    apt-get remove --purge -y build-essential && \
 | 
					    apt-get remove --purge -y build-essential git && \
 | 
				
			||||||
    apt-get autoremove --purge -y && \
 | 
					    apt-get autoremove --purge -y && \
 | 
				
			||||||
    # This is quite hacky, but docker has no guaranteed Group ID
 | 
					    apt-get clean && \
 | 
				
			||||||
    # we could instead check for the GID of the socket and add the user dynamically,
 | 
					    rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
 | 
				
			||||||
    # but then we have to drop permmissions later
 | 
					 | 
				
			||||||
    groupadd -g 998 docker_998 && \
 | 
					 | 
				
			||||||
    groupadd -g 999 docker_999 && \
 | 
					 | 
				
			||||||
    adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
 | 
					    adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
 | 
				
			||||||
    usermod -a -G docker_998 authentik && \
 | 
					 | 
				
			||||||
    usermod -a -G docker_999 authentik && \
 | 
					 | 
				
			||||||
    mkdir /backups && \
 | 
					    mkdir /backups && \
 | 
				
			||||||
    chown authentik:authentik /backups
 | 
					    chown authentik:authentik /backups
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./authentik/ /authentik
 | 
					COPY ./authentik/ /authentik
 | 
				
			||||||
COPY ./pyproject.toml /
 | 
					COPY ./pyproject.toml /
 | 
				
			||||||
COPY ./xml /xml
 | 
					COPY ./xml /xml
 | 
				
			||||||
 | 
					COPY ./tests /tests
 | 
				
			||||||
COPY ./manage.py /
 | 
					COPY ./manage.py /
 | 
				
			||||||
COPY ./lifecycle/ /lifecycle
 | 
					COPY ./lifecycle/ /lifecycle
 | 
				
			||||||
 | 
					COPY --from=builder /work/authentik /authentik-proxy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
USER authentik
 | 
					USER authentik
 | 
				
			||||||
STOPSIGNAL SIGINT
 | 
					 | 
				
			||||||
ENV TMPDIR /dev/shm/
 | 
					ENV TMPDIR /dev/shm/
 | 
				
			||||||
ENV PYTHONUBUFFERED 1
 | 
					ENV PYTHONUBUFFERED 1
 | 
				
			||||||
ENTRYPOINT [ "/lifecycle/bootstrap.sh" ]
 | 
					ENTRYPOINT [ "/lifecycle/bootstrap.sh" ]
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										54
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								Makefile
									
									
									
									
									
								
							@ -1,4 +1,9 @@
 | 
				
			|||||||
all: lint-fix lint coverage gen
 | 
					.SHELLFLAGS += -x -e
 | 
				
			||||||
 | 
					PWD = $(shell pwd)
 | 
				
			||||||
 | 
					UID = $(shell id -u)
 | 
				
			||||||
 | 
					GID = $(shell id -g)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					all: lint-fix lint test gen
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test-integration:
 | 
					test-integration:
 | 
				
			||||||
	k3d cluster create || exit 0
 | 
						k3d cluster create || exit 0
 | 
				
			||||||
@ -8,7 +13,7 @@ test-integration:
 | 
				
			|||||||
test-e2e:
 | 
					test-e2e:
 | 
				
			||||||
	coverage run manage.py test --failfast -v 3 tests/e2e
 | 
						coverage run manage.py test --failfast -v 3 tests/e2e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
coverage:
 | 
					test:
 | 
				
			||||||
	coverage run manage.py test -v 3 authentik
 | 
						coverage run manage.py test -v 3 authentik
 | 
				
			||||||
	coverage html
 | 
						coverage html
 | 
				
			||||||
	coverage report
 | 
						coverage report
 | 
				
			||||||
@ -22,16 +27,39 @@ lint:
 | 
				
			|||||||
	bandit -r authentik tests lifecycle -x node_modules
 | 
						bandit -r authentik tests lifecycle -x node_modules
 | 
				
			||||||
	pylint authentik tests lifecycle
 | 
						pylint authentik tests lifecycle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gen: coverage
 | 
					gen-build:
 | 
				
			||||||
	./manage.py generate_swagger -o swagger.yaml -f yaml
 | 
						./manage.py spectacular --file schema.yml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
local-stack:
 | 
					gen-clean:
 | 
				
			||||||
	export AUTHENTIK_TAG=testing
 | 
						rm -rf web/api/src/
 | 
				
			||||||
	docker build -t beryju/authentik:testng .
 | 
						rm -rf outpost/api/
 | 
				
			||||||
	docker-compose up -d
 | 
					 | 
				
			||||||
	docker-compose run --rm server migrate
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
build-static:
 | 
					gen-web:
 | 
				
			||||||
	docker-compose -f scripts/ci.docker-compose.yml up -d
 | 
						docker run \
 | 
				
			||||||
	docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default .
 | 
							--rm -v ${PWD}:/local \
 | 
				
			||||||
	docker-compose -f scripts/ci.docker-compose.yml down -v
 | 
							--user ${UID}:${GID} \
 | 
				
			||||||
 | 
							openapitools/openapi-generator-cli generate \
 | 
				
			||||||
 | 
							-i /local/schema.yml \
 | 
				
			||||||
 | 
							-g typescript-fetch \
 | 
				
			||||||
 | 
							-o /local/web/api \
 | 
				
			||||||
 | 
							--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
 | 
				
			||||||
 | 
						cd web/api && npx tsc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					gen-outpost:
 | 
				
			||||||
 | 
						docker run \
 | 
				
			||||||
 | 
							--rm -v ${PWD}:/local \
 | 
				
			||||||
 | 
							--user ${UID}:${GID} \
 | 
				
			||||||
 | 
							openapitools/openapi-generator-cli generate \
 | 
				
			||||||
 | 
							--git-host goauthentik.io \
 | 
				
			||||||
 | 
							--git-repo-id outpost \
 | 
				
			||||||
 | 
							--git-user-id api \
 | 
				
			||||||
 | 
							-i /local/schema.yml \
 | 
				
			||||||
 | 
							-g go \
 | 
				
			||||||
 | 
							-o /local/outpost/api \
 | 
				
			||||||
 | 
							--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true
 | 
				
			||||||
 | 
						rm -f outpost/api/go.mod outpost/api/go.sum
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					gen: gen-build gen-clean gen-web gen-outpost
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					run:
 | 
				
			||||||
 | 
						go run -v cmd/server/main.go
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										11
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								Pipfile
									
									
									
									
									
								
							@ -11,7 +11,7 @@ channels-redis = "*"
 | 
				
			|||||||
dacite = "*"
 | 
					dacite = "*"
 | 
				
			||||||
defusedxml = "*"
 | 
					defusedxml = "*"
 | 
				
			||||||
django = "*"
 | 
					django = "*"
 | 
				
			||||||
django-dbbackup = "*"
 | 
					django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' }
 | 
				
			||||||
django-filter = "*"
 | 
					django-filter = "*"
 | 
				
			||||||
django-guardian = "*"
 | 
					django-guardian = "*"
 | 
				
			||||||
django-model-utils = "*"
 | 
					django-model-utils = "*"
 | 
				
			||||||
@ -22,7 +22,7 @@ django-storages = "*"
 | 
				
			|||||||
djangorestframework = "*"
 | 
					djangorestframework = "*"
 | 
				
			||||||
djangorestframework-guardian = "*"
 | 
					djangorestframework-guardian = "*"
 | 
				
			||||||
docker = "*"
 | 
					docker = "*"
 | 
				
			||||||
drf_yasg = "*"
 | 
					drf-spectacular = "*"
 | 
				
			||||||
facebook-sdk = "*"
 | 
					facebook-sdk = "*"
 | 
				
			||||||
geoip2 = "*"
 | 
					geoip2 = "*"
 | 
				
			||||||
gunicorn = "*"
 | 
					gunicorn = "*"
 | 
				
			||||||
@ -32,7 +32,7 @@ lxml = ">=4.6.3"
 | 
				
			|||||||
packaging = "*"
 | 
					packaging = "*"
 | 
				
			||||||
psycopg2-binary = "*"
 | 
					psycopg2-binary = "*"
 | 
				
			||||||
pycryptodome = "*"
 | 
					pycryptodome = "*"
 | 
				
			||||||
pyjwkest = "*"
 | 
					pyjwt = "*"
 | 
				
			||||||
pyyaml = "*"
 | 
					pyyaml = "*"
 | 
				
			||||||
requests-oauthlib = "*"
 | 
					requests-oauthlib = "*"
 | 
				
			||||||
sentry-sdk = "*"
 | 
					sentry-sdk = "*"
 | 
				
			||||||
@ -44,13 +44,15 @@ urllib3 = {extras = ["secure"],version = "*"}
 | 
				
			|||||||
uvicorn = {extras = ["standard"],version = "*"}
 | 
					uvicorn = {extras = ["standard"],version = "*"}
 | 
				
			||||||
webauthn = "*"
 | 
					webauthn = "*"
 | 
				
			||||||
xmlsec = "*"
 | 
					xmlsec = "*"
 | 
				
			||||||
 | 
					duo-client = "*"
 | 
				
			||||||
 | 
					ua-parser = "*"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[requires]
 | 
					[requires]
 | 
				
			||||||
python_version = "3.9"
 | 
					python_version = "3.9"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[dev-packages]
 | 
					[dev-packages]
 | 
				
			||||||
bandit = "*"
 | 
					bandit = "*"
 | 
				
			||||||
black = "==20.8b1"
 | 
					black = "==21.5b1"
 | 
				
			||||||
bump2version = "*"
 | 
					bump2version = "*"
 | 
				
			||||||
colorama = "*"
 | 
					colorama = "*"
 | 
				
			||||||
coverage = "*"
 | 
					coverage = "*"
 | 
				
			||||||
@ -59,3 +61,4 @@ pylint-django = "*"
 | 
				
			|||||||
pytest = "*"
 | 
					pytest = "*"
 | 
				
			||||||
pytest-django = "*"
 | 
					pytest-django = "*"
 | 
				
			||||||
selenium = "*"
 | 
					selenium = "*"
 | 
				
			||||||
 | 
					requests-mock = "*"
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										745
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										745
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -11,6 +11,7 @@
 | 
				
			|||||||

 | 
					
 | 
				
			||||||

 | 
					
 | 
				
			||||||

 | 
					
 | 
				
			||||||
 | 
					[Transifex](https://www.transifex.com/beryjuorg/authentik/)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## What is authentik?
 | 
					## What is authentik?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -20,7 +21,7 @@ authentik is an open-source Identity Provider focused on flexibility and versati
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/)
 | 
					For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://goauthentik.io/docs/installation/kubernetes/)
 | 
					For bigger setups, there is a Helm Chart [here])(https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Screenshots
 | 
					## Screenshots
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -4,8 +4,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
| Version    | Supported          |
 | 
					| Version    | Supported          |
 | 
				
			||||||
| ---------- | ------------------ |
 | 
					| ---------- | ------------------ |
 | 
				
			||||||
| 2021.3.x   | :white_check_mark: |
 | 
					 | 
				
			||||||
| 2021.4.x   | :white_check_mark: |
 | 
					| 2021.4.x   | :white_check_mark: |
 | 
				
			||||||
 | 
					| 2021.5.x   | :white_check_mark: |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Reporting a Vulnerability
 | 
					## Reporting a Vulnerability
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,3 @@
 | 
				
			|||||||
"""authentik"""
 | 
					"""authentik"""
 | 
				
			||||||
__version__ = "2021.4.6"
 | 
					__version__ = "2021.6.1-rc6"
 | 
				
			||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
					ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
"""Meta API"""
 | 
					"""Meta API"""
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from rest_framework.fields import CharField
 | 
					from rest_framework.fields import CharField
 | 
				
			||||||
from rest_framework.permissions import IsAdminUser
 | 
					from rest_framework.permissions import IsAdminUser
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
@ -22,7 +22,7 @@ class AppsViewSet(ViewSet):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    permission_classes = [IsAdminUser]
 | 
					    permission_classes = [IsAdminUser]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: AppSerializer(many=True)})
 | 
					    @extend_schema(responses={200: AppSerializer(many=True)})
 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					    def list(self, request: Request) -> Response:
 | 
				
			||||||
        """List current messages and pass into Serializer"""
 | 
					        """List current messages and pass into Serializer"""
 | 
				
			||||||
        data = []
 | 
					        data = []
 | 
				
			||||||
 | 
				
			|||||||
@ -7,12 +7,12 @@ from django.db.models import Count, ExpressionWrapper, F
 | 
				
			|||||||
from django.db.models.fields import DurationField
 | 
					from django.db.models.fields import DurationField
 | 
				
			||||||
from django.db.models.functions import ExtractHour
 | 
					from django.db.models.functions import ExtractHour
 | 
				
			||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method
 | 
					from drf_spectacular.utils import extend_schema, extend_schema_field
 | 
				
			||||||
from rest_framework.fields import IntegerField, SerializerMethodField
 | 
					from rest_framework.fields import IntegerField, SerializerMethodField
 | 
				
			||||||
from rest_framework.permissions import IsAdminUser
 | 
					from rest_framework.permissions import IsAdminUser
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.viewsets import ViewSet
 | 
					from rest_framework.views import APIView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
@ -58,24 +58,24 @@ class LoginMetricsSerializer(PassiveSerializer):
 | 
				
			|||||||
    logins_per_1h = SerializerMethodField()
 | 
					    logins_per_1h = SerializerMethodField()
 | 
				
			||||||
    logins_failed_per_1h = SerializerMethodField()
 | 
					    logins_failed_per_1h = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
 | 
					    @extend_schema_field(CoordinateSerializer(many=True))
 | 
				
			||||||
    def get_logins_per_1h(self, _):
 | 
					    def get_logins_per_1h(self, _):
 | 
				
			||||||
        """Get successful logins per hour for the last 24 hours"""
 | 
					        """Get successful logins per hour for the last 24 hours"""
 | 
				
			||||||
        return get_events_per_1h(action=EventAction.LOGIN)
 | 
					        return get_events_per_1h(action=EventAction.LOGIN)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
 | 
					    @extend_schema_field(CoordinateSerializer(many=True))
 | 
				
			||||||
    def get_logins_failed_per_1h(self, _):
 | 
					    def get_logins_failed_per_1h(self, _):
 | 
				
			||||||
        """Get failed logins per hour for the last 24 hours"""
 | 
					        """Get failed logins per hour for the last 24 hours"""
 | 
				
			||||||
        return get_events_per_1h(action=EventAction.LOGIN_FAILED)
 | 
					        return get_events_per_1h(action=EventAction.LOGIN_FAILED)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AdministrationMetricsViewSet(ViewSet):
 | 
					class AdministrationMetricsViewSet(APIView):
 | 
				
			||||||
    """Login Metrics per 1h"""
 | 
					    """Login Metrics per 1h"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    permission_classes = [IsAdminUser]
 | 
					    permission_classes = [IsAdminUser]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: LoginMetricsSerializer(many=False)})
 | 
					    @extend_schema(responses={200: LoginMetricsSerializer(many=False)})
 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					    def get(self, request: Request) -> Response:
 | 
				
			||||||
        """Login Metrics per 1h"""
 | 
					        """Login Metrics per 1h"""
 | 
				
			||||||
        serializer = LoginMetricsSerializer(True)
 | 
					        serializer = LoginMetricsSerializer(True)
 | 
				
			||||||
        return Response(serializer.data)
 | 
					        return Response(serializer.data)
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										91
									
								
								authentik/admin/api/system.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								authentik/admin/api/system.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					"""authentik administration overview"""
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import platform
 | 
				
			||||||
 | 
					from datetime import datetime
 | 
				
			||||||
 | 
					from sys import version as python_version
 | 
				
			||||||
 | 
					from typing import TypedDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.utils.timezone import now
 | 
				
			||||||
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
 | 
					from gunicorn import version_info as gunicorn_version
 | 
				
			||||||
 | 
					from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
 | 
				
			||||||
 | 
					from rest_framework.fields import SerializerMethodField
 | 
				
			||||||
 | 
					from rest_framework.permissions import IsAdminUser
 | 
				
			||||||
 | 
					from rest_framework.request import Request
 | 
				
			||||||
 | 
					from rest_framework.response import Response
 | 
				
			||||||
 | 
					from rest_framework.views import APIView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RuntimeDict(TypedDict):
 | 
				
			||||||
 | 
					    """Runtime information"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    python_version: str
 | 
				
			||||||
 | 
					    gunicorn_version: str
 | 
				
			||||||
 | 
					    environment: str
 | 
				
			||||||
 | 
					    architecture: str
 | 
				
			||||||
 | 
					    platform: str
 | 
				
			||||||
 | 
					    uname: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SystemSerializer(PassiveSerializer):
 | 
				
			||||||
 | 
					    """Get system information."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    http_headers = SerializerMethodField()
 | 
				
			||||||
 | 
					    http_host = SerializerMethodField()
 | 
				
			||||||
 | 
					    http_is_secure = SerializerMethodField()
 | 
				
			||||||
 | 
					    runtime = SerializerMethodField()
 | 
				
			||||||
 | 
					    tenant = SerializerMethodField()
 | 
				
			||||||
 | 
					    server_time = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_http_headers(self, request: Request) -> dict[str, str]:
 | 
				
			||||||
 | 
					        """Get HTTP Request headers"""
 | 
				
			||||||
 | 
					        headers = {}
 | 
				
			||||||
 | 
					        for key, value in request.META.items():
 | 
				
			||||||
 | 
					            if not isinstance(value, str):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            headers[key] = value
 | 
				
			||||||
 | 
					        return headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_http_host(self, request: Request) -> str:
 | 
				
			||||||
 | 
					        """Get HTTP host"""
 | 
				
			||||||
 | 
					        return request._request.get_host()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_http_is_secure(self, request: Request) -> bool:
 | 
				
			||||||
 | 
					        """Get HTTP Secure flag"""
 | 
				
			||||||
 | 
					        return request._request.is_secure()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_runtime(self, request: Request) -> RuntimeDict:
 | 
				
			||||||
 | 
					        """Get versions"""
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            "python_version": python_version,
 | 
				
			||||||
 | 
					            "gunicorn_version": ".".join(str(x) for x in gunicorn_version),
 | 
				
			||||||
 | 
					            "environment": "kubernetes"
 | 
				
			||||||
 | 
					            if SERVICE_HOST_ENV_NAME in os.environ
 | 
				
			||||||
 | 
					            else "compose",
 | 
				
			||||||
 | 
					            "architecture": platform.machine(),
 | 
				
			||||||
 | 
					            "platform": platform.platform(),
 | 
				
			||||||
 | 
					            "uname": " ".join(platform.uname()),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_tenant(self, request: Request) -> str:
 | 
				
			||||||
 | 
					        """Currently active tenant"""
 | 
				
			||||||
 | 
					        return str(request._request.tenant)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_server_time(self, request: Request) -> datetime:
 | 
				
			||||||
 | 
					        """Current server time"""
 | 
				
			||||||
 | 
					        return now()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SystemView(APIView):
 | 
				
			||||||
 | 
					    """Get system information."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    permission_classes = [IsAdminUser]
 | 
				
			||||||
 | 
					    pagination_class = None
 | 
				
			||||||
 | 
					    filter_backends = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @extend_schema(responses={200: SystemSerializer(many=False)})
 | 
				
			||||||
 | 
					    def get(self, request: Request) -> Response:
 | 
				
			||||||
 | 
					        """Get system information."""
 | 
				
			||||||
 | 
					        return Response(SystemSerializer(request).data)
 | 
				
			||||||
@ -4,7 +4,8 @@ from importlib import import_module
 | 
				
			|||||||
from django.contrib import messages
 | 
					from django.contrib import messages
 | 
				
			||||||
from django.http.response import Http404
 | 
					from django.http.response import Http404
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
 | 
					from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
 | 
				
			||||||
from rest_framework.permissions import IsAdminUser
 | 
					from rest_framework.permissions import IsAdminUser
 | 
				
			||||||
@ -21,7 +22,7 @@ class TaskSerializer(PassiveSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    task_name = CharField()
 | 
					    task_name = CharField()
 | 
				
			||||||
    task_description = CharField()
 | 
					    task_description = CharField()
 | 
				
			||||||
    task_finish_timestamp = DateTimeField(source="finish_timestamp")
 | 
					    task_finish_timestamp = DateTimeField(source="finish_time")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    status = ChoiceField(
 | 
					    status = ChoiceField(
 | 
				
			||||||
        source="result.status.name",
 | 
					        source="result.status.name",
 | 
				
			||||||
@ -29,14 +30,32 @@ class TaskSerializer(PassiveSerializer):
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
    messages = ListField(source="result.messages")
 | 
					    messages = ListField(source="result.messages")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_representation(self, instance):
 | 
				
			||||||
 | 
					        """When a new version of authentik adds fields to TaskInfo,
 | 
				
			||||||
 | 
					        the API will fail with an AttributeError, as the classes
 | 
				
			||||||
 | 
					        are pickled in cache. In that case, just delete the info"""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return super().to_representation(instance)
 | 
				
			||||||
 | 
					        except AttributeError:
 | 
				
			||||||
 | 
					            if isinstance(self.instance, list):
 | 
				
			||||||
 | 
					                for inst in self.instance:
 | 
				
			||||||
 | 
					                    inst.delete()
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                self.instance.delete()
 | 
				
			||||||
 | 
					            return {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TaskViewSet(ViewSet):
 | 
					class TaskViewSet(ViewSet):
 | 
				
			||||||
    """Read-only view set that returns all background tasks"""
 | 
					    """Read-only view set that returns all background tasks"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    permission_classes = [IsAdminUser]
 | 
					    permission_classes = [IsAdminUser]
 | 
				
			||||||
 | 
					    serializer_class = TaskSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        responses={200: TaskSerializer(many=False), 404: "Task not found"}
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: TaskSerializer(many=False),
 | 
				
			||||||
 | 
					            404: OpenApiResponse(description="Task not found"),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    # pylint: disable=invalid-name
 | 
					    # pylint: disable=invalid-name
 | 
				
			||||||
    def retrieve(self, request: Request, pk=None) -> Response:
 | 
					    def retrieve(self, request: Request, pk=None) -> Response:
 | 
				
			||||||
@ -46,18 +65,19 @@ class TaskViewSet(ViewSet):
 | 
				
			|||||||
            raise Http404
 | 
					            raise Http404
 | 
				
			||||||
        return Response(TaskSerializer(task, many=False).data)
 | 
					        return Response(TaskSerializer(task, many=False).data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: TaskSerializer(many=True)})
 | 
					    @extend_schema(responses={200: TaskSerializer(many=True)})
 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					    def list(self, request: Request) -> Response:
 | 
				
			||||||
        """List system tasks"""
 | 
					        """List system tasks"""
 | 
				
			||||||
        tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name)
 | 
					        tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name)
 | 
				
			||||||
        return Response(TaskSerializer(tasks, many=True).data)
 | 
					        return Response(TaskSerializer(tasks, many=True).data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        request=OpenApiTypes.NONE,
 | 
				
			||||||
        responses={
 | 
					        responses={
 | 
				
			||||||
            204: "Task retried successfully",
 | 
					            204: OpenApiResponse(description="Task retried successfully"),
 | 
				
			||||||
            404: "Task not found",
 | 
					            404: OpenApiResponse(description="Task not found"),
 | 
				
			||||||
            500: "Failed to retry task",
 | 
					            500: OpenApiResponse(description="Failed to retry task"),
 | 
				
			||||||
        }
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=True, methods=["post"])
 | 
					    @action(detail=True, methods=["post"])
 | 
				
			||||||
    # pylint: disable=invalid-name
 | 
					    # pylint: disable=invalid-name
 | 
				
			||||||
 | 
				
			|||||||
@ -2,14 +2,13 @@
 | 
				
			|||||||
from os import environ
 | 
					from os import environ
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from packaging.version import parse
 | 
					from packaging.version import parse
 | 
				
			||||||
from rest_framework.fields import SerializerMethodField
 | 
					from rest_framework.fields import SerializerMethodField
 | 
				
			||||||
from rest_framework.mixins import ListModelMixin
 | 
					 | 
				
			||||||
from rest_framework.permissions import IsAuthenticated
 | 
					from rest_framework.permissions import IsAuthenticated
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.views import APIView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik import ENV_GIT_HASH_KEY, __version__
 | 
					from authentik import ENV_GIT_HASH_KEY, __version__
 | 
				
			||||||
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
 | 
					from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
 | 
				
			||||||
@ -47,17 +46,14 @@ class VersionSerializer(PassiveSerializer):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class VersionViewSet(ListModelMixin, GenericViewSet):
 | 
					class VersionView(APIView):
 | 
				
			||||||
    """Get running and latest version."""
 | 
					    """Get running and latest version."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    permission_classes = [IsAuthenticated]
 | 
					    permission_classes = [IsAuthenticated]
 | 
				
			||||||
    pagination_class = None
 | 
					    pagination_class = None
 | 
				
			||||||
    filter_backends = []
 | 
					    filter_backends = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):  # pragma: no cover
 | 
					    @extend_schema(responses={200: VersionSerializer(many=False)})
 | 
				
			||||||
        return None
 | 
					    def get(self, request: Request) -> Response:
 | 
				
			||||||
 | 
					 | 
				
			||||||
    @swagger_auto_schema(responses={200: VersionSerializer(many=False)})
 | 
					 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					 | 
				
			||||||
        """Get running and latest version."""
 | 
					        """Get running and latest version."""
 | 
				
			||||||
        return Response(VersionSerializer(True).data)
 | 
					        return Response(VersionSerializer(True).data)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,25 +1,26 @@
 | 
				
			|||||||
"""authentik administration overview"""
 | 
					"""authentik administration overview"""
 | 
				
			||||||
from rest_framework.mixins import ListModelMixin
 | 
					from drf_spectacular.utils import extend_schema, inline_serializer
 | 
				
			||||||
 | 
					from prometheus_client import Gauge
 | 
				
			||||||
 | 
					from rest_framework.fields import IntegerField
 | 
				
			||||||
from rest_framework.permissions import IsAdminUser
 | 
					from rest_framework.permissions import IsAdminUser
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.serializers import Serializer
 | 
					from rest_framework.views import APIView
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.root.celery import CELERY_APP
 | 
					from authentik.root.celery import CELERY_APP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class WorkerViewSet(ListModelMixin, GenericViewSet):
 | 
					
 | 
				
			||||||
 | 
					class WorkerView(APIView):
 | 
				
			||||||
    """Get currently connected worker count."""
 | 
					    """Get currently connected worker count."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    serializer_class = Serializer
 | 
					 | 
				
			||||||
    permission_classes = [IsAdminUser]
 | 
					    permission_classes = [IsAdminUser]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):  # pragma: no cover
 | 
					    @extend_schema(
 | 
				
			||||||
        return None
 | 
					        responses=inline_serializer("Workers", fields={"count": IntegerField()})
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					    def get(self, request: Request) -> Response:
 | 
				
			||||||
        """Get currently connected worker count."""
 | 
					        """Get currently connected worker count."""
 | 
				
			||||||
        return Response(
 | 
					        count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
				
			||||||
            {"pagination": {"count": len(CELERY_APP.control.ping(timeout=0.5))}}
 | 
					        return Response({"count": count})
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,15 @@
 | 
				
			|||||||
"""authentik admin tasks"""
 | 
					"""authentik admin tasks"""
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
 | 
					from os import environ
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.core.validators import URLValidator
 | 
					from django.core.validators import URLValidator
 | 
				
			||||||
from packaging.version import parse
 | 
					from packaging.version import parse
 | 
				
			||||||
 | 
					from prometheus_client import Info
 | 
				
			||||||
from requests import RequestException, get
 | 
					from requests import RequestException, get
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik import __version__
 | 
					from authentik import ENV_GIT_HASH_KEY, __version__
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
 | 
					from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
 | 
				
			||||||
from authentik.root.celery import CELERY_APP
 | 
					from authentik.root.celery import CELERY_APP
 | 
				
			||||||
@ -17,6 +19,18 @@ VERSION_CACHE_KEY = "authentik_latest_version"
 | 
				
			|||||||
VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours
 | 
					VERSION_CACHE_TIMEOUT = 8 * 60 * 60  # 8 hours
 | 
				
			||||||
# Chop of the first ^ because we want to search the entire string
 | 
					# Chop of the first ^ because we want to search the entire string
 | 
				
			||||||
URL_FINDER = URLValidator.regex.pattern[1:]
 | 
					URL_FINDER = URLValidator.regex.pattern[1:]
 | 
				
			||||||
 | 
					PROM_INFO = Info("authentik_version", "Currently running authentik version")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _set_prom_info():
 | 
				
			||||||
 | 
					    """Set prometheus info for version"""
 | 
				
			||||||
 | 
					    PROM_INFO.info(
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "version": __version__,
 | 
				
			||||||
 | 
					            "latest": cache.get(VERSION_CACHE_KEY, ""),
 | 
				
			||||||
 | 
					            "build_hash": environ.get(ENV_GIT_HASH_KEY, ""),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
					@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
				
			||||||
@ -36,6 +50,7 @@ def update_latest_version(self: MonitoredTask):
 | 
				
			|||||||
                TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
 | 
					                TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        _set_prom_info()
 | 
				
			||||||
        # Check if upstream version is newer than what we're running,
 | 
					        # Check if upstream version is newer than what we're running,
 | 
				
			||||||
        # and if no event exists yet, create one.
 | 
					        # and if no event exists yet, create one.
 | 
				
			||||||
        local_version = parse(__version__)
 | 
					        local_version = parse(__version__)
 | 
				
			||||||
@ -53,3 +68,6 @@ def update_latest_version(self: MonitoredTask):
 | 
				
			|||||||
    except (RequestException, IndexError) as exc:
 | 
					    except (RequestException, IndexError) as exc:
 | 
				
			||||||
        cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
 | 
					        cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
 | 
				
			||||||
        self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
 | 
					        self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_set_prom_info()
 | 
				
			||||||
 | 
				
			|||||||
@ -7,6 +7,7 @@ from django.urls import reverse
 | 
				
			|||||||
from authentik import __version__
 | 
					from authentik import __version__
 | 
				
			||||||
from authentik.core.models import Group, User
 | 
					from authentik.core.models import Group, User
 | 
				
			||||||
from authentik.core.tasks import clean_expired_models
 | 
					from authentik.core.tasks import clean_expired_models
 | 
				
			||||||
 | 
					from authentik.events.monitored_tasks import TaskResultStatus
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestAdminAPI(TestCase):
 | 
					class TestAdminAPI(TestCase):
 | 
				
			||||||
@ -30,6 +31,26 @@ class TestAdminAPI(TestCase):
 | 
				
			|||||||
            any(task["task_name"] == "clean_expired_models" for task in body)
 | 
					            any(task["task_name"] == "clean_expired_models" for task in body)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_tasks_single(self):
 | 
				
			||||||
 | 
					        """Test Task API (read single)"""
 | 
				
			||||||
 | 
					        clean_expired_models.delay()
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:admin_system_tasks-detail",
 | 
				
			||||||
 | 
					                kwargs={"pk": "clean_expired_models"},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        body = loads(response.content)
 | 
				
			||||||
 | 
					        self.assertEqual(body["status"], TaskResultStatus.SUCCESSFUL.name)
 | 
				
			||||||
 | 
					        self.assertEqual(body["task_name"], "clean_expired_models")
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:admin_system_tasks-detail", kwargs={"pk": "qwerqwer"}
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_tasks_retry(self):
 | 
					    def test_tasks_retry(self):
 | 
				
			||||||
        """Test Task API (retry)"""
 | 
					        """Test Task API (retry)"""
 | 
				
			||||||
        clean_expired_models.delay()
 | 
					        clean_expired_models.delay()
 | 
				
			||||||
@ -53,24 +74,29 @@ class TestAdminAPI(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_version(self):
 | 
					    def test_version(self):
 | 
				
			||||||
        """Test Version API"""
 | 
					        """Test Version API"""
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:admin_version-list"))
 | 
					        response = self.client.get(reverse("authentik_api:admin_version"))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
        body = loads(response.content)
 | 
					        body = loads(response.content)
 | 
				
			||||||
        self.assertEqual(body["version_current"], __version__)
 | 
					        self.assertEqual(body["version_current"], __version__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_workers(self):
 | 
					    def test_workers(self):
 | 
				
			||||||
        """Test Workers API"""
 | 
					        """Test Workers API"""
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:admin_workers-list"))
 | 
					        response = self.client.get(reverse("authentik_api:admin_workers"))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
        body = loads(response.content)
 | 
					        body = loads(response.content)
 | 
				
			||||||
        self.assertEqual(body["pagination"]["count"], 0)
 | 
					        self.assertEqual(body["count"], 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_metrics(self):
 | 
					    def test_metrics(self):
 | 
				
			||||||
        """Test metrics API"""
 | 
					        """Test metrics API"""
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:admin_metrics-list"))
 | 
					        response = self.client.get(reverse("authentik_api:admin_metrics"))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_apps(self):
 | 
					    def test_apps(self):
 | 
				
			||||||
        """Test apps API"""
 | 
					        """Test apps API"""
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:apps-list"))
 | 
					        response = self.client.get(reverse("authentik_api:apps-list"))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_system(self):
 | 
				
			||||||
 | 
					        """Test system API"""
 | 
				
			||||||
 | 
					        response = self.client.get(reverse("authentik_api:admin_system"))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
				
			|||||||
@ -10,3 +10,25 @@ class AuthentikAPIConfig(AppConfig):
 | 
				
			|||||||
    label = "authentik_api"
 | 
					    label = "authentik_api"
 | 
				
			||||||
    mountpoint = "api/"
 | 
					    mountpoint = "api/"
 | 
				
			||||||
    verbose_name = "authentik API"
 | 
					    verbose_name = "authentik API"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def ready(self) -> None:
 | 
				
			||||||
 | 
					        from drf_spectacular.extensions import OpenApiAuthenticationExtension
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        from authentik.api.authentication import TokenAuthentication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Class is defined here as it needs to be created early enough that drf-spectacular will
 | 
				
			||||||
 | 
					        # find it, but also won't cause any import issues
 | 
				
			||||||
 | 
					        # pylint: disable=unused-variable
 | 
				
			||||||
 | 
					        class TokenSchema(OpenApiAuthenticationExtension):
 | 
				
			||||||
 | 
					            """Auth schema"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            target_class = TokenAuthentication
 | 
				
			||||||
 | 
					            name = "authentik"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            def get_security_definition(self, auto_schema):
 | 
				
			||||||
 | 
					                """Auth schema"""
 | 
				
			||||||
 | 
					                return {
 | 
				
			||||||
 | 
					                    "type": "apiKey",
 | 
				
			||||||
 | 
					                    "in": "header",
 | 
				
			||||||
 | 
					                    "name": "Authorization",
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
"""API Authentication"""
 | 
					"""API Authentication"""
 | 
				
			||||||
from base64 import b64decode, b64encode
 | 
					from base64 import b64decode
 | 
				
			||||||
from binascii import Error
 | 
					from binascii import Error
 | 
				
			||||||
from typing import Any, Optional, Union
 | 
					from typing import Any, Optional, Union
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -17,16 +17,8 @@ LOGGER = get_logger()
 | 
				
			|||||||
def token_from_header(raw_header: bytes) -> Optional[Token]:
 | 
					def token_from_header(raw_header: bytes) -> Optional[Token]:
 | 
				
			||||||
    """raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
 | 
					    """raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
 | 
				
			||||||
    auth_credentials = raw_header.decode()
 | 
					    auth_credentials = raw_header.decode()
 | 
				
			||||||
    if auth_credentials == "":
 | 
					    if auth_credentials == "" or " " not in auth_credentials:
 | 
				
			||||||
        return None
 | 
					        return None
 | 
				
			||||||
    # Legacy, accept basic auth thats fully encoded (2021.3 outposts)
 | 
					 | 
				
			||||||
    if " " not in auth_credentials:
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            plain = b64decode(auth_credentials.encode()).decode()
 | 
					 | 
				
			||||||
            auth_type, body = plain.split()
 | 
					 | 
				
			||||||
            auth_credentials = f"{auth_type} {b64encode(body.encode()).decode()}"
 | 
					 | 
				
			||||||
        except (UnicodeDecodeError, Error):
 | 
					 | 
				
			||||||
            raise AuthenticationFailed("Malformed header")
 | 
					 | 
				
			||||||
    auth_type, auth_credentials = auth_credentials.split()
 | 
					    auth_type, auth_credentials = auth_credentials.split()
 | 
				
			||||||
    if auth_type.lower() not in ["basic", "bearer"]:
 | 
					    if auth_type.lower() not in ["basic", "bearer"]:
 | 
				
			||||||
        LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
 | 
					        LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
 | 
				
			||||||
@ -50,7 +42,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]:
 | 
				
			|||||||
    return tokens.first()
 | 
					    return tokens.first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AuthentikTokenAuthentication(BaseAuthentication):
 | 
					class TokenAuthentication(BaseAuthentication):
 | 
				
			||||||
    """Token-based authentication using HTTP Bearer authentication"""
 | 
					    """Token-based authentication using HTTP Bearer authentication"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def authenticate(self, request: Request) -> Union[tuple[User, Any], None]:
 | 
					    def authenticate(self, request: Request) -> Union[tuple[User, Any], None]:
 | 
				
			||||||
@ -62,4 +54,4 @@ class AuthentikTokenAuthentication(BaseAuthentication):
 | 
				
			|||||||
        if not token:
 | 
					        if not token:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return (token.user, None)
 | 
					        return (token.user, None)  # pragma: no cover
 | 
				
			||||||
							
								
								
									
										35
									
								
								authentik/api/authorization.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								authentik/api/authorization.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					"""API Authorization"""
 | 
				
			||||||
 | 
					from django.db.models import Model
 | 
				
			||||||
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
 | 
					from rest_framework.filters import BaseFilterBackend
 | 
				
			||||||
 | 
					from rest_framework.permissions import BasePermission
 | 
				
			||||||
 | 
					from rest_framework.request import Request
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OwnerFilter(BaseFilterBackend):
 | 
				
			||||||
 | 
					    """Filter objects by their owner"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    owner_key = "user"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
 | 
				
			||||||
 | 
					        return queryset.filter(**{self.owner_key: request.user})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class OwnerPermissions(BasePermission):
 | 
				
			||||||
 | 
					    """Authorize requests by an object's owner matching the requesting user"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    owner_key = "user"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def has_permission(self, request: Request, view) -> bool:
 | 
				
			||||||
 | 
					        """If the user is authenticated, we allow all requests here. For listing, the
 | 
				
			||||||
 | 
					        object-level permissions are done by the filter backend"""
 | 
				
			||||||
 | 
					        return request.user.is_authenticated
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def has_object_permission(self, request: Request, view, obj: Model) -> bool:
 | 
				
			||||||
 | 
					        """Check if the object's owner matches the currently logged in user"""
 | 
				
			||||||
 | 
					        if not hasattr(obj, self.owner_key):
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        owner = getattr(obj, self.owner_key)
 | 
				
			||||||
 | 
					        if owner != request.user:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
@ -30,3 +30,47 @@ class Pagination(pagination.PageNumberPagination):
 | 
				
			|||||||
                "results": data,
 | 
					                "results": data,
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_paginated_response_schema(self, schema):
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            "type": "object",
 | 
				
			||||||
 | 
					            "properties": {
 | 
				
			||||||
 | 
					                "pagination": {
 | 
				
			||||||
 | 
					                    "type": "object",
 | 
				
			||||||
 | 
					                    "properties": {
 | 
				
			||||||
 | 
					                        "next": {
 | 
				
			||||||
 | 
					                            "type": "number",
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "previous": {
 | 
				
			||||||
 | 
					                            "type": "number",
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "count": {
 | 
				
			||||||
 | 
					                            "type": "number",
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "current": {
 | 
				
			||||||
 | 
					                            "type": "number",
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "total_pages": {
 | 
				
			||||||
 | 
					                            "type": "number",
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "start_index": {
 | 
				
			||||||
 | 
					                            "type": "number",
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                        "end_index": {
 | 
				
			||||||
 | 
					                            "type": "number",
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    "required": [
 | 
				
			||||||
 | 
					                        "next",
 | 
				
			||||||
 | 
					                        "previous",
 | 
				
			||||||
 | 
					                        "count",
 | 
				
			||||||
 | 
					                        "current",
 | 
				
			||||||
 | 
					                        "total_pages",
 | 
				
			||||||
 | 
					                        "start_index",
 | 
				
			||||||
 | 
					                        "end_index",
 | 
				
			||||||
 | 
					                    ],
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                "results": schema,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "required": ["pagination", "results"],
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
				
			|||||||
@ -1,97 +0,0 @@
 | 
				
			|||||||
"""Swagger Pagination Schema class"""
 | 
					 | 
				
			||||||
from typing import OrderedDict
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from drf_yasg import openapi
 | 
					 | 
				
			||||||
from drf_yasg.inspectors import PaginatorInspector
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class PaginationInspector(PaginatorInspector):
 | 
					 | 
				
			||||||
    """Swagger Pagination Schema class"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_paginated_response(self, paginator, response_schema):
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        :param BasePagination paginator: the paginator
 | 
					 | 
				
			||||||
        :param openapi.Schema response_schema: the response schema that must be paged.
 | 
					 | 
				
			||||||
        :rtype: openapi.Schema
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return openapi.Schema(
 | 
					 | 
				
			||||||
            type=openapi.TYPE_OBJECT,
 | 
					 | 
				
			||||||
            properties=OrderedDict(
 | 
					 | 
				
			||||||
                (
 | 
					 | 
				
			||||||
                    (
 | 
					 | 
				
			||||||
                        "pagination",
 | 
					 | 
				
			||||||
                        openapi.Schema(
 | 
					 | 
				
			||||||
                            type=openapi.TYPE_OBJECT,
 | 
					 | 
				
			||||||
                            properties=OrderedDict(
 | 
					 | 
				
			||||||
                                (
 | 
					 | 
				
			||||||
                                    ("next", openapi.Schema(type=openapi.TYPE_NUMBER)),
 | 
					 | 
				
			||||||
                                    (
 | 
					 | 
				
			||||||
                                        "previous",
 | 
					 | 
				
			||||||
                                        openapi.Schema(type=openapi.TYPE_NUMBER),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                    ("count", openapi.Schema(type=openapi.TYPE_NUMBER)),
 | 
					 | 
				
			||||||
                                    (
 | 
					 | 
				
			||||||
                                        "current",
 | 
					 | 
				
			||||||
                                        openapi.Schema(type=openapi.TYPE_NUMBER),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                    (
 | 
					 | 
				
			||||||
                                        "total_pages",
 | 
					 | 
				
			||||||
                                        openapi.Schema(type=openapi.TYPE_NUMBER),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                    (
 | 
					 | 
				
			||||||
                                        "start_index",
 | 
					 | 
				
			||||||
                                        openapi.Schema(type=openapi.TYPE_NUMBER),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                    (
 | 
					 | 
				
			||||||
                                        "end_index",
 | 
					 | 
				
			||||||
                                        openapi.Schema(type=openapi.TYPE_NUMBER),
 | 
					 | 
				
			||||||
                                    ),
 | 
					 | 
				
			||||||
                                )
 | 
					 | 
				
			||||||
                            ),
 | 
					 | 
				
			||||||
                            required=[
 | 
					 | 
				
			||||||
                                "next",
 | 
					 | 
				
			||||||
                                "previous",
 | 
					 | 
				
			||||||
                                "count",
 | 
					 | 
				
			||||||
                                "current",
 | 
					 | 
				
			||||||
                                "total_pages",
 | 
					 | 
				
			||||||
                                "start_index",
 | 
					 | 
				
			||||||
                                "end_index",
 | 
					 | 
				
			||||||
                            ],
 | 
					 | 
				
			||||||
                        ),
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                    ("results", response_schema),
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            required=["results", "pagination"],
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_paginator_parameters(self, paginator):
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Get the pagination parameters for a single paginator **instance**.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Should return :data:`.NotHandled` if this inspector
 | 
					 | 
				
			||||||
        does not know how to handle the given `paginator`.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        :param BasePagination paginator: the paginator
 | 
					 | 
				
			||||||
        :rtype: list[openapi.Parameter]
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return [
 | 
					 | 
				
			||||||
            openapi.Parameter(
 | 
					 | 
				
			||||||
                "page",
 | 
					 | 
				
			||||||
                openapi.IN_QUERY,
 | 
					 | 
				
			||||||
                "Page Index",
 | 
					 | 
				
			||||||
                False,
 | 
					 | 
				
			||||||
                None,
 | 
					 | 
				
			||||||
                openapi.TYPE_INTEGER,
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
            openapi.Parameter(
 | 
					 | 
				
			||||||
                "page_size",
 | 
					 | 
				
			||||||
                openapi.IN_QUERY,
 | 
					 | 
				
			||||||
                "Page Size",
 | 
					 | 
				
			||||||
                False,
 | 
					 | 
				
			||||||
                None,
 | 
					 | 
				
			||||||
                openapi.TYPE_INTEGER,
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
@ -1,102 +1,77 @@
 | 
				
			|||||||
"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
 | 
					"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
 | 
				
			||||||
from drf_yasg import openapi
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from drf_yasg.inspectors.view import SwaggerAutoSchema
 | 
					from drf_spectacular.plumbing import (
 | 
				
			||||||
from drf_yasg.utils import force_real_str, is_list_view
 | 
					    ResolvedComponent,
 | 
				
			||||||
from rest_framework import exceptions, status
 | 
					    build_array_type,
 | 
				
			||||||
from rest_framework.settings import api_settings
 | 
					    build_basic_type,
 | 
				
			||||||
 | 
					    build_object_type,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from drf_spectacular.settings import spectacular_settings
 | 
				
			||||||
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ErrorResponseAutoSchema(SwaggerAutoSchema):
 | 
					def build_standard_type(obj, **kwargs):
 | 
				
			||||||
    """Inspector which includes an error schema"""
 | 
					    """Build a basic type with optional add ons."""
 | 
				
			||||||
 | 
					    schema = build_basic_type(obj)
 | 
				
			||||||
 | 
					    schema.update(kwargs)
 | 
				
			||||||
 | 
					    return schema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_generic_error_schema(self):
 | 
					
 | 
				
			||||||
        """Get a generic error schema"""
 | 
					GENERIC_ERROR = build_object_type(
 | 
				
			||||||
        return openapi.Schema(
 | 
					    description=_("Generic API Error"),
 | 
				
			||||||
            "Generic API Error",
 | 
					    properties={
 | 
				
			||||||
            type=openapi.TYPE_OBJECT,
 | 
					        "detail": build_standard_type(OpenApiTypes.STR),
 | 
				
			||||||
            properties={
 | 
					        "code": build_standard_type(OpenApiTypes.STR),
 | 
				
			||||||
                "detail": openapi.Schema(
 | 
					    },
 | 
				
			||||||
                    type=openapi.TYPE_STRING, description="Error details"
 | 
					    required=["detail"],
 | 
				
			||||||
                ),
 | 
					)
 | 
				
			||||||
                "code": openapi.Schema(
 | 
					VALIDATION_ERROR = build_object_type(
 | 
				
			||||||
                    type=openapi.TYPE_STRING, description="Error code"
 | 
					    description=_("Validation Error"),
 | 
				
			||||||
                ),
 | 
					    properties={
 | 
				
			||||||
            },
 | 
					        "non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)),
 | 
				
			||||||
            required=["detail"],
 | 
					        "code": build_standard_type(OpenApiTypes.STR),
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    required=["detail"],
 | 
				
			||||||
 | 
					    additionalProperties={},
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def postprocess_schema_responses(result, generator, **kwargs):  # noqa: W0613
 | 
				
			||||||
 | 
					    """Workaround to set a default response for endpoints.
 | 
				
			||||||
 | 
					    Workaround suggested at
 | 
				
			||||||
 | 
					    <https://github.com/tfranzel/drf-spectacular/issues/119#issuecomment-656970357>
 | 
				
			||||||
 | 
					    for the missing drf-spectacular feature discussed in
 | 
				
			||||||
 | 
					    <https://github.com/tfranzel/drf-spectacular/issues/101>.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create_component(name, schema, type_=ResolvedComponent.SCHEMA):
 | 
				
			||||||
 | 
					        """Register a component and return a reference to it."""
 | 
				
			||||||
 | 
					        component = ResolvedComponent(
 | 
				
			||||||
 | 
					            name=name,
 | 
				
			||||||
 | 
					            type=type_,
 | 
				
			||||||
 | 
					            schema=schema,
 | 
				
			||||||
 | 
					            object=name,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        generator.registry.register_on_missing(component)
 | 
				
			||||||
 | 
					        return component
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_validation_error_schema(self):
 | 
					    generic_error = create_component("GenericError", GENERIC_ERROR)
 | 
				
			||||||
        """Get a generic validation error schema"""
 | 
					    validation_error = create_component("ValidationError", VALIDATION_ERROR)
 | 
				
			||||||
        return openapi.Schema(
 | 
					 | 
				
			||||||
            "Validation Error",
 | 
					 | 
				
			||||||
            type=openapi.TYPE_OBJECT,
 | 
					 | 
				
			||||||
            properties={
 | 
					 | 
				
			||||||
                api_settings.NON_FIELD_ERRORS_KEY: openapi.Schema(
 | 
					 | 
				
			||||||
                    description="List of validation errors not related to any field",
 | 
					 | 
				
			||||||
                    type=openapi.TYPE_ARRAY,
 | 
					 | 
				
			||||||
                    items=openapi.Schema(type=openapi.TYPE_STRING),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            additional_properties=openapi.Schema(
 | 
					 | 
				
			||||||
                description=(
 | 
					 | 
				
			||||||
                    "A list of error messages for each "
 | 
					 | 
				
			||||||
                    "field that triggered a validation error"
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                type=openapi.TYPE_ARRAY,
 | 
					 | 
				
			||||||
                items=openapi.Schema(type=openapi.TYPE_STRING),
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_response_serializers(self):
 | 
					    for path in result["paths"].values():
 | 
				
			||||||
        responses = super().get_response_serializers()
 | 
					        for method in path.values():
 | 
				
			||||||
        definitions = self.components.with_scope(
 | 
					            method["responses"].setdefault("400", validation_error.ref)
 | 
				
			||||||
            openapi.SCHEMA_DEFINITIONS
 | 
					            method["responses"].setdefault("403", generic_error.ref)
 | 
				
			||||||
        )  # type: openapi.ReferenceResolver
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        definitions.setdefault("GenericError", self.get_generic_error_schema)
 | 
					    result["components"] = generator.registry.build(
 | 
				
			||||||
        definitions.setdefault("ValidationError", self.get_validation_error_schema)
 | 
					        spectacular_settings.APPEND_COMPONENTS
 | 
				
			||||||
        definitions.setdefault("APIException", self.get_generic_error_schema)
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if self.get_request_serializer() or self.get_query_serializer():
 | 
					    # This is a workaround for authentik/stages/prompt/stage.py
 | 
				
			||||||
            responses.setdefault(
 | 
					    # since the serializer PromptChallengeResponse
 | 
				
			||||||
                exceptions.ValidationError.status_code,
 | 
					    # accepts dynamic keys
 | 
				
			||||||
                openapi.Response(
 | 
					    for component in result["components"]["schemas"]:
 | 
				
			||||||
                    description=force_real_str(
 | 
					        if component == "PromptChallengeResponseRequest":
 | 
				
			||||||
                        exceptions.ValidationError.default_detail
 | 
					            comp = result["components"]["schemas"][component]
 | 
				
			||||||
                    ),
 | 
					            comp["additionalProperties"] = {}
 | 
				
			||||||
                    schema=openapi.SchemaRef(definitions, "ValidationError"),
 | 
					    return result
 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        security = self.get_security()
 | 
					 | 
				
			||||||
        if security is None or len(security) > 0:
 | 
					 | 
				
			||||||
            # Note: 401 error codes are coerced  into 403 see
 | 
					 | 
				
			||||||
            # rest_framework/views.py:433:handle_exception
 | 
					 | 
				
			||||||
            # This is b/c the API uses token auth which doesn't have WWW-Authenticate header
 | 
					 | 
				
			||||||
            responses.setdefault(
 | 
					 | 
				
			||||||
                status.HTTP_403_FORBIDDEN,
 | 
					 | 
				
			||||||
                openapi.Response(
 | 
					 | 
				
			||||||
                    description="Authentication credentials were invalid, absent or insufficient.",
 | 
					 | 
				
			||||||
                    schema=openapi.SchemaRef(definitions, "GenericError"),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        if not is_list_view(self.path, self.method, self.view):
 | 
					 | 
				
			||||||
            responses.setdefault(
 | 
					 | 
				
			||||||
                exceptions.PermissionDenied.status_code,
 | 
					 | 
				
			||||||
                openapi.Response(
 | 
					 | 
				
			||||||
                    description="Permission denied.",
 | 
					 | 
				
			||||||
                    schema=openapi.SchemaRef(definitions, "APIException"),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            responses.setdefault(
 | 
					 | 
				
			||||||
                exceptions.NotFound.status_code,
 | 
					 | 
				
			||||||
                openapi.Response(
 | 
					 | 
				
			||||||
                    description=(
 | 
					 | 
				
			||||||
                        "Object does not exist or caller "
 | 
					 | 
				
			||||||
                        "has insufficient permissions to access it."
 | 
					 | 
				
			||||||
                    ),
 | 
					 | 
				
			||||||
                    schema=openapi.SchemaRef(definitions, "APIException"),
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return responses
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@
 | 
				
			|||||||
{% load static %}
 | 
					{% load static %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block title %}
 | 
					{% block title %}
 | 
				
			||||||
authentik API Browser
 | 
					API Browser - {{ tenant.branding_title }}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					{% block head %}
 | 
				
			||||||
@ -5,7 +5,7 @@ from django.test import TestCase
 | 
				
			|||||||
from guardian.shortcuts import get_anonymous_user
 | 
					from guardian.shortcuts import get_anonymous_user
 | 
				
			||||||
from rest_framework.exceptions import AuthenticationFailed
 | 
					from rest_framework.exceptions import AuthenticationFailed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.auth import token_from_header
 | 
					from authentik.api.authentication import token_from_header
 | 
				
			||||||
from authentik.core.models import Token, TokenIntents
 | 
					from authentik.core.models import Token, TokenIntents
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										16
									
								
								authentik/api/tests/test_config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								authentik/api/tests/test_config.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					"""Test config API"""
 | 
				
			||||||
 | 
					from json import loads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestConfig(APITestCase):
 | 
				
			||||||
 | 
					    """Test config API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_config(self):
 | 
				
			||||||
 | 
					        """Test YAML generation"""
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:config"),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertTrue(loads(response.content.decode()))
 | 
				
			||||||
							
								
								
									
										33
									
								
								authentik/api/tests/test_decorators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								authentik/api/tests/test_decorators.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
				
			|||||||
 | 
					"""test decorators api"""
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from guardian.shortcuts import assign_perm
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import Application, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestAPIDecorators(APITestCase):
 | 
				
			||||||
 | 
					    """test decorators api"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					        self.user = User.objects.create(username="test-user")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_obj_perm_denied(self):
 | 
				
			||||||
 | 
					        """Test object perm denied"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        app = Application.objects.create(name="denied", slug="denied")
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_other_perm_denied(self):
 | 
				
			||||||
 | 
					        """Test other perm denied"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        app = Application.objects.create(name="denied", slug="denied")
 | 
				
			||||||
 | 
					        assign_perm("authentik_core.view_application", self.user, app)
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
							
								
								
									
										22
									
								
								authentik/api/tests/test_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								authentik/api/tests/test_schema.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					"""Schema generation tests"""
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					from yaml import safe_load
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestSchemaGeneration(APITestCase):
 | 
				
			||||||
 | 
					    """Generic admin tests"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_schema(self):
 | 
				
			||||||
 | 
					        """Test generation"""
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:schema"),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertTrue(safe_load(response.content.decode()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_browser(self):
 | 
				
			||||||
 | 
					        """Test API Browser"""
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:schema-browser"),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
@ -1,24 +0,0 @@
 | 
				
			|||||||
"""Swagger generation tests"""
 | 
					 | 
				
			||||||
from json import loads
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.urls import reverse
 | 
					 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					 | 
				
			||||||
from yaml import safe_load
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestSwaggerGeneration(APITestCase):
 | 
					 | 
				
			||||||
    """Generic admin tests"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_yaml(self):
 | 
					 | 
				
			||||||
        """Test YAML generation"""
 | 
					 | 
				
			||||||
        response = self.client.get(
 | 
					 | 
				
			||||||
            reverse("authentik_api:schema-json", kwargs={"format": ".yaml"}),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertTrue(safe_load(response.content.decode()))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_json(self):
 | 
					 | 
				
			||||||
        """Test JSON generation"""
 | 
					 | 
				
			||||||
        response = self.client.get(
 | 
					 | 
				
			||||||
            reverse("authentik_api:schema-json", kwargs={"format": ".json"}),
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.assertTrue(loads(response.content.decode()))
 | 
					 | 
				
			||||||
@ -1,50 +1,70 @@
 | 
				
			|||||||
"""core Configs API"""
 | 
					"""core Configs API"""
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from os import environ, path
 | 
				
			||||||
from rest_framework.fields import BooleanField, CharField, ListField
 | 
					
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db import models
 | 
				
			||||||
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
 | 
					from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
 | 
				
			||||||
 | 
					from rest_framework.fields import BooleanField, CharField, ChoiceField, ListField
 | 
				
			||||||
from rest_framework.permissions import AllowAny
 | 
					from rest_framework.permissions import AllowAny
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.viewsets import ViewSet
 | 
					from rest_framework.views import APIView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
 | 
					from authentik.events.geo import GEOIP_READER
 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FooterLinkSerializer(PassiveSerializer):
 | 
					class Capabilities(models.TextChoices):
 | 
				
			||||||
    """Links returned in Config API"""
 | 
					    """Define capabilities which influence which APIs can/should be used"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    href = CharField(read_only=True)
 | 
					    CAN_SAVE_MEDIA = "can_save_media"
 | 
				
			||||||
    name = CharField(read_only=True)
 | 
					    CAN_GEO_IP = "can_geo_ip"
 | 
				
			||||||
 | 
					    CAN_BACKUP = "can_backup"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ConfigSerializer(PassiveSerializer):
 | 
					class ConfigSerializer(PassiveSerializer):
 | 
				
			||||||
    """Serialize authentik Config into DRF Object"""
 | 
					    """Serialize authentik Config into DRF Object"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    branding_logo = CharField(read_only=True)
 | 
					 | 
				
			||||||
    branding_title = CharField(read_only=True)
 | 
					 | 
				
			||||||
    ui_footer_links = ListField(child=FooterLinkSerializer(), read_only=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    error_reporting_enabled = BooleanField(read_only=True)
 | 
					    error_reporting_enabled = BooleanField(read_only=True)
 | 
				
			||||||
    error_reporting_environment = CharField(read_only=True)
 | 
					    error_reporting_environment = CharField(read_only=True)
 | 
				
			||||||
    error_reporting_send_pii = BooleanField(read_only=True)
 | 
					    error_reporting_send_pii = BooleanField(read_only=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ConfigsViewSet(ViewSet):
 | 
					
 | 
				
			||||||
 | 
					class ConfigView(APIView):
 | 
				
			||||||
    """Read-only view set that returns the current session's Configs"""
 | 
					    """Read-only view set that returns the current session's Configs"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    permission_classes = [AllowAny]
 | 
					    permission_classes = [AllowAny]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: ConfigSerializer(many=False)})
 | 
					    def get_capabilities(self) -> list[Capabilities]:
 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					        """Get all capabilities this server instance supports"""
 | 
				
			||||||
 | 
					        caps = []
 | 
				
			||||||
 | 
					        deb_test = settings.DEBUG or settings.TEST
 | 
				
			||||||
 | 
					        if path.ismount(settings.MEDIA_ROOT) or deb_test:
 | 
				
			||||||
 | 
					            caps.append(Capabilities.CAN_SAVE_MEDIA)
 | 
				
			||||||
 | 
					        if GEOIP_READER.enabled:
 | 
				
			||||||
 | 
					            caps.append(Capabilities.CAN_GEO_IP)
 | 
				
			||||||
 | 
					        if SERVICE_HOST_ENV_NAME in environ:
 | 
				
			||||||
 | 
					            # Running in k8s, only s3 backup is supported
 | 
				
			||||||
 | 
					            if CONFIG.y("postgresql.s3_backup"):
 | 
				
			||||||
 | 
					                caps.append(Capabilities.CAN_BACKUP)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Running in compose, backup is always supported
 | 
				
			||||||
 | 
					            caps.append(Capabilities.CAN_BACKUP)
 | 
				
			||||||
 | 
					        return caps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @extend_schema(responses={200: ConfigSerializer(many=False)})
 | 
				
			||||||
 | 
					    def get(self, request: Request) -> Response:
 | 
				
			||||||
        """Retrive public configuration options"""
 | 
					        """Retrive public configuration options"""
 | 
				
			||||||
        config = ConfigSerializer(
 | 
					        config = ConfigSerializer(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "branding_logo": CONFIG.y("authentik.branding.logo"),
 | 
					 | 
				
			||||||
                "branding_title": CONFIG.y("authentik.branding.title"),
 | 
					 | 
				
			||||||
                "error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
 | 
					                "error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
 | 
				
			||||||
                "error_reporting_environment": CONFIG.y("error_reporting.environment"),
 | 
					                "error_reporting_environment": CONFIG.y("error_reporting.environment"),
 | 
				
			||||||
                "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"),
 | 
					                "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"),
 | 
				
			||||||
                "ui_footer_links": CONFIG.y("authentik.footer_links"),
 | 
					                "capabilities": self.get_capabilities(),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return Response(config.data)
 | 
					        return Response(config.data)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,18 +1,18 @@
 | 
				
			|||||||
"""api v2 urls"""
 | 
					"""api v2 urls"""
 | 
				
			||||||
from django.urls import path, re_path
 | 
					from django.urls import path
 | 
				
			||||||
from drf_yasg import openapi
 | 
					from drf_spectacular.views import SpectacularAPIView
 | 
				
			||||||
from drf_yasg.views import get_schema_view
 | 
					 | 
				
			||||||
from rest_framework import routers
 | 
					from rest_framework import routers
 | 
				
			||||||
from rest_framework.permissions import AllowAny
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.admin.api.meta import AppsViewSet
 | 
					from authentik.admin.api.meta import AppsViewSet
 | 
				
			||||||
from authentik.admin.api.metrics import AdministrationMetricsViewSet
 | 
					from authentik.admin.api.metrics import AdministrationMetricsViewSet
 | 
				
			||||||
 | 
					from authentik.admin.api.system import SystemView
 | 
				
			||||||
from authentik.admin.api.tasks import TaskViewSet
 | 
					from authentik.admin.api.tasks import TaskViewSet
 | 
				
			||||||
from authentik.admin.api.version import VersionViewSet
 | 
					from authentik.admin.api.version import VersionView
 | 
				
			||||||
from authentik.admin.api.workers import WorkerViewSet
 | 
					from authentik.admin.api.workers import WorkerView
 | 
				
			||||||
from authentik.api.v2.config import ConfigsViewSet
 | 
					from authentik.api.v2.config import ConfigView
 | 
				
			||||||
from authentik.api.views import SwaggerView
 | 
					from authentik.api.views import APIBrowserView
 | 
				
			||||||
from authentik.core.api.applications import ApplicationViewSet
 | 
					from authentik.core.api.applications import ApplicationViewSet
 | 
				
			||||||
 | 
					from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
 | 
				
			||||||
from authentik.core.api.groups import GroupViewSet
 | 
					from authentik.core.api.groups import GroupViewSet
 | 
				
			||||||
from authentik.core.api.propertymappings import PropertyMappingViewSet
 | 
					from authentik.core.api.propertymappings import PropertyMappingViewSet
 | 
				
			||||||
from authentik.core.api.providers import ProviderViewSet
 | 
					from authentik.core.api.providers import ProviderViewSet
 | 
				
			||||||
@ -28,12 +28,12 @@ from authentik.flows.api.bindings import FlowStageBindingViewSet
 | 
				
			|||||||
from authentik.flows.api.flows import FlowViewSet
 | 
					from authentik.flows.api.flows import FlowViewSet
 | 
				
			||||||
from authentik.flows.api.stages import StageViewSet
 | 
					from authentik.flows.api.stages import StageViewSet
 | 
				
			||||||
from authentik.flows.views import FlowExecutorView
 | 
					from authentik.flows.views import FlowExecutorView
 | 
				
			||||||
from authentik.outposts.api.outpost_service_connections import (
 | 
					from authentik.outposts.api.outposts import OutpostViewSet
 | 
				
			||||||
 | 
					from authentik.outposts.api.service_connections import (
 | 
				
			||||||
    DockerServiceConnectionViewSet,
 | 
					    DockerServiceConnectionViewSet,
 | 
				
			||||||
    KubernetesServiceConnectionViewSet,
 | 
					    KubernetesServiceConnectionViewSet,
 | 
				
			||||||
    ServiceConnectionViewSet,
 | 
					    ServiceConnectionViewSet,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.outposts.api.outposts import OutpostViewSet
 | 
					 | 
				
			||||||
from authentik.policies.api.bindings import PolicyBindingViewSet
 | 
					from authentik.policies.api.bindings import PolicyBindingViewSet
 | 
				
			||||||
from authentik.policies.api.policies import PolicyViewSet
 | 
					from authentik.policies.api.policies import PolicyViewSet
 | 
				
			||||||
from authentik.policies.dummy.api import DummyPolicyViewSet
 | 
					from authentik.policies.dummy.api import DummyPolicyViewSet
 | 
				
			||||||
@ -47,6 +47,7 @@ from authentik.policies.reputation.api import (
 | 
				
			|||||||
    ReputationPolicyViewSet,
 | 
					    ReputationPolicyViewSet,
 | 
				
			||||||
    UserReputationViewSet,
 | 
					    UserReputationViewSet,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
 | 
				
			||||||
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
 | 
					from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
 | 
				
			||||||
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
 | 
					from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
 | 
				
			||||||
from authentik.providers.oauth2.api.tokens import (
 | 
					from authentik.providers.oauth2.api.tokens import (
 | 
				
			||||||
@ -63,7 +64,13 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet
 | 
				
			|||||||
from authentik.sources.oauth.api.source_connection import (
 | 
					from authentik.sources.oauth.api.source_connection import (
 | 
				
			||||||
    UserOAuthSourceConnectionViewSet,
 | 
					    UserOAuthSourceConnectionViewSet,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from authentik.sources.plex.api import PlexSourceViewSet
 | 
				
			||||||
from authentik.sources.saml.api import SAMLSourceViewSet
 | 
					from authentik.sources.saml.api import SAMLSourceViewSet
 | 
				
			||||||
 | 
					from authentik.stages.authenticator_duo.api import (
 | 
				
			||||||
 | 
					    AuthenticatorDuoStageViewSet,
 | 
				
			||||||
 | 
					    DuoAdminDeviceViewSet,
 | 
				
			||||||
 | 
					    DuoDeviceViewSet,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from authentik.stages.authenticator_static.api import (
 | 
					from authentik.stages.authenticator_static.api import (
 | 
				
			||||||
    AuthenticatorStaticStageViewSet,
 | 
					    AuthenticatorStaticStageViewSet,
 | 
				
			||||||
    StaticAdminDeviceViewSet,
 | 
					    StaticAdminDeviceViewSet,
 | 
				
			||||||
@ -95,24 +102,21 @@ from authentik.stages.user_delete.api import UserDeleteStageViewSet
 | 
				
			|||||||
from authentik.stages.user_login.api import UserLoginStageViewSet
 | 
					from authentik.stages.user_login.api import UserLoginStageViewSet
 | 
				
			||||||
from authentik.stages.user_logout.api import UserLogoutStageViewSet
 | 
					from authentik.stages.user_logout.api import UserLogoutStageViewSet
 | 
				
			||||||
from authentik.stages.user_write.api import UserWriteStageViewSet
 | 
					from authentik.stages.user_write.api import UserWriteStageViewSet
 | 
				
			||||||
 | 
					from authentik.tenants.api import TenantViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
router = routers.DefaultRouter()
 | 
					router = routers.DefaultRouter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
router.register("root/config", ConfigsViewSet, basename="configs")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
router.register("admin/version", VersionViewSet, basename="admin_version")
 | 
					 | 
				
			||||||
router.register("admin/workers", WorkerViewSet, basename="admin_workers")
 | 
					 | 
				
			||||||
router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics")
 | 
					 | 
				
			||||||
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
 | 
					router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
 | 
				
			||||||
router.register("admin/apps", AppsViewSet, basename="apps")
 | 
					router.register("admin/apps", AppsViewSet, basename="apps")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					router.register("core/authenticated_sessions", AuthenticatedSessionViewSet)
 | 
				
			||||||
router.register("core/applications", ApplicationViewSet)
 | 
					router.register("core/applications", ApplicationViewSet)
 | 
				
			||||||
router.register("core/groups", GroupViewSet)
 | 
					router.register("core/groups", GroupViewSet)
 | 
				
			||||||
router.register("core/users", UserViewSet)
 | 
					router.register("core/users", UserViewSet)
 | 
				
			||||||
router.register("core/user_consent", UserConsentViewSet)
 | 
					router.register("core/user_consent", UserConsentViewSet)
 | 
				
			||||||
router.register("core/tokens", TokenViewSet)
 | 
					router.register("core/tokens", TokenViewSet)
 | 
				
			||||||
 | 
					router.register("core/tenants", TenantViewSet)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
router.register("outposts/outposts", OutpostViewSet)
 | 
					 | 
				
			||||||
router.register("outposts/instances", OutpostViewSet)
 | 
					router.register("outposts/instances", OutpostViewSet)
 | 
				
			||||||
router.register("outposts/service_connections/all", ServiceConnectionViewSet)
 | 
					router.register("outposts/service_connections/all", ServiceConnectionViewSet)
 | 
				
			||||||
router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)
 | 
					router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)
 | 
				
			||||||
@ -120,6 +124,7 @@ router.register(
 | 
				
			|||||||
    "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet
 | 
					    "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
router.register("outposts/proxy", ProxyOutpostConfigViewSet)
 | 
					router.register("outposts/proxy", ProxyOutpostConfigViewSet)
 | 
				
			||||||
 | 
					router.register("outposts/ldap", LDAPOutpostConfigViewSet)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
router.register("flows/instances", FlowViewSet)
 | 
					router.register("flows/instances", FlowViewSet)
 | 
				
			||||||
router.register("flows/bindings", FlowStageBindingViewSet)
 | 
					router.register("flows/bindings", FlowStageBindingViewSet)
 | 
				
			||||||
@ -136,6 +141,7 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS
 | 
				
			|||||||
router.register("sources/ldap", LDAPSourceViewSet)
 | 
					router.register("sources/ldap", LDAPSourceViewSet)
 | 
				
			||||||
router.register("sources/saml", SAMLSourceViewSet)
 | 
					router.register("sources/saml", SAMLSourceViewSet)
 | 
				
			||||||
router.register("sources/oauth", OAuthSourceViewSet)
 | 
					router.register("sources/oauth", OAuthSourceViewSet)
 | 
				
			||||||
 | 
					router.register("sources/plex", PlexSourceViewSet)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
router.register("policies/all", PolicyViewSet)
 | 
					router.register("policies/all", PolicyViewSet)
 | 
				
			||||||
router.register("policies/bindings", PolicyBindingViewSet)
 | 
					router.register("policies/bindings", PolicyBindingViewSet)
 | 
				
			||||||
@ -149,6 +155,7 @@ router.register("policies/reputation/ips", IPReputationViewSet)
 | 
				
			|||||||
router.register("policies/reputation", ReputationPolicyViewSet)
 | 
					router.register("policies/reputation", ReputationPolicyViewSet)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
router.register("providers/all", ProviderViewSet)
 | 
					router.register("providers/all", ProviderViewSet)
 | 
				
			||||||
 | 
					router.register("providers/ldap", LDAPProviderViewSet)
 | 
				
			||||||
router.register("providers/proxy", ProxyProviderViewSet)
 | 
					router.register("providers/proxy", ProxyProviderViewSet)
 | 
				
			||||||
router.register("providers/oauth2", OAuth2ProviderViewSet)
 | 
					router.register("providers/oauth2", OAuth2ProviderViewSet)
 | 
				
			||||||
router.register("providers/saml", SAMLProviderViewSet)
 | 
					router.register("providers/saml", SAMLProviderViewSet)
 | 
				
			||||||
@ -161,14 +168,31 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
 | 
				
			|||||||
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
 | 
					router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
 | 
				
			||||||
router.register("propertymappings/scope", ScopeMappingViewSet)
 | 
					router.register("propertymappings/scope", ScopeMappingViewSet)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					router.register("authenticators/duo", DuoDeviceViewSet)
 | 
				
			||||||
router.register("authenticators/static", StaticDeviceViewSet)
 | 
					router.register("authenticators/static", StaticDeviceViewSet)
 | 
				
			||||||
router.register("authenticators/totp", TOTPDeviceViewSet)
 | 
					router.register("authenticators/totp", TOTPDeviceViewSet)
 | 
				
			||||||
router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
 | 
					router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
 | 
				
			||||||
router.register("authenticators/admin/static", StaticAdminDeviceViewSet)
 | 
					router.register(
 | 
				
			||||||
router.register("authenticators/admin/totp", TOTPAdminDeviceViewSet)
 | 
					    "authenticators/admin/duo",
 | 
				
			||||||
router.register("authenticators/admin/webauthn", WebAuthnAdminDeviceViewSet)
 | 
					    DuoAdminDeviceViewSet,
 | 
				
			||||||
 | 
					    basename="admin-duodevice",
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					router.register(
 | 
				
			||||||
 | 
					    "authenticators/admin/static",
 | 
				
			||||||
 | 
					    StaticAdminDeviceViewSet,
 | 
				
			||||||
 | 
					    basename="admin-staticdevice",
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					router.register(
 | 
				
			||||||
 | 
					    "authenticators/admin/totp", TOTPAdminDeviceViewSet, basename="admin-totpdevice"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					router.register(
 | 
				
			||||||
 | 
					    "authenticators/admin/webauthn",
 | 
				
			||||||
 | 
					    WebAuthnAdminDeviceViewSet,
 | 
				
			||||||
 | 
					    basename="admin-webauthndevice",
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
router.register("stages/all", StageViewSet)
 | 
					router.register("stages/all", StageViewSet)
 | 
				
			||||||
 | 
					router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet)
 | 
				
			||||||
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
 | 
					router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
 | 
				
			||||||
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
 | 
					router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
 | 
				
			||||||
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
 | 
					router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
 | 
				
			||||||
@ -191,32 +215,26 @@ router.register("stages/user_write", UserWriteStageViewSet)
 | 
				
			|||||||
router.register("stages/dummy", DummyStageViewSet)
 | 
					router.register("stages/dummy", DummyStageViewSet)
 | 
				
			||||||
router.register("policies/dummy", DummyPolicyViewSet)
 | 
					router.register("policies/dummy", DummyPolicyViewSet)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
info = openapi.Info(
 | 
					 | 
				
			||||||
    title="authentik API",
 | 
					 | 
				
			||||||
    default_version="v2beta",
 | 
					 | 
				
			||||||
    contact=openapi.Contact(email="hello@beryju.org"),
 | 
					 | 
				
			||||||
    license=openapi.License(
 | 
					 | 
				
			||||||
        name="GNU GPLv3",
 | 
					 | 
				
			||||||
        url="https://github.com/goauthentik/authentik/blob/master/LICENSE",
 | 
					 | 
				
			||||||
    ),
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
SchemaView = get_schema_view(info, public=True, permission_classes=(AllowAny,))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
urlpatterns = (
 | 
					urlpatterns = (
 | 
				
			||||||
    [
 | 
					    [
 | 
				
			||||||
        path("", SwaggerView.as_view(), name="swagger"),
 | 
					        path("", APIBrowserView.as_view(), name="schema-browser"),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
    + router.urls
 | 
					    + router.urls
 | 
				
			||||||
    + [
 | 
					    + [
 | 
				
			||||||
 | 
					        path(
 | 
				
			||||||
 | 
					            "admin/metrics/",
 | 
				
			||||||
 | 
					            AdministrationMetricsViewSet.as_view(),
 | 
				
			||||||
 | 
					            name="admin_metrics",
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        path("admin/version/", VersionView.as_view(), name="admin_version"),
 | 
				
			||||||
 | 
					        path("admin/workers/", WorkerView.as_view(), name="admin_workers"),
 | 
				
			||||||
 | 
					        path("admin/system/", SystemView.as_view(), name="admin_system"),
 | 
				
			||||||
 | 
					        path("root/config/", ConfigView.as_view(), name="config"),
 | 
				
			||||||
        path(
 | 
					        path(
 | 
				
			||||||
            "flows/executor/<slug:flow_slug>/",
 | 
					            "flows/executor/<slug:flow_slug>/",
 | 
				
			||||||
            FlowExecutorView.as_view(),
 | 
					            FlowExecutorView.as_view(),
 | 
				
			||||||
            name="flow-executor",
 | 
					            name="flow-executor",
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        re_path(
 | 
					        path("schema/", SpectacularAPIView.as_view(), name="schema"),
 | 
				
			||||||
            r"^swagger(?P<format>\.json|\.yaml)$",
 | 
					 | 
				
			||||||
            SchemaView.without_ui(cache_timeout=0),
 | 
					 | 
				
			||||||
            name="schema-json",
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
				
			|||||||
@ -5,18 +5,15 @@ from django.urls import reverse
 | 
				
			|||||||
from django.views.generic import TemplateView
 | 
					from django.views.generic import TemplateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SwaggerView(TemplateView):
 | 
					class APIBrowserView(TemplateView):
 | 
				
			||||||
    """Show swagger view based on rapi-doc"""
 | 
					    """Show browser view based on rapi-doc"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    template_name = "api/swagger.html"
 | 
					    template_name = "api/browser.html"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
 | 
					    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
 | 
				
			||||||
        path = self.request.build_absolute_uri(
 | 
					        path = self.request.build_absolute_uri(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
                "authentik_api:schema-json",
 | 
					                "authentik_api:schema",
 | 
				
			||||||
                kwargs={
 | 
					 | 
				
			||||||
                    "format": ".json",
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return super().get_context_data(path=path, **kwargs)
 | 
					        return super().get_context_data(path=path, **kwargs)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,17 @@
 | 
				
			|||||||
"""Application API Views"""
 | 
					"""Application API Views"""
 | 
				
			||||||
from typing import Optional
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.db.models import QuerySet
 | 
					from django.db.models import QuerySet
 | 
				
			||||||
from django.http.response import HttpResponseBadRequest
 | 
					from django.http.response import HttpResponseBadRequest
 | 
				
			||||||
from drf_yasg import openapi
 | 
					from django.shortcuts import get_object_or_404
 | 
				
			||||||
from drf_yasg.utils import no_body, swagger_auto_schema
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
 | 
					from drf_spectacular.utils import (
 | 
				
			||||||
 | 
					    OpenApiParameter,
 | 
				
			||||||
 | 
					    OpenApiResponse,
 | 
				
			||||||
 | 
					    extend_schema,
 | 
				
			||||||
 | 
					    inline_serializer,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import SerializerMethodField
 | 
					from rest_framework.fields import BooleanField, CharField, FileField, ReadOnlyField
 | 
				
			||||||
from rest_framework.parsers import MultiPartParser
 | 
					from rest_framework.parsers import MultiPartParser
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
@ -19,9 +23,13 @@ from structlog.stdlib import get_logger
 | 
				
			|||||||
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
 | 
					from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
from authentik.core.api.providers import ProviderSerializer
 | 
					from authentik.core.api.providers import ProviderSerializer
 | 
				
			||||||
from authentik.core.models import Application
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
 | 
					from authentik.core.models import Application, User
 | 
				
			||||||
from authentik.events.models import EventAction
 | 
					from authentik.events.models import EventAction
 | 
				
			||||||
 | 
					from authentik.policies.api.exec import PolicyTestResultSerializer
 | 
				
			||||||
from authentik.policies.engine import PolicyEngine
 | 
					from authentik.policies.engine import PolicyEngine
 | 
				
			||||||
 | 
					from authentik.policies.types import PolicyResult
 | 
				
			||||||
 | 
					from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -34,12 +42,10 @@ def user_app_cache_key(user_pk: str) -> str:
 | 
				
			|||||||
class ApplicationSerializer(ModelSerializer):
 | 
					class ApplicationSerializer(ModelSerializer):
 | 
				
			||||||
    """Application Serializer"""
 | 
					    """Application Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    launch_url = SerializerMethodField()
 | 
					    launch_url = ReadOnlyField(source="get_launch_url")
 | 
				
			||||||
    provider_obj = ProviderSerializer(source="get_provider", required=False)
 | 
					    provider_obj = ProviderSerializer(source="get_provider", required=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_launch_url(self, instance: Application) -> Optional[str]:
 | 
					    meta_icon = ReadOnlyField(source="get_meta_icon")
 | 
				
			||||||
        """Get generated launch URL"""
 | 
					 | 
				
			||||||
        return instance.get_launch_url()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -57,9 +63,12 @@ class ApplicationSerializer(ModelSerializer):
 | 
				
			|||||||
            "meta_publisher",
 | 
					            "meta_publisher",
 | 
				
			||||||
            "policy_engine_mode",
 | 
					            "policy_engine_mode",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					        extra_kwargs = {
 | 
				
			||||||
 | 
					            "meta_icon": {"read_only": True},
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ApplicationViewSet(ModelViewSet):
 | 
					class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """Application Viewset"""
 | 
					    """Application Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = Application.objects.all()
 | 
					    queryset = Application.objects.all()
 | 
				
			||||||
@ -91,17 +100,52 @@ class ApplicationViewSet(ModelViewSet):
 | 
				
			|||||||
                applications.append(application)
 | 
					                applications.append(application)
 | 
				
			||||||
        return applications
 | 
					        return applications
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        manual_parameters=[
 | 
					        parameters=[
 | 
				
			||||||
            openapi.Parameter(
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                name="for_user",
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                type=OpenApiTypes.INT,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: PolicyTestResultSerializer(),
 | 
				
			||||||
 | 
					            404: OpenApiResponse(description="for_user user not found"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @action(detail=True, methods=["GET"])
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def check_access(self, request: Request, slug: str) -> Response:
 | 
				
			||||||
 | 
					        """Check access to a single application by slug"""
 | 
				
			||||||
 | 
					        # Don't use self.get_object as that checks for view_application permission
 | 
				
			||||||
 | 
					        # which the user might not have, even if they have access
 | 
				
			||||||
 | 
					        application = get_object_or_404(Application, slug=slug)
 | 
				
			||||||
 | 
					        # If the current user is superuser, they can set `for_user`
 | 
				
			||||||
 | 
					        for_user = self.request.user
 | 
				
			||||||
 | 
					        if self.request.user.is_superuser and "for_user" in request.data:
 | 
				
			||||||
 | 
					            for_user = get_object_or_404(User, pk=request.data.get("for_user"))
 | 
				
			||||||
 | 
					        engine = PolicyEngine(application, for_user, self.request)
 | 
				
			||||||
 | 
					        engine.build()
 | 
				
			||||||
 | 
					        result = engine.result
 | 
				
			||||||
 | 
					        response = PolicyTestResultSerializer(PolicyResult(False))
 | 
				
			||||||
 | 
					        if result.passing:
 | 
				
			||||||
 | 
					            response = PolicyTestResultSerializer(PolicyResult(True))
 | 
				
			||||||
 | 
					        if self.request.user.is_superuser:
 | 
				
			||||||
 | 
					            response = PolicyTestResultSerializer(result)
 | 
				
			||||||
 | 
					        return Response(response.data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
                name="superuser_full_list",
 | 
					                name="superuser_full_list",
 | 
				
			||||||
                in_=openapi.IN_QUERY,
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
                type=openapi.TYPE_BOOLEAN,
 | 
					                type=OpenApiTypes.BOOL,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    def list(self, request: Request) -> Response:
 | 
					    def list(self, request: Request) -> Response:
 | 
				
			||||||
        """Custom list method that checks Policy based access instead of guardian"""
 | 
					        """Custom list method that checks Policy based access instead of guardian"""
 | 
				
			||||||
 | 
					        self.request.session.pop(USER_LOGIN_AUTHENTICATED, None)
 | 
				
			||||||
        queryset = self._filter_queryset_for_list(self.get_queryset())
 | 
					        queryset = self._filter_queryset_for_list(self.get_queryset())
 | 
				
			||||||
        self.paginate_queryset(queryset)
 | 
					        self.paginate_queryset(queryset)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -131,17 +175,20 @@ class ApplicationViewSet(ModelViewSet):
 | 
				
			|||||||
        return self.get_paginated_response(serializer.data)
 | 
					        return self.get_paginated_response(serializer.data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_core.change_application")
 | 
					    @permission_required("authentik_core.change_application")
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        request_body=no_body,
 | 
					        request={
 | 
				
			||||||
        manual_parameters=[
 | 
					            "multipart/form-data": inline_serializer(
 | 
				
			||||||
            openapi.Parameter(
 | 
					                "SetIcon",
 | 
				
			||||||
                name="file",
 | 
					                fields={
 | 
				
			||||||
                in_=openapi.IN_FORM,
 | 
					                    "file": FileField(required=False),
 | 
				
			||||||
                type=openapi.TYPE_FILE,
 | 
					                    "clear": BooleanField(default=False),
 | 
				
			||||||
                required=True,
 | 
					                },
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        ],
 | 
					        },
 | 
				
			||||||
        responses={200: "Success", 400: "Bad request"},
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: OpenApiResponse(description="Success"),
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Bad request"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(
 | 
					    @action(
 | 
				
			||||||
        detail=True,
 | 
					        detail=True,
 | 
				
			||||||
@ -155,16 +202,46 @@ class ApplicationViewSet(ModelViewSet):
 | 
				
			|||||||
        """Set application icon"""
 | 
					        """Set application icon"""
 | 
				
			||||||
        app: Application = self.get_object()
 | 
					        app: Application = self.get_object()
 | 
				
			||||||
        icon = request.FILES.get("file", None)
 | 
					        icon = request.FILES.get("file", None)
 | 
				
			||||||
        if not icon:
 | 
					        clear = request.data.get("clear", "false").lower() == "true"
 | 
				
			||||||
 | 
					        if clear:
 | 
				
			||||||
 | 
					            # .delete() saves the model by default
 | 
				
			||||||
 | 
					            app.meta_icon.delete()
 | 
				
			||||||
 | 
					            return Response({})
 | 
				
			||||||
 | 
					        if icon:
 | 
				
			||||||
 | 
					            app.meta_icon = icon
 | 
				
			||||||
 | 
					            app.save()
 | 
				
			||||||
 | 
					            return Response({})
 | 
				
			||||||
 | 
					        return HttpResponseBadRequest()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @permission_required("authentik_core.change_application")
 | 
				
			||||||
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        request=inline_serializer("SetIconURL", fields={"url": CharField()}),
 | 
				
			||||||
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: OpenApiResponse(description="Success"),
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Bad request"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @action(
 | 
				
			||||||
 | 
					        detail=True,
 | 
				
			||||||
 | 
					        pagination_class=None,
 | 
				
			||||||
 | 
					        filter_backends=[],
 | 
				
			||||||
 | 
					        methods=["POST"],
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def set_icon_url(self, request: Request, slug: str):
 | 
				
			||||||
 | 
					        """Set application icon (as URL)"""
 | 
				
			||||||
 | 
					        app: Application = self.get_object()
 | 
				
			||||||
 | 
					        url = request.data.get("url", None)
 | 
				
			||||||
 | 
					        if url is None:
 | 
				
			||||||
            return HttpResponseBadRequest()
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
        app.meta_icon = icon
 | 
					        app.meta_icon.name = url
 | 
				
			||||||
        app.save()
 | 
					        app.save()
 | 
				
			||||||
        return Response({})
 | 
					        return Response({})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required(
 | 
					    @permission_required(
 | 
				
			||||||
        "authentik_core.view_application", ["authentik_events.view_event"]
 | 
					        "authentik_core.view_application", ["authentik_events.view_event"]
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)})
 | 
					    @extend_schema(responses={200: CoordinateSerializer(many=True)})
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def metrics(self, request: Request, slug: str):
 | 
					    def metrics(self, request: Request, slug: str):
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										117
									
								
								authentik/core/api/authenticated_sessions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								authentik/core/api/authenticated_sessions.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,117 @@
 | 
				
			|||||||
 | 
					"""AuthenticatedSessions API Viewset"""
 | 
				
			||||||
 | 
					from typing import Optional, TypedDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django_filters.rest_framework import DjangoFilterBackend
 | 
				
			||||||
 | 
					from guardian.utils import get_anonymous_user
 | 
				
			||||||
 | 
					from rest_framework import mixins
 | 
				
			||||||
 | 
					from rest_framework.fields import SerializerMethodField
 | 
				
			||||||
 | 
					from rest_framework.filters import OrderingFilter, SearchFilter
 | 
				
			||||||
 | 
					from rest_framework.request import Request
 | 
				
			||||||
 | 
					from rest_framework.serializers import ModelSerializer
 | 
				
			||||||
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
 | 
					from ua_parser import user_agent_parser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
 | 
					from authentik.core.models import AuthenticatedSession
 | 
				
			||||||
 | 
					from authentik.events.geo import GEOIP_READER, GeoIPDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserAgentDeviceDict(TypedDict):
 | 
				
			||||||
 | 
					    """User agent device"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    brand: str
 | 
				
			||||||
 | 
					    family: str
 | 
				
			||||||
 | 
					    model: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserAgentOSDict(TypedDict):
 | 
				
			||||||
 | 
					    """User agent os"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    family: str
 | 
				
			||||||
 | 
					    major: str
 | 
				
			||||||
 | 
					    minor: str
 | 
				
			||||||
 | 
					    patch: str
 | 
				
			||||||
 | 
					    patch_minor: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserAgentBrowserDict(TypedDict):
 | 
				
			||||||
 | 
					    """User agent browser"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    family: str
 | 
				
			||||||
 | 
					    major: str
 | 
				
			||||||
 | 
					    minor: str
 | 
				
			||||||
 | 
					    patch: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserAgentDict(TypedDict):
 | 
				
			||||||
 | 
					    """User agent details"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    device: UserAgentDeviceDict
 | 
				
			||||||
 | 
					    os: UserAgentOSDict
 | 
				
			||||||
 | 
					    user_agent: UserAgentBrowserDict
 | 
				
			||||||
 | 
					    string: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AuthenticatedSessionSerializer(ModelSerializer):
 | 
				
			||||||
 | 
					    """AuthenticatedSession Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    current = SerializerMethodField()
 | 
				
			||||||
 | 
					    user_agent = SerializerMethodField()
 | 
				
			||||||
 | 
					    geo_ip = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_current(self, instance: AuthenticatedSession) -> bool:
 | 
				
			||||||
 | 
					        """Check if session is currently active session"""
 | 
				
			||||||
 | 
					        request: Request = self.context["request"]
 | 
				
			||||||
 | 
					        return request._request.session.session_key == instance.session_key
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
 | 
				
			||||||
 | 
					        """Get parsed user agent"""
 | 
				
			||||||
 | 
					        return user_agent_parser.Parse(instance.last_user_agent)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_geo_ip(
 | 
				
			||||||
 | 
					        self, instance: AuthenticatedSession
 | 
				
			||||||
 | 
					    ) -> Optional[GeoIPDict]:  # pragma: no cover
 | 
				
			||||||
 | 
					        """Get parsed user agent"""
 | 
				
			||||||
 | 
					        return GEOIP_READER.city_dict(instance.last_ip)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        model = AuthenticatedSession
 | 
				
			||||||
 | 
					        fields = [
 | 
				
			||||||
 | 
					            "uuid",
 | 
				
			||||||
 | 
					            "current",
 | 
				
			||||||
 | 
					            "user_agent",
 | 
				
			||||||
 | 
					            "geo_ip",
 | 
				
			||||||
 | 
					            "user",
 | 
				
			||||||
 | 
					            "last_ip",
 | 
				
			||||||
 | 
					            "last_user_agent",
 | 
				
			||||||
 | 
					            "last_used",
 | 
				
			||||||
 | 
					            "expires",
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AuthenticatedSessionViewSet(
 | 
				
			||||||
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
 | 
					    mixins.DestroyModelMixin,
 | 
				
			||||||
 | 
					    UsedByMixin,
 | 
				
			||||||
 | 
					    mixins.ListModelMixin,
 | 
				
			||||||
 | 
					    GenericViewSet,
 | 
				
			||||||
 | 
					):
 | 
				
			||||||
 | 
					    """AuthenticatedSession Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    queryset = AuthenticatedSession.objects.all()
 | 
				
			||||||
 | 
					    serializer_class = AuthenticatedSessionSerializer
 | 
				
			||||||
 | 
					    search_fields = ["user__username", "last_ip", "last_user_agent"]
 | 
				
			||||||
 | 
					    filterset_fields = ["user__username", "last_ip", "last_user_agent"]
 | 
				
			||||||
 | 
					    ordering = ["user__username"]
 | 
				
			||||||
 | 
					    filter_backends = [
 | 
				
			||||||
 | 
					        DjangoFilterBackend,
 | 
				
			||||||
 | 
					        OrderingFilter,
 | 
				
			||||||
 | 
					        SearchFilter,
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_queryset(self):
 | 
				
			||||||
 | 
					        user = self.request.user if self.request else get_anonymous_user()
 | 
				
			||||||
 | 
					        if user.is_superuser:
 | 
				
			||||||
 | 
					            return super().get_queryset()
 | 
				
			||||||
 | 
					        return super().get_queryset().filter(user=user.pk)
 | 
				
			||||||
@ -1,8 +1,11 @@
 | 
				
			|||||||
"""Groups API Viewset"""
 | 
					"""Groups API Viewset"""
 | 
				
			||||||
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
from rest_framework.fields import JSONField
 | 
					from rest_framework.fields import JSONField
 | 
				
			||||||
from rest_framework.serializers import ModelSerializer
 | 
					from rest_framework.serializers import ModelSerializer
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import is_dict
 | 
					from authentik.core.api.utils import is_dict
 | 
				
			||||||
from authentik.core.models import Group
 | 
					from authentik.core.models import Group
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -18,7 +21,7 @@ class GroupSerializer(ModelSerializer):
 | 
				
			|||||||
        fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"]
 | 
					        fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class GroupViewSet(ModelViewSet):
 | 
					class GroupViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """Group Viewset"""
 | 
					    """Group Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = Group.objects.all()
 | 
					    queryset = Group.objects.all()
 | 
				
			||||||
@ -26,3 +29,16 @@ class GroupViewSet(ModelViewSet):
 | 
				
			|||||||
    search_fields = ["name", "is_superuser"]
 | 
					    search_fields = ["name", "is_superuser"]
 | 
				
			||||||
    filterset_fields = ["name", "is_superuser"]
 | 
					    filterset_fields = ["name", "is_superuser"]
 | 
				
			||||||
    ordering = ["name"]
 | 
					    ordering = ["name"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
 | 
				
			||||||
 | 
					        """Custom filter_queryset method which ignores guardian, but still supports sorting"""
 | 
				
			||||||
 | 
					        for backend in list(self.filter_backends):
 | 
				
			||||||
 | 
					            if backend == ObjectPermissionsFilter:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            queryset = backend().filter_queryset(self.request, queryset, self)
 | 
				
			||||||
 | 
					        return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_queryset(self, queryset):
 | 
				
			||||||
 | 
					        if self.request.user.has_perm("authentik_core.view_group"):
 | 
				
			||||||
 | 
					            return self._filter_queryset_for_list(queryset)
 | 
				
			||||||
 | 
					        return super().filter_queryset(queryset)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,8 @@
 | 
				
			|||||||
"""PropertyMapping API Views"""
 | 
					"""PropertyMapping API Views"""
 | 
				
			||||||
from json import dumps
 | 
					from json import dumps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from drf_yasg import openapi
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
 | 
				
			||||||
from guardian.shortcuts import get_objects_for_user
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
@ -14,6 +14,7 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
				
			|||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import (
 | 
					from authentik.core.api.utils import (
 | 
				
			||||||
    MetaNameSerializer,
 | 
					    MetaNameSerializer,
 | 
				
			||||||
    PassiveSerializer,
 | 
					    PassiveSerializer,
 | 
				
			||||||
@ -65,6 +66,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
 | 
				
			|||||||
class PropertyMappingViewSet(
 | 
					class PropertyMappingViewSet(
 | 
				
			||||||
    mixins.RetrieveModelMixin,
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
    mixins.DestroyModelMixin,
 | 
					    mixins.DestroyModelMixin,
 | 
				
			||||||
 | 
					    UsedByMixin,
 | 
				
			||||||
    mixins.ListModelMixin,
 | 
					    mixins.ListModelMixin,
 | 
				
			||||||
    GenericViewSet,
 | 
					    GenericViewSet,
 | 
				
			||||||
):
 | 
					):
 | 
				
			||||||
@ -78,10 +80,10 @@ class PropertyMappingViewSet(
 | 
				
			|||||||
    filterset_fields = {"managed": ["isnull"]}
 | 
					    filterset_fields = {"managed": ["isnull"]}
 | 
				
			||||||
    ordering = ["name"]
 | 
					    ordering = ["name"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):  # pragma: no cover
 | 
				
			||||||
        return PropertyMapping.objects.select_subclasses()
 | 
					        return PropertyMapping.objects.select_subclasses()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
					    @extend_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def types(self, request: Request) -> Response:
 | 
					    def types(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all creatable property-mapping types"""
 | 
					        """Get all creatable property-mapping types"""
 | 
				
			||||||
@ -100,14 +102,17 @@ class PropertyMappingViewSet(
 | 
				
			|||||||
        return Response(TypeCreateSerializer(data, many=True).data)
 | 
					        return Response(TypeCreateSerializer(data, many=True).data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_core.view_propertymapping")
 | 
					    @permission_required("authentik_core.view_propertymapping")
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        request_body=PolicyTestSerializer(),
 | 
					        request=PolicyTestSerializer(),
 | 
				
			||||||
        responses={200: PropertyMappingTestResultSerializer, 400: "Invalid parameters"},
 | 
					        responses={
 | 
				
			||||||
        manual_parameters=[
 | 
					            200: PropertyMappingTestResultSerializer,
 | 
				
			||||||
            openapi.Parameter(
 | 
					            400: OpenApiResponse(description="Invalid parameters"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
                name="format_result",
 | 
					                name="format_result",
 | 
				
			||||||
                in_=openapi.IN_QUERY,
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
                type=openapi.TYPE_BOOLEAN,
 | 
					                type=OpenApiTypes.BOOL,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
"""Provider API Views"""
 | 
					"""Provider API Views"""
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import ReadOnlyField
 | 
					from rest_framework.fields import ReadOnlyField
 | 
				
			||||||
@ -9,6 +9,7 @@ from rest_framework.response import Response
 | 
				
			|||||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
					from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
 | 
					from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
 | 
				
			||||||
from authentik.core.models import Provider
 | 
					from authentik.core.models import Provider
 | 
				
			||||||
from authentik.lib.utils.reflection import all_subclasses
 | 
					from authentik.lib.utils.reflection import all_subclasses
 | 
				
			||||||
@ -22,7 +23,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    component = SerializerMethodField()
 | 
					    component = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_component(self, obj: Provider):  # pragma: no cover
 | 
					    def get_component(self, obj: Provider) -> str:  # pragma: no cover
 | 
				
			||||||
        """Get object component so that we know how to edit the object"""
 | 
					        """Get object component so that we know how to edit the object"""
 | 
				
			||||||
        # pyright: reportGeneralTypeIssues=false
 | 
					        # pyright: reportGeneralTypeIssues=false
 | 
				
			||||||
        if obj.__class__ == Provider:
 | 
					        if obj.__class__ == Provider:
 | 
				
			||||||
@ -48,6 +49,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
class ProviderViewSet(
 | 
					class ProviderViewSet(
 | 
				
			||||||
    mixins.RetrieveModelMixin,
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
    mixins.DestroyModelMixin,
 | 
					    mixins.DestroyModelMixin,
 | 
				
			||||||
 | 
					    UsedByMixin,
 | 
				
			||||||
    mixins.ListModelMixin,
 | 
					    mixins.ListModelMixin,
 | 
				
			||||||
    GenericViewSet,
 | 
					    GenericViewSet,
 | 
				
			||||||
):
 | 
					):
 | 
				
			||||||
@ -63,10 +65,10 @@ class ProviderViewSet(
 | 
				
			|||||||
        "application__name",
 | 
					        "application__name",
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):  # pragma: no cover
 | 
				
			||||||
        return Provider.objects.select_subclasses()
 | 
					        return Provider.objects.select_subclasses()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
					    @extend_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def types(self, request: Request) -> Response:
 | 
					    def types(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all creatable provider types"""
 | 
					        """Get all creatable provider types"""
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
"""Source API Views"""
 | 
					"""Source API Views"""
 | 
				
			||||||
from typing import Iterable
 | 
					from typing import Iterable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
@ -10,6 +10,7 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
				
			|||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
 | 
					from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
 | 
				
			||||||
from authentik.core.models import Source
 | 
					from authentik.core.models import Source
 | 
				
			||||||
from authentik.core.types import UserSettingSerializer
 | 
					from authentik.core.types import UserSettingSerializer
 | 
				
			||||||
@ -24,7 +25,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    component = SerializerMethodField()
 | 
					    component = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_component(self, obj: Source):
 | 
					    def get_component(self, obj: Source) -> str:
 | 
				
			||||||
        """Get object component so that we know how to edit the object"""
 | 
					        """Get object component so that we know how to edit the object"""
 | 
				
			||||||
        # pyright: reportGeneralTypeIssues=false
 | 
					        # pyright: reportGeneralTypeIssues=false
 | 
				
			||||||
        if obj.__class__ == Source:
 | 
					        if obj.__class__ == Source:
 | 
				
			||||||
@ -45,12 +46,14 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
            "verbose_name",
 | 
					            "verbose_name",
 | 
				
			||||||
            "verbose_name_plural",
 | 
					            "verbose_name_plural",
 | 
				
			||||||
            "policy_engine_mode",
 | 
					            "policy_engine_mode",
 | 
				
			||||||
 | 
					            "user_matching_mode",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SourceViewSet(
 | 
					class SourceViewSet(
 | 
				
			||||||
    mixins.RetrieveModelMixin,
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
    mixins.DestroyModelMixin,
 | 
					    mixins.DestroyModelMixin,
 | 
				
			||||||
 | 
					    UsedByMixin,
 | 
				
			||||||
    mixins.ListModelMixin,
 | 
					    mixins.ListModelMixin,
 | 
				
			||||||
    GenericViewSet,
 | 
					    GenericViewSet,
 | 
				
			||||||
):
 | 
					):
 | 
				
			||||||
@ -60,10 +63,10 @@ class SourceViewSet(
 | 
				
			|||||||
    serializer_class = SourceSerializer
 | 
					    serializer_class = SourceSerializer
 | 
				
			||||||
    lookup_field = "slug"
 | 
					    lookup_field = "slug"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):  # pragma: no cover
 | 
				
			||||||
        return Source.objects.select_subclasses()
 | 
					        return Source.objects.select_subclasses()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
					    @extend_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def types(self, request: Request) -> Response:
 | 
					    def types(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all creatable source types"""
 | 
					        """Get all creatable source types"""
 | 
				
			||||||
@ -86,7 +89,7 @@ class SourceViewSet(
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
        return Response(TypeCreateSerializer(data, many=True).data)
 | 
					        return Response(TypeCreateSerializer(data, many=True).data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: UserSettingSerializer(many=True)})
 | 
					    @extend_schema(responses={200: UserSettingSerializer(many=True)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def user_settings(self, request: Request) -> Response:
 | 
					    def user_settings(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all sources the user can configure"""
 | 
					        """Get all sources the user can configure"""
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
"""Tokens API Viewset"""
 | 
					"""Tokens API Viewset"""
 | 
				
			||||||
from django.http.response import Http404
 | 
					from django.http.response import Http404
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import CharField
 | 
					from rest_framework.fields import CharField
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
@ -9,6 +9,7 @@ from rest_framework.serializers import ModelSerializer
 | 
				
			|||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.users import UserSerializer
 | 
					from authentik.core.api.users import UserSerializer
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
from authentik.core.models import Token, TokenIntents
 | 
					from authentik.core.models import Token, TokenIntents
 | 
				
			||||||
@ -43,7 +44,7 @@ class TokenViewSerializer(PassiveSerializer):
 | 
				
			|||||||
    key = CharField(read_only=True)
 | 
					    key = CharField(read_only=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TokenViewSet(ModelViewSet):
 | 
					class TokenViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """Token Viewset"""
 | 
					    """Token Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    lookup_field = "identifier"
 | 
					    lookup_field = "identifier"
 | 
				
			||||||
@ -67,10 +68,10 @@ class TokenViewSet(ModelViewSet):
 | 
				
			|||||||
        serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API)
 | 
					        serializer.save(user=self.request.user, intent=TokenIntents.INTENT_API)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_core.view_token_key")
 | 
					    @permission_required("authentik_core.view_token_key")
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        responses={
 | 
					        responses={
 | 
				
			||||||
            200: TokenViewSerializer(many=False),
 | 
					            200: TokenViewSerializer(many=False),
 | 
				
			||||||
            404: "Token not found or expired",
 | 
					            404: OpenApiResponse(description="Token not found or expired"),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										102
									
								
								authentik/core/api/used_by.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								authentik/core/api/used_by.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,102 @@
 | 
				
			|||||||
 | 
					"""used_by mixin"""
 | 
				
			||||||
 | 
					from enum import Enum
 | 
				
			||||||
 | 
					from inspect import getmembers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db.models.base import Model
 | 
				
			||||||
 | 
					from django.db.models.deletion import SET_DEFAULT, SET_NULL
 | 
				
			||||||
 | 
					from django.db.models.manager import Manager
 | 
				
			||||||
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
 | 
					from rest_framework.fields import CharField, ChoiceField
 | 
				
			||||||
 | 
					from rest_framework.request import Request
 | 
				
			||||||
 | 
					from rest_framework.response import Response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DeleteAction(Enum):
 | 
				
			||||||
 | 
					    """Which action a delete will have on a used object"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    CASCADE = "cascade"
 | 
				
			||||||
 | 
					    CASCADE_MANY = "cascade_many"
 | 
				
			||||||
 | 
					    SET_NULL = "set_null"
 | 
				
			||||||
 | 
					    SET_DEFAULT = "set_default"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UsedBySerializer(PassiveSerializer):
 | 
				
			||||||
 | 
					    """A list of all objects referencing the queried object"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    app = CharField()
 | 
				
			||||||
 | 
					    model_name = CharField()
 | 
				
			||||||
 | 
					    pk = CharField()
 | 
				
			||||||
 | 
					    name = CharField()
 | 
				
			||||||
 | 
					    action = ChoiceField(choices=[(x.name, x.name) for x in DeleteAction])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_delete_action(manager: Manager) -> str:
 | 
				
			||||||
 | 
					    """Get the delete action from the Foreign key, falls back to cascade"""
 | 
				
			||||||
 | 
					    if hasattr(manager, "field"):
 | 
				
			||||||
 | 
					        if manager.field.remote_field.on_delete.__name__ == SET_NULL.__name__:
 | 
				
			||||||
 | 
					            return DeleteAction.SET_NULL.name
 | 
				
			||||||
 | 
					        if manager.field.remote_field.on_delete.__name__ == SET_DEFAULT.__name__:
 | 
				
			||||||
 | 
					            return DeleteAction.SET_DEFAULT.name
 | 
				
			||||||
 | 
					    if hasattr(manager, "source_field"):
 | 
				
			||||||
 | 
					        return DeleteAction.CASCADE_MANY.name
 | 
				
			||||||
 | 
					    return DeleteAction.CASCADE.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UsedByMixin:
 | 
				
			||||||
 | 
					    """Mixin to add a used_by endpoint to return a list of all objects using this object"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        responses={200: UsedBySerializer(many=True)},
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
 | 
					    # pylint: disable=invalid-name, unused-argument, too-many-locals
 | 
				
			||||||
 | 
					    def used_by(self, request: Request, *args, **kwargs) -> Response:
 | 
				
			||||||
 | 
					        """Get a list of all objects that use this object"""
 | 
				
			||||||
 | 
					        # pyright: reportGeneralTypeIssues=false
 | 
				
			||||||
 | 
					        model: Model = self.get_object()
 | 
				
			||||||
 | 
					        used_by = []
 | 
				
			||||||
 | 
					        shadows = []
 | 
				
			||||||
 | 
					        for attr_name, manager in getmembers(model, lambda x: isinstance(x, Manager)):
 | 
				
			||||||
 | 
					            if attr_name == "objects":  # pragma: no cover
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            manager: Manager
 | 
				
			||||||
 | 
					            if manager.model._meta.abstract:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            app = manager.model._meta.app_label
 | 
				
			||||||
 | 
					            model_name = manager.model._meta.model_name
 | 
				
			||||||
 | 
					            delete_action = get_delete_action(manager)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # To make sure we only apply shadows when there are any objects,
 | 
				
			||||||
 | 
					            # but so we only apply them once, have a simple flag for the first object
 | 
				
			||||||
 | 
					            first_object = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for obj in get_objects_for_user(
 | 
				
			||||||
 | 
					                request.user, f"{app}.view_{model_name}", manager
 | 
				
			||||||
 | 
					            ).all():
 | 
				
			||||||
 | 
					                # Only merge shadows on first object
 | 
				
			||||||
 | 
					                if first_object:
 | 
				
			||||||
 | 
					                    shadows += getattr(
 | 
				
			||||||
 | 
					                        manager.model._meta, "authentik_used_by_shadows", []
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                first_object = False
 | 
				
			||||||
 | 
					                serializer = UsedBySerializer(
 | 
				
			||||||
 | 
					                    data={
 | 
				
			||||||
 | 
					                        "app": app,
 | 
				
			||||||
 | 
					                        "model_name": model_name,
 | 
				
			||||||
 | 
					                        "pk": str(obj.pk),
 | 
				
			||||||
 | 
					                        "name": str(obj),
 | 
				
			||||||
 | 
					                        "action": delete_action,
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                serializer.is_valid()
 | 
				
			||||||
 | 
					                used_by.append(serializer.data)
 | 
				
			||||||
 | 
					        # Check the shadows map and remove anything that should be shadowed
 | 
				
			||||||
 | 
					        for idx, user in enumerate(used_by):
 | 
				
			||||||
 | 
					            full_model_name = f"{user['app']}.{user['model_name']}"
 | 
				
			||||||
 | 
					            if full_model_name in shadows:
 | 
				
			||||||
 | 
					                del used_by[idx]
 | 
				
			||||||
 | 
					        return Response(used_by)
 | 
				
			||||||
@ -1,18 +1,31 @@
 | 
				
			|||||||
"""User API Views"""
 | 
					"""User API Views"""
 | 
				
			||||||
 | 
					from json import loads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
from django.http.response import Http404
 | 
					from django.http.response import Http404
 | 
				
			||||||
from django.urls import reverse_lazy
 | 
					from django.urls import reverse_lazy
 | 
				
			||||||
from django.utils.http import urlencode
 | 
					from django.utils.http import urlencode
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method
 | 
					from django_filters.filters import BooleanFilter, CharFilter
 | 
				
			||||||
 | 
					from django_filters.filterset import FilterSet
 | 
				
			||||||
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field
 | 
				
			||||||
from guardian.utils import get_anonymous_user
 | 
					from guardian.utils import get_anonymous_user
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import CharField, JSONField, SerializerMethodField
 | 
					from rest_framework.fields import CharField, JSONField, SerializerMethodField
 | 
				
			||||||
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 BooleanField, ModelSerializer
 | 
					from rest_framework.serializers import (
 | 
				
			||||||
 | 
					    BooleanField,
 | 
				
			||||||
 | 
					    ListSerializer,
 | 
				
			||||||
 | 
					    ModelSerializer,
 | 
				
			||||||
 | 
					    ValidationError,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
 | 
					from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
 | 
					from authentik.core.api.groups import GroupSerializer
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
 | 
					from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
 | 
				
			||||||
from authentik.core.middleware import (
 | 
					from authentik.core.middleware import (
 | 
				
			||||||
    SESSION_IMPERSONATE_ORIGINAL_USER,
 | 
					    SESSION_IMPERSONATE_ORIGINAL_USER,
 | 
				
			||||||
@ -20,7 +33,7 @@ from authentik.core.middleware import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
from authentik.core.models import Token, TokenIntents, User
 | 
					from authentik.core.models import Token, TokenIntents, User
 | 
				
			||||||
from authentik.events.models import EventAction
 | 
					from authentik.events.models import EventAction
 | 
				
			||||||
from authentik.flows.models import Flow, FlowDesignation
 | 
					from authentik.tenants.models import Tenant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserSerializer(ModelSerializer):
 | 
					class UserSerializer(ModelSerializer):
 | 
				
			||||||
@ -29,6 +42,8 @@ class UserSerializer(ModelSerializer):
 | 
				
			|||||||
    is_superuser = BooleanField(read_only=True)
 | 
					    is_superuser = BooleanField(read_only=True)
 | 
				
			||||||
    avatar = CharField(read_only=True)
 | 
					    avatar = CharField(read_only=True)
 | 
				
			||||||
    attributes = JSONField(validators=[is_dict], required=False)
 | 
					    attributes = JSONField(validators=[is_dict], required=False)
 | 
				
			||||||
 | 
					    groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
 | 
				
			||||||
 | 
					    uid = CharField(read_only=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -40,9 +55,11 @@ class UserSerializer(ModelSerializer):
 | 
				
			|||||||
            "is_active",
 | 
					            "is_active",
 | 
				
			||||||
            "last_login",
 | 
					            "last_login",
 | 
				
			||||||
            "is_superuser",
 | 
					            "is_superuser",
 | 
				
			||||||
 | 
					            "groups",
 | 
				
			||||||
            "email",
 | 
					            "email",
 | 
				
			||||||
            "avatar",
 | 
					            "avatar",
 | 
				
			||||||
            "attributes",
 | 
					            "attributes",
 | 
				
			||||||
 | 
					            "uid",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -61,13 +78,13 @@ class UserMetricsSerializer(PassiveSerializer):
 | 
				
			|||||||
    logins_failed_per_1h = SerializerMethodField()
 | 
					    logins_failed_per_1h = SerializerMethodField()
 | 
				
			||||||
    authorizations_per_1h = SerializerMethodField()
 | 
					    authorizations_per_1h = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
 | 
					    @extend_schema_field(CoordinateSerializer(many=True))
 | 
				
			||||||
    def get_logins_per_1h(self, _):
 | 
					    def get_logins_per_1h(self, _):
 | 
				
			||||||
        """Get successful logins per hour for the last 24 hours"""
 | 
					        """Get successful logins per hour for the last 24 hours"""
 | 
				
			||||||
        user = self.context["user"]
 | 
					        user = self.context["user"]
 | 
				
			||||||
        return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk)
 | 
					        return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
 | 
					    @extend_schema_field(CoordinateSerializer(many=True))
 | 
				
			||||||
    def get_logins_failed_per_1h(self, _):
 | 
					    def get_logins_failed_per_1h(self, _):
 | 
				
			||||||
        """Get failed logins per hour for the last 24 hours"""
 | 
					        """Get failed logins per hour for the last 24 hours"""
 | 
				
			||||||
        user = self.context["user"]
 | 
					        user = self.context["user"]
 | 
				
			||||||
@ -75,7 +92,7 @@ class UserMetricsSerializer(PassiveSerializer):
 | 
				
			|||||||
            action=EventAction.LOGIN_FAILED, context__username=user.username
 | 
					            action=EventAction.LOGIN_FAILED, context__username=user.username
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
 | 
					    @extend_schema_field(CoordinateSerializer(many=True))
 | 
				
			||||||
    def get_authorizations_per_1h(self, _):
 | 
					    def get_authorizations_per_1h(self, _):
 | 
				
			||||||
        """Get failed logins per hour for the last 24 hours"""
 | 
					        """Get failed logins per hour for the last 24 hours"""
 | 
				
			||||||
        user = self.context["user"]
 | 
					        user = self.context["user"]
 | 
				
			||||||
@ -84,18 +101,49 @@ class UserMetricsSerializer(PassiveSerializer):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserViewSet(ModelViewSet):
 | 
					class UsersFilter(FilterSet):
 | 
				
			||||||
 | 
					    """Filter for users"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    attributes = CharFilter(
 | 
				
			||||||
 | 
					        field_name="attributes",
 | 
				
			||||||
 | 
					        lookup_expr="",
 | 
				
			||||||
 | 
					        label="Attributes",
 | 
				
			||||||
 | 
					        method="filter_attributes",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def filter_attributes(self, queryset, name, value):
 | 
				
			||||||
 | 
					        """Filter attributes by query args"""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            value = loads(value)
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            raise ValidationError(detail="filter: failed to parse JSON")
 | 
				
			||||||
 | 
					        if not isinstance(value, dict):
 | 
				
			||||||
 | 
					            raise ValidationError(detail="filter: value must be key:value mapping")
 | 
				
			||||||
 | 
					        qs = {}
 | 
				
			||||||
 | 
					        for key, _value in value.items():
 | 
				
			||||||
 | 
					            qs[f"attributes__{key}"] = _value
 | 
				
			||||||
 | 
					        return queryset.filter(**qs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = User
 | 
				
			||||||
 | 
					        fields = ["username", "name", "is_active", "is_superuser", "attributes"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """User Viewset"""
 | 
					    """User Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = User.objects.none()
 | 
					    queryset = User.objects.none()
 | 
				
			||||||
    serializer_class = UserSerializer
 | 
					    serializer_class = UserSerializer
 | 
				
			||||||
    search_fields = ["username", "name", "is_active"]
 | 
					    search_fields = ["username", "name", "is_active"]
 | 
				
			||||||
    filterset_fields = ["username", "name", "is_active"]
 | 
					    filterset_class = UsersFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):  # pragma: no cover
 | 
				
			||||||
        return User.objects.all().exclude(pk=get_anonymous_user().pk)
 | 
					        return User.objects.all().exclude(pk=get_anonymous_user().pk)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: SessionUserSerializer(many=False)})
 | 
					    @extend_schema(responses={200: SessionUserSerializer(many=False)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    # pylint: disable=invalid-name
 | 
					    # pylint: disable=invalid-name
 | 
				
			||||||
    def me(self, request: Request) -> Response:
 | 
					    def me(self, request: Request) -> Response:
 | 
				
			||||||
@ -111,7 +159,7 @@ class UserViewSet(ModelViewSet):
 | 
				
			|||||||
        return Response(serializer.data)
 | 
					        return Response(serializer.data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_core.view_user", ["authentik_events.view_event"])
 | 
					    @permission_required("authentik_core.view_user", ["authentik_events.view_event"])
 | 
				
			||||||
    @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)})
 | 
					    @extend_schema(responses={200: UserMetricsSerializer(many=False)})
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    # pylint: disable=invalid-name, unused-argument
 | 
					    # pylint: disable=invalid-name, unused-argument
 | 
				
			||||||
    def metrics(self, request: Request, pk: int) -> Response:
 | 
					    def metrics(self, request: Request, pk: int) -> Response:
 | 
				
			||||||
@ -122,15 +170,19 @@ class UserViewSet(ModelViewSet):
 | 
				
			|||||||
        return Response(serializer.data)
 | 
					        return Response(serializer.data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_core.reset_user_password")
 | 
					    @permission_required("authentik_core.reset_user_password")
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        responses={"200": LinkSerializer(many=False), "404": "No recovery flow found."},
 | 
					        responses={
 | 
				
			||||||
 | 
					            "200": LinkSerializer(many=False),
 | 
				
			||||||
 | 
					            "404": OpenApiResponse(description="No recovery flow found."),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    # pylint: disable=invalid-name, unused-argument
 | 
					    # pylint: disable=invalid-name, unused-argument
 | 
				
			||||||
    def recovery(self, request: Request, pk: int) -> Response:
 | 
					    def recovery(self, request: Request, pk: int) -> Response:
 | 
				
			||||||
        """Create a temporary link that a user can use to recover their accounts"""
 | 
					        """Create a temporary link that a user can use to recover their accounts"""
 | 
				
			||||||
 | 
					        tenant: Tenant = request._request.tenant
 | 
				
			||||||
        # Check that there is a recovery flow, if not return an error
 | 
					        # Check that there is a recovery flow, if not return an error
 | 
				
			||||||
        flow = Flow.with_policy(request, designation=FlowDesignation.RECOVERY)
 | 
					        flow = tenant.flow_recovery
 | 
				
			||||||
        if not flow:
 | 
					        if not flow:
 | 
				
			||||||
            raise Http404
 | 
					            raise Http404
 | 
				
			||||||
        user: User = self.get_object()
 | 
					        user: User = self.get_object()
 | 
				
			||||||
@ -141,6 +193,20 @@ class UserViewSet(ModelViewSet):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        querystring = urlencode({"token": token.key})
 | 
					        querystring = urlencode({"token": token.key})
 | 
				
			||||||
        link = request.build_absolute_uri(
 | 
					        link = request.build_absolute_uri(
 | 
				
			||||||
            reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}"
 | 
					            reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
 | 
				
			||||||
 | 
					            + f"?{querystring}"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return Response({"link": link})
 | 
					        return Response({"link": link})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
 | 
				
			||||||
 | 
					        """Custom filter_queryset method which ignores guardian, but still supports sorting"""
 | 
				
			||||||
 | 
					        for backend in list(self.filter_backends):
 | 
				
			||||||
 | 
					            if backend == ObjectPermissionsFilter:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            queryset = backend().filter_queryset(self.request, queryset, self)
 | 
				
			||||||
 | 
					        return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def filter_queryset(self, queryset):
 | 
				
			||||||
 | 
					        if self.request.user.has_perm("authentik_core.view_group"):
 | 
				
			||||||
 | 
					            return self._filter_queryset_for_list(queryset)
 | 
				
			||||||
 | 
					        return super().filter_queryset(queryset)
 | 
				
			||||||
 | 
				
			|||||||
@ -20,12 +20,17 @@ def is_dict(value: Any):
 | 
				
			|||||||
class PassiveSerializer(Serializer):
 | 
					class PassiveSerializer(Serializer):
 | 
				
			||||||
    """Base serializer class which doesn't implement create/update methods"""
 | 
					    """Base serializer class which doesn't implement create/update methods"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create(self, validated_data: dict) -> Model:
 | 
					    def create(self, validated_data: dict) -> Model:  # pragma: no cover
 | 
				
			||||||
        return Model()
 | 
					        return Model()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def update(self, instance: Model, validated_data: dict) -> Model:
 | 
					    def update(
 | 
				
			||||||
 | 
					        self, instance: Model, validated_data: dict
 | 
				
			||||||
 | 
					    ) -> Model:  # pragma: no cover
 | 
				
			||||||
        return Model()
 | 
					        return Model()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					        model = Model
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MetaNameSerializer(PassiveSerializer):
 | 
					class MetaNameSerializer(PassiveSerializer):
 | 
				
			||||||
    """Add verbose names to response"""
 | 
					    """Add verbose names to response"""
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,10 @@
 | 
				
			|||||||
from importlib import import_module
 | 
					from importlib import import_module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.apps import AppConfig
 | 
					from django.apps import AppConfig
 | 
				
			||||||
 | 
					from django.db import ProgrammingError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.signals import GAUGE_MODELS
 | 
				
			||||||
 | 
					from authentik.lib.utils.reflection import get_apps
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AuthentikCoreConfig(AppConfig):
 | 
					class AuthentikCoreConfig(AppConfig):
 | 
				
			||||||
@ -15,3 +19,12 @@ class AuthentikCoreConfig(AppConfig):
 | 
				
			|||||||
    def ready(self):
 | 
					    def ready(self):
 | 
				
			||||||
        import_module("authentik.core.signals")
 | 
					        import_module("authentik.core.signals")
 | 
				
			||||||
        import_module("authentik.core.managed")
 | 
					        import_module("authentik.core.managed")
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            for app in get_apps():
 | 
				
			||||||
 | 
					                for model in app.get_models():
 | 
				
			||||||
 | 
					                    GAUGE_MODELS.labels(
 | 
				
			||||||
 | 
					                        model_name=model._meta.model_name,
 | 
				
			||||||
 | 
					                        app=model._meta.app_label,
 | 
				
			||||||
 | 
					                    ).set(model.objects.count())
 | 
				
			||||||
 | 
					        except ProgrammingError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@ from channels.generic.websocket import JsonWebsocketConsumer
 | 
				
			|||||||
from rest_framework.exceptions import AuthenticationFailed
 | 
					from rest_framework.exceptions import AuthenticationFailed
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.auth import token_from_header
 | 
					from authentik.api.authentication import token_from_header
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
				
			|||||||
@ -26,6 +26,8 @@ class ImpersonateMiddleware:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        if SESSION_IMPERSONATE_USER in request.session:
 | 
					        if SESSION_IMPERSONATE_USER in request.session:
 | 
				
			||||||
            request.user = request.session[SESSION_IMPERSONATE_USER]
 | 
					            request.user = request.session[SESSION_IMPERSONATE_USER]
 | 
				
			||||||
 | 
					            # Ensure that the user is active, otherwise nothing will work
 | 
				
			||||||
 | 
					            request.user.is_active = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return self.get_response(request)
 | 
					        return self.get_response(request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -42,10 +44,14 @@ class RequestIDMiddleware:
 | 
				
			|||||||
        if not hasattr(request, "request_id"):
 | 
					        if not hasattr(request, "request_id"):
 | 
				
			||||||
            request_id = uuid4().hex
 | 
					            request_id = uuid4().hex
 | 
				
			||||||
            setattr(request, "request_id", request_id)
 | 
					            setattr(request, "request_id", request_id)
 | 
				
			||||||
            LOCAL.authentik = {"request_id": request_id}
 | 
					            LOCAL.authentik = {
 | 
				
			||||||
 | 
					                "request_id": request_id,
 | 
				
			||||||
 | 
					                "host": request.get_host(),
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
        response = self.get_response(request)
 | 
					        response = self.get_response(request)
 | 
				
			||||||
        response[RESPONSE_HEADER_ID] = request.request_id
 | 
					        response[RESPONSE_HEADER_ID] = request.request_id
 | 
				
			||||||
        del LOCAL.authentik["request_id"]
 | 
					        del LOCAL.authentik["request_id"]
 | 
				
			||||||
 | 
					        del LOCAL.authentik["host"]
 | 
				
			||||||
        return response
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -54,4 +60,5 @@ def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
 | 
				
			|||||||
    """If threadlocal has authentik defined, add request_id to log"""
 | 
					    """If threadlocal has authentik defined, add request_id to log"""
 | 
				
			||||||
    if hasattr(LOCAL, "authentik"):
 | 
					    if hasattr(LOCAL, "authentik"):
 | 
				
			||||||
        event_dict["request_id"] = LOCAL.authentik.get("request_id", "")
 | 
					        event_dict["request_id"] = LOCAL.authentik.get("request_id", "")
 | 
				
			||||||
 | 
					        event_dict["host"] = LOCAL.authentik.get("host", "")
 | 
				
			||||||
    return event_dict
 | 
					    return event_dict
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										40
									
								
								authentik/core/migrations/0020_source_user_matching_mode.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								authentik/core/migrations/0020_source_user_matching_mode.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2 on 2021-05-03 17:06
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_core", "0019_source_managed"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name="source",
 | 
				
			||||||
 | 
					            name="user_matching_mode",
 | 
				
			||||||
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                choices=[
 | 
				
			||||||
 | 
					                    ("identifier", "Use the source-specific identifier"),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "email_link",
 | 
				
			||||||
 | 
					                        "Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "email_deny",
 | 
				
			||||||
 | 
					                        "Use the user's email address, but deny enrollment when the email address already exists.",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "username_link",
 | 
				
			||||||
 | 
					                        "Link to a user with identical username address. Can have security implications when a username is used with another source.",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "username_deny",
 | 
				
			||||||
 | 
					                        "Use the user's username, but deny enrollment when the username already exists.",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					                default="identifier",
 | 
				
			||||||
 | 
					                help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										20
									
								
								authentik/core/migrations/0021_alter_application_slug.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								authentik/core/migrations/0021_alter_application_slug.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.3 on 2021-05-14 08:48
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_core", "0020_source_user_matching_mode"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="application",
 | 
				
			||||||
 | 
					            name="slug",
 | 
				
			||||||
 | 
					            field=models.SlugField(
 | 
				
			||||||
 | 
					                help_text="Internal application name, used in URLs.", unique=True
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										63
									
								
								authentik/core/migrations/0022_authenticatedsession.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								authentik/core/migrations/0022_authenticatedsession.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,63 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.3 on 2021-05-29 22:14
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import uuid
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.apps.registry import Apps
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import authentik.core.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			||||||
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
 | 
					    from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
				
			||||||
 | 
					    from django.core.cache import cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    session_keys = cache.keys(KEY_PREFIX + "*")
 | 
				
			||||||
 | 
					    cache.delete_many(session_keys)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_core", "0021_alter_application_slug"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name="AuthenticatedSession",
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "expires",
 | 
				
			||||||
 | 
					                    models.DateTimeField(
 | 
				
			||||||
 | 
					                        default=authentik.core.models.default_token_duration
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ("expiring", models.BooleanField(default=True)),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "uuid",
 | 
				
			||||||
 | 
					                    models.UUIDField(
 | 
				
			||||||
 | 
					                        default=uuid.uuid4, primary_key=True, serialize=False
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ("session_key", models.CharField(max_length=40)),
 | 
				
			||||||
 | 
					                ("last_ip", models.TextField()),
 | 
				
			||||||
 | 
					                ("last_user_agent", models.TextField(blank=True)),
 | 
				
			||||||
 | 
					                ("last_used", models.DateTimeField(auto_now=True)),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "user",
 | 
				
			||||||
 | 
					                    models.ForeignKey(
 | 
				
			||||||
 | 
					                        on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                        to=settings.AUTH_USER_MODEL,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                "abstract": False,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RunPython(migrate_sessions),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -0,0 +1,23 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.3 on 2021-06-02 21:51
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.core.validators
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_core", "0022_authenticatedsession"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="application",
 | 
				
			||||||
 | 
					            name="meta_launch_url",
 | 
				
			||||||
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                blank=True,
 | 
				
			||||||
 | 
					                default="",
 | 
				
			||||||
 | 
					                validators=[django.core.validators.URLValidator()],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										35
									
								
								authentik/core/migrations/0024_alter_token_identifier.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								authentik/core/migrations/0024_alter_token_identifier.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.3 on 2021-06-03 09:33
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.apps.registry import Apps
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			||||||
 | 
					from django.db.models import Count
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			||||||
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
 | 
					    Token = apps.get_model("authentik_core", "token")
 | 
				
			||||||
 | 
					    identifiers = (
 | 
				
			||||||
 | 
					        Token.objects.using(db_alias)
 | 
				
			||||||
 | 
					        .values("identifier")
 | 
				
			||||||
 | 
					        .annotate(identifier_count=Count("identifier"))
 | 
				
			||||||
 | 
					        .filter(identifier_count__gt=1)
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    for ident in identifiers:
 | 
				
			||||||
 | 
					        Token.objects.using(db_alias).filter(identifier=ident["identifier"]).delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_core", "0023_alter_application_meta_launch_url"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.RunPython(fix_duplicates),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="token",
 | 
				
			||||||
 | 
					            name="identifier",
 | 
				
			||||||
 | 
					            field=models.SlugField(max_length=255, unique=True),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.3 on 2021-06-05 19:04
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_core", "0024_alter_token_identifier"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="application",
 | 
				
			||||||
 | 
					            name="meta_icon",
 | 
				
			||||||
 | 
					            field=models.FileField(
 | 
				
			||||||
 | 
					                default=None, null=True, upload_to="application-icons/"
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -5,9 +5,11 @@ from typing import Any, Optional, Type
 | 
				
			|||||||
from urllib.parse import urlencode
 | 
					from urllib.parse import urlencode
 | 
				
			||||||
from uuid import uuid4
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.options as options
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.contrib.auth.models import AbstractUser
 | 
					from django.contrib.auth.models import AbstractUser
 | 
				
			||||||
from django.contrib.auth.models import UserManager as DjangoUserManager
 | 
					from django.contrib.auth.models import UserManager as DjangoUserManager
 | 
				
			||||||
 | 
					from django.core import validators
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from django.db.models import Q, QuerySet
 | 
					from django.db.models import Q, QuerySet
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
@ -23,22 +25,26 @@ from structlog.stdlib import get_logger
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.exceptions import PropertyMappingExpressionException
 | 
					from authentik.core.exceptions import PropertyMappingExpressionException
 | 
				
			||||||
from authentik.core.signals import password_changed
 | 
					from authentik.core.signals import password_changed
 | 
				
			||||||
from authentik.core.types import UILoginButton
 | 
					from authentik.core.types import UILoginButton, UserSettingSerializer
 | 
				
			||||||
from authentik.flows.challenge import Challenge
 | 
					 | 
				
			||||||
from authentik.flows.models import Flow
 | 
					from authentik.flows.models import Flow
 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
 | 
					from authentik.lib.models import CreatedUpdatedModel, SerializerModel
 | 
				
			||||||
 | 
					from authentik.lib.utils.http import get_client_ip
 | 
				
			||||||
from authentik.managed.models import ManagedModel
 | 
					from authentik.managed.models import ManagedModel
 | 
				
			||||||
from authentik.policies.models import PolicyBindingModel
 | 
					from authentik.policies.models import PolicyBindingModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
 | 
					USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
 | 
				
			||||||
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
 | 
					USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
 | 
				
			||||||
 | 
					USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
GRAVATAR_URL = "https://secure.gravatar.com"
 | 
					GRAVATAR_URL = "https://secure.gravatar.com"
 | 
				
			||||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
 | 
					DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					options.DEFAULT_NAMES = options.DEFAULT_NAMES + ("authentik_used_by_shadows",)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def default_token_duration():
 | 
					def default_token_duration():
 | 
				
			||||||
    """Default duration a Token is valid"""
 | 
					    """Default duration a Token is valid"""
 | 
				
			||||||
    return now() + timedelta(minutes=30)
 | 
					    return now() + timedelta(minutes=30)
 | 
				
			||||||
@ -206,17 +212,35 @@ class Application(PolicyBindingModel):
 | 
				
			|||||||
    add custom fields and other properties"""
 | 
					    add custom fields and other properties"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    name = models.TextField(help_text=_("Application's display Name."))
 | 
					    name = models.TextField(help_text=_("Application's display Name."))
 | 
				
			||||||
    slug = models.SlugField(help_text=_("Internal application name, used in URLs."))
 | 
					    slug = models.SlugField(
 | 
				
			||||||
 | 
					        help_text=_("Internal application name, used in URLs."), unique=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    provider = models.OneToOneField(
 | 
					    provider = models.OneToOneField(
 | 
				
			||||||
        "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
 | 
					        "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    meta_launch_url = models.URLField(default="", blank=True)
 | 
					    meta_launch_url = models.TextField(
 | 
				
			||||||
 | 
					        default="", blank=True, validators=[validators.URLValidator()]
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    # For template applications, this can be set to /static/authentik/applications/*
 | 
					    # For template applications, this can be set to /static/authentik/applications/*
 | 
				
			||||||
    meta_icon = models.FileField(upload_to="application-icons/", default="", blank=True)
 | 
					    meta_icon = models.FileField(
 | 
				
			||||||
 | 
					        upload_to="application-icons/", default=None, null=True
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    meta_description = models.TextField(default="", blank=True)
 | 
					    meta_description = models.TextField(default="", blank=True)
 | 
				
			||||||
    meta_publisher = models.TextField(default="", blank=True)
 | 
					    meta_publisher = models.TextField(default="", blank=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def get_meta_icon(self) -> Optional[str]:
 | 
				
			||||||
 | 
					        """Get the URL to the App Icon image. If the name is /static or starts with http
 | 
				
			||||||
 | 
					        it is returned as-is"""
 | 
				
			||||||
 | 
					        if not self.meta_icon:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        if self.meta_icon.name.startswith("http") or self.meta_icon.name.startswith(
 | 
				
			||||||
 | 
					            "/static"
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            return self.meta_icon.name
 | 
				
			||||||
 | 
					        return self.meta_icon.url
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_launch_url(self) -> Optional[str]:
 | 
					    def get_launch_url(self) -> Optional[str]:
 | 
				
			||||||
        """Get launch URL if set, otherwise attempt to get launch URL based on provider."""
 | 
					        """Get launch URL if set, otherwise attempt to get launch URL based on provider."""
 | 
				
			||||||
        if self.meta_launch_url:
 | 
					        if self.meta_launch_url:
 | 
				
			||||||
@ -240,6 +264,30 @@ class Application(PolicyBindingModel):
 | 
				
			|||||||
        verbose_name_plural = _("Applications")
 | 
					        verbose_name_plural = _("Applications")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SourceUserMatchingModes(models.TextChoices):
 | 
				
			||||||
 | 
					    """Different modes a source can handle new/returning users"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    IDENTIFIER = "identifier", _("Use the source-specific identifier")
 | 
				
			||||||
 | 
					    EMAIL_LINK = "email_link", _(
 | 
				
			||||||
 | 
					        (
 | 
				
			||||||
 | 
					            "Link to a user with identical email address. Can have security implications "
 | 
				
			||||||
 | 
					            "when a source doesn't validate email addresses."
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    EMAIL_DENY = "email_deny", _(
 | 
				
			||||||
 | 
					        "Use the user's email address, but deny enrollment when the email address already exists."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    USERNAME_LINK = "username_link", _(
 | 
				
			||||||
 | 
					        (
 | 
				
			||||||
 | 
					            "Link to a user with identical username address. Can have security implications "
 | 
				
			||||||
 | 
					            "when a username is used with another source."
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    USERNAME_DENY = "username_deny", _(
 | 
				
			||||||
 | 
					        "Use the user's username, but deny enrollment when the username already exists."
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
					class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
				
			||||||
    """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
 | 
					    """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -272,6 +320,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
        related_name="source_enrollment",
 | 
					        related_name="source_enrollment",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    user_matching_mode = models.TextField(
 | 
				
			||||||
 | 
					        choices=SourceUserMatchingModes.choices,
 | 
				
			||||||
 | 
					        default=SourceUserMatchingModes.IDENTIFIER,
 | 
				
			||||||
 | 
					        help_text=_(
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
 | 
					                "How the source determines if an existing user should be authenticated or "
 | 
				
			||||||
 | 
					                "a new user enrolled."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    objects = InheritanceManager()
 | 
					    objects = InheritanceManager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
@ -286,9 +345,9 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
        return None
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def ui_user_settings(self) -> Optional[Challenge]:
 | 
					    def ui_user_settings(self) -> Optional[UserSettingSerializer]:
 | 
				
			||||||
        """Entrypoint to integrate with User settings. Can either return None if no
 | 
					        """Entrypoint to integrate with User settings. Can either return None if no
 | 
				
			||||||
        user settings are available, or a challenge."""
 | 
					        user settings are available, or UserSettingSerializer."""
 | 
				
			||||||
        return None
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
@ -301,6 +360,8 @@ class UserSourceConnection(CreatedUpdatedModel):
 | 
				
			|||||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
					    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
				
			||||||
    source = models.ForeignKey(Source, on_delete=models.CASCADE)
 | 
					    source = models.ForeignKey(Source, on_delete=models.CASCADE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = InheritanceManager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        unique_together = (("user", "source"),)
 | 
					        unique_together = (("user", "source"),)
 | 
				
			||||||
@ -348,7 +409,7 @@ class Token(ManagedModel, ExpiringModel):
 | 
				
			|||||||
    """Token used to authenticate the User for API Access or confirm another Stage like Email."""
 | 
					    """Token used to authenticate the User for API Access or confirm another Stage like Email."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
					    token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
				
			||||||
    identifier = models.SlugField(max_length=255)
 | 
					    identifier = models.SlugField(max_length=255, unique=True)
 | 
				
			||||||
    key = models.TextField(default=default_token_key)
 | 
					    key = models.TextField(default=default_token_key)
 | 
				
			||||||
    intent = models.TextField(
 | 
					    intent = models.TextField(
 | 
				
			||||||
        choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION
 | 
					        choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION
 | 
				
			||||||
@ -412,3 +473,33 @@ class PropertyMapping(SerializerModel, ManagedModel):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        verbose_name = _("Property Mapping")
 | 
					        verbose_name = _("Property Mapping")
 | 
				
			||||||
        verbose_name_plural = _("Property Mappings")
 | 
					        verbose_name_plural = _("Property Mappings")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AuthenticatedSession(ExpiringModel):
 | 
				
			||||||
 | 
					    """Additional session class for authenticated users. Augments the standard django session
 | 
				
			||||||
 | 
					    to achieve the following:
 | 
				
			||||||
 | 
					        - Make it queryable by user
 | 
				
			||||||
 | 
					        - Have a direct connection to user objects
 | 
				
			||||||
 | 
					        - Allow users to view their own sessions and terminate them
 | 
				
			||||||
 | 
					        - Save structured and well-defined information.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    uuid = models.UUIDField(default=uuid4, primary_key=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    session_key = models.CharField(max_length=40)
 | 
				
			||||||
 | 
					    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    last_ip = models.TextField()
 | 
				
			||||||
 | 
					    last_user_agent = models.TextField(blank=True)
 | 
				
			||||||
 | 
					    last_used = models.DateTimeField(auto_now=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def from_request(request: HttpRequest, user: User) -> "AuthenticatedSession":
 | 
				
			||||||
 | 
					        """Create a new session from a http request"""
 | 
				
			||||||
 | 
					        return AuthenticatedSession(
 | 
				
			||||||
 | 
					            session_key=request.session.session_key,
 | 
				
			||||||
 | 
					            user=user,
 | 
				
			||||||
 | 
					            last_ip=get_client_ip(request),
 | 
				
			||||||
 | 
					            last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
 | 
				
			||||||
 | 
					            expires=request.session.get_expiry_date(),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -1,20 +1,39 @@
 | 
				
			|||||||
"""authentik core signals"""
 | 
					"""authentik core signals"""
 | 
				
			||||||
 | 
					from typing import TYPE_CHECKING, Type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.contrib.auth.signals import user_logged_in, user_logged_out
 | 
				
			||||||
 | 
					from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.core.signals import Signal
 | 
					from django.core.signals import Signal
 | 
				
			||||||
from django.db.models.signals import post_save
 | 
					from django.db.models import Model
 | 
				
			||||||
 | 
					from django.db.models.signals import post_save, pre_delete
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
 | 
					from django.http.request import HttpRequest
 | 
				
			||||||
 | 
					from prometheus_client import Gauge
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Arguments: user: User, password: str
 | 
					# Arguments: user: User, password: str
 | 
				
			||||||
password_changed = Signal()
 | 
					password_changed = Signal()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					GAUGE_MODELS = Gauge(
 | 
				
			||||||
 | 
					    "authentik_models", "Count of various objects", ["model_name", "app"]
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
 | 
					    from authentik.core.models import AuthenticatedSession, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(post_save)
 | 
					@receiver(post_save)
 | 
				
			||||||
# pylint: disable=unused-argument
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
def post_save_application(sender, instance, created: bool, **_):
 | 
					def post_save_application(sender: type[Model], instance, created: bool, **_):
 | 
				
			||||||
    """Clear user's application cache upon application creation"""
 | 
					    """Clear user's application cache upon application creation"""
 | 
				
			||||||
    from authentik.core.api.applications import user_app_cache_key
 | 
					    from authentik.core.api.applications import user_app_cache_key
 | 
				
			||||||
    from authentik.core.models import Application
 | 
					    from authentik.core.models import Application
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    GAUGE_MODELS.labels(
 | 
				
			||||||
 | 
					        model_name=sender._meta.model_name,
 | 
				
			||||||
 | 
					        app=sender._meta.app_label,
 | 
				
			||||||
 | 
					    ).set(sender.objects.count())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if sender != Application:
 | 
					    if sender != Application:
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
    if not created:  # pragma: no cover
 | 
					    if not created:  # pragma: no cover
 | 
				
			||||||
@ -22,3 +41,37 @@ def post_save_application(sender, instance, created: bool, **_):
 | 
				
			|||||||
    # Also delete user application cache
 | 
					    # Also delete user application cache
 | 
				
			||||||
    keys = cache.keys(user_app_cache_key("*"))
 | 
					    keys = cache.keys(user_app_cache_key("*"))
 | 
				
			||||||
    cache.delete_many(keys)
 | 
					    cache.delete_many(keys)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(user_logged_in)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
 | 
				
			||||||
 | 
					    """Create an AuthenticatedSession from request"""
 | 
				
			||||||
 | 
					    from authentik.core.models import AuthenticatedSession
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    AuthenticatedSession.from_request(request, user).save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(user_logged_out)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
 | 
				
			||||||
 | 
					    """Delete AuthenticatedSession if it exists"""
 | 
				
			||||||
 | 
					    from authentik.core.models import AuthenticatedSession
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    AuthenticatedSession.objects.filter(
 | 
				
			||||||
 | 
					        session_key=request.session.session_key
 | 
				
			||||||
 | 
					    ).delete()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(pre_delete)
 | 
				
			||||||
 | 
					def authenticated_session_delete(
 | 
				
			||||||
 | 
					    sender: Type[Model], instance: "AuthenticatedSession", **_
 | 
				
			||||||
 | 
					):
 | 
				
			||||||
 | 
					    """Delete session when authenticated session is deleted"""
 | 
				
			||||||
 | 
					    from authentik.core.models import AuthenticatedSession
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if sender != AuthenticatedSession:
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cache_key = f"{KEY_PREFIX}{instance.session_key}"
 | 
				
			||||||
 | 
					    cache.delete(cache_key)
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										0
									
								
								authentik/core/sources/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/core/sources/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										287
									
								
								authentik/core/sources/flow_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								authentik/core/sources/flow_manager.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,287 @@
 | 
				
			|||||||
 | 
					"""Source decision helper"""
 | 
				
			||||||
 | 
					from enum import Enum
 | 
				
			||||||
 | 
					from typing import Any, Optional, Type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.contrib import messages
 | 
				
			||||||
 | 
					from django.db import IntegrityError
 | 
				
			||||||
 | 
					from django.db.models.query_utils import Q
 | 
				
			||||||
 | 
					from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
 | 
				
			||||||
 | 
					from django.shortcuts import redirect
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import (
 | 
				
			||||||
 | 
					    Source,
 | 
				
			||||||
 | 
					    SourceUserMatchingModes,
 | 
				
			||||||
 | 
					    User,
 | 
				
			||||||
 | 
					    UserSourceConnection,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from authentik.core.sources.stage import (
 | 
				
			||||||
 | 
					    PLAN_CONTEXT_SOURCES_CONNECTION,
 | 
				
			||||||
 | 
					    PostUserEnrollmentStage,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					from authentik.flows.models import Flow, Stage, in_memory_stage
 | 
				
			||||||
 | 
					from authentik.flows.planner import (
 | 
				
			||||||
 | 
					    PLAN_CONTEXT_PENDING_USER,
 | 
				
			||||||
 | 
					    PLAN_CONTEXT_REDIRECT,
 | 
				
			||||||
 | 
					    PLAN_CONTEXT_SOURCE,
 | 
				
			||||||
 | 
					    PLAN_CONTEXT_SSO,
 | 
				
			||||||
 | 
					    FlowPlanner,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
 | 
				
			||||||
 | 
					from authentik.lib.utils.urls import redirect_with_qs
 | 
				
			||||||
 | 
					from authentik.policies.utils import delete_none_keys
 | 
				
			||||||
 | 
					from authentik.stages.password import BACKEND_DJANGO
 | 
				
			||||||
 | 
					from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
 | 
				
			||||||
 | 
					from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Action(Enum):
 | 
				
			||||||
 | 
					    """Actions that can be decided based on the request
 | 
				
			||||||
 | 
					    and source settings"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    LINK = "link"
 | 
				
			||||||
 | 
					    AUTH = "auth"
 | 
				
			||||||
 | 
					    ENROLL = "enroll"
 | 
				
			||||||
 | 
					    DENY = "deny"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SourceFlowManager:
 | 
				
			||||||
 | 
					    """Help sources decide what they should do after authorization. Based on source settings and
 | 
				
			||||||
 | 
					    previous connections, authenticate the user, enroll a new user, link to an existing user
 | 
				
			||||||
 | 
					    or deny the request."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    source: Source
 | 
				
			||||||
 | 
					    request: HttpRequest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    identifier: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    connection_type: Type[UserSourceConnection] = UserSourceConnection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        source: Source,
 | 
				
			||||||
 | 
					        request: HttpRequest,
 | 
				
			||||||
 | 
					        identifier: str,
 | 
				
			||||||
 | 
					        enroll_info: dict[str, Any],
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        self.source = source
 | 
				
			||||||
 | 
					        self.request = request
 | 
				
			||||||
 | 
					        self.identifier = identifier
 | 
				
			||||||
 | 
					        self.enroll_info = enroll_info
 | 
				
			||||||
 | 
					        self._logger = get_logger().bind(source=source, identifier=identifier)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # pylint: disable=too-many-return-statements
 | 
				
			||||||
 | 
					    def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
 | 
				
			||||||
 | 
					        """decide which action should be taken"""
 | 
				
			||||||
 | 
					        new_connection = self.connection_type(
 | 
				
			||||||
 | 
					            source=self.source, identifier=self.identifier
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # When request is authenticated, always link
 | 
				
			||||||
 | 
					        if self.request.user.is_authenticated:
 | 
				
			||||||
 | 
					            new_connection.user = self.request.user
 | 
				
			||||||
 | 
					            new_connection = self.update_connection(new_connection, **kwargs)
 | 
				
			||||||
 | 
					            new_connection.save()
 | 
				
			||||||
 | 
					            return Action.LINK, new_connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        existing_connections = self.connection_type.objects.filter(
 | 
				
			||||||
 | 
					            source=self.source, identifier=self.identifier
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if existing_connections.exists():
 | 
				
			||||||
 | 
					            connection = existing_connections.first()
 | 
				
			||||||
 | 
					            return Action.AUTH, self.update_connection(connection, **kwargs)
 | 
				
			||||||
 | 
					        # No connection exists, but we match on identifier, so enroll
 | 
				
			||||||
 | 
					        if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER:
 | 
				
			||||||
 | 
					            # We don't save the connection here cause it doesn't have a user assigned yet
 | 
				
			||||||
 | 
					            return Action.ENROLL, self.update_connection(new_connection, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check for existing users with matching attributes
 | 
				
			||||||
 | 
					        query = Q()
 | 
				
			||||||
 | 
					        # Either query existing user based on email or username
 | 
				
			||||||
 | 
					        if self.source.user_matching_mode in [
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.EMAIL_LINK,
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.EMAIL_DENY,
 | 
				
			||||||
 | 
					        ]:
 | 
				
			||||||
 | 
					            if not self.enroll_info.get("email", None):
 | 
				
			||||||
 | 
					                self._logger.warning("Refusing to use none email", source=self.source)
 | 
				
			||||||
 | 
					                return Action.DENY, None
 | 
				
			||||||
 | 
					            query = Q(email__exact=self.enroll_info.get("email", None))
 | 
				
			||||||
 | 
					        if self.source.user_matching_mode in [
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.USERNAME_LINK,
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.USERNAME_DENY,
 | 
				
			||||||
 | 
					        ]:
 | 
				
			||||||
 | 
					            if not self.enroll_info.get("username", None):
 | 
				
			||||||
 | 
					                self._logger.warning(
 | 
				
			||||||
 | 
					                    "Refusing to use none username", source=self.source
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                return Action.DENY, None
 | 
				
			||||||
 | 
					            query = Q(username__exact=self.enroll_info.get("username", None))
 | 
				
			||||||
 | 
					        self._logger.debug("trying to link with existing user", query=query)
 | 
				
			||||||
 | 
					        matching_users = User.objects.filter(query)
 | 
				
			||||||
 | 
					        # No matching users, always enroll
 | 
				
			||||||
 | 
					        if not matching_users.exists():
 | 
				
			||||||
 | 
					            self._logger.debug("no matching users found, enrolling")
 | 
				
			||||||
 | 
					            return Action.ENROLL, self.update_connection(new_connection, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        user = matching_users.first()
 | 
				
			||||||
 | 
					        if self.source.user_matching_mode in [
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.EMAIL_LINK,
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.USERNAME_LINK,
 | 
				
			||||||
 | 
					        ]:
 | 
				
			||||||
 | 
					            new_connection.user = user
 | 
				
			||||||
 | 
					            new_connection = self.update_connection(new_connection, **kwargs)
 | 
				
			||||||
 | 
					            new_connection.save()
 | 
				
			||||||
 | 
					            return Action.LINK, new_connection
 | 
				
			||||||
 | 
					        if self.source.user_matching_mode in [
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.EMAIL_DENY,
 | 
				
			||||||
 | 
					            SourceUserMatchingModes.USERNAME_DENY,
 | 
				
			||||||
 | 
					        ]:
 | 
				
			||||||
 | 
					            self._logger.info("denying source because user exists", user=user)
 | 
				
			||||||
 | 
					            return Action.DENY, None
 | 
				
			||||||
 | 
					        # Should never get here as default enroll case is returned above.
 | 
				
			||||||
 | 
					        return Action.DENY, None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def update_connection(
 | 
				
			||||||
 | 
					        self, connection: UserSourceConnection, **kwargs
 | 
				
			||||||
 | 
					    ) -> UserSourceConnection:
 | 
				
			||||||
 | 
					        """Optionally make changes to the connection after it is looked up/created."""
 | 
				
			||||||
 | 
					        return connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_flow(self, **kwargs) -> HttpResponse:
 | 
				
			||||||
 | 
					        """Get the flow response based on user_matching_mode"""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            action, connection = self.get_action(**kwargs)
 | 
				
			||||||
 | 
					        except IntegrityError as exc:
 | 
				
			||||||
 | 
					            self._logger.warning("failed to get action", exc=exc)
 | 
				
			||||||
 | 
					            return redirect("/")
 | 
				
			||||||
 | 
					        self._logger.debug("get_action() says", action=action, connection=connection)
 | 
				
			||||||
 | 
					        if connection:
 | 
				
			||||||
 | 
					            if action == Action.LINK:
 | 
				
			||||||
 | 
					                self._logger.debug("Linking existing user")
 | 
				
			||||||
 | 
					                return self.handle_existing_user_link(connection)
 | 
				
			||||||
 | 
					            if action == Action.AUTH:
 | 
				
			||||||
 | 
					                self._logger.debug("Handling auth user")
 | 
				
			||||||
 | 
					                return self.handle_auth_user(connection)
 | 
				
			||||||
 | 
					            if action == Action.ENROLL:
 | 
				
			||||||
 | 
					                self._logger.debug("Handling enrollment of new user")
 | 
				
			||||||
 | 
					                return self.handle_enroll(connection)
 | 
				
			||||||
 | 
					        # Default case, assume deny
 | 
				
			||||||
 | 
					        messages.error(
 | 
				
			||||||
 | 
					            self.request,
 | 
				
			||||||
 | 
					            _(
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "Request to authenticate with %(source)s has been denied. Please authenticate "
 | 
				
			||||||
 | 
					                    "with the source you've previously signed up with."
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                % {"source": self.source.name}
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return redirect("/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def get_stages_to_append(self, flow: Flow) -> list[Stage]:
 | 
				
			||||||
 | 
					        """Hook to override stages which are appended to the flow"""
 | 
				
			||||||
 | 
					        if flow.slug == self.source.enrollment_flow.slug:
 | 
				
			||||||
 | 
					            return [
 | 
				
			||||||
 | 
					                in_memory_stage(PostUserEnrollmentStage),
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
 | 
				
			||||||
 | 
					        """Prepare Authentication Plan, redirect user FlowExecutor"""
 | 
				
			||||||
 | 
					        # Ensure redirect is carried through when user was trying to
 | 
				
			||||||
 | 
					        # authorize application
 | 
				
			||||||
 | 
					        final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
 | 
				
			||||||
 | 
					            NEXT_ARG_NAME, "authentik_core:if-admin"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        kwargs.update(
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                # Since we authenticate the user by their token, they have no backend set
 | 
				
			||||||
 | 
					                PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO,
 | 
				
			||||||
 | 
					                PLAN_CONTEXT_SSO: True,
 | 
				
			||||||
 | 
					                PLAN_CONTEXT_SOURCE: self.source,
 | 
				
			||||||
 | 
					                PLAN_CONTEXT_REDIRECT: final_redirect,
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if not flow:
 | 
				
			||||||
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
 | 
					        # We run the Flow planner here so we can pass the Pending user in the context
 | 
				
			||||||
 | 
					        planner = FlowPlanner(flow)
 | 
				
			||||||
 | 
					        plan = planner.plan(self.request, kwargs)
 | 
				
			||||||
 | 
					        for stage in self.get_stages_to_append(flow):
 | 
				
			||||||
 | 
					            plan.append(stage)
 | 
				
			||||||
 | 
					        self.request.session[SESSION_KEY_PLAN] = plan
 | 
				
			||||||
 | 
					        return redirect_with_qs(
 | 
				
			||||||
 | 
					            "authentik_core:if-flow",
 | 
				
			||||||
 | 
					            self.request.GET,
 | 
				
			||||||
 | 
					            flow_slug=flow.slug,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def handle_auth_user(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        connection: UserSourceConnection,
 | 
				
			||||||
 | 
					    ) -> HttpResponse:
 | 
				
			||||||
 | 
					        """Login user and redirect."""
 | 
				
			||||||
 | 
					        messages.success(
 | 
				
			||||||
 | 
					            self.request,
 | 
				
			||||||
 | 
					            _(
 | 
				
			||||||
 | 
					                "Successfully authenticated with %(source)s!"
 | 
				
			||||||
 | 
					                % {"source": self.source.name}
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
 | 
				
			||||||
 | 
					        return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_existing_user_link(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        connection: UserSourceConnection,
 | 
				
			||||||
 | 
					    ) -> HttpResponse:
 | 
				
			||||||
 | 
					        """Handler when the user was already authenticated and linked an external source
 | 
				
			||||||
 | 
					        to their account."""
 | 
				
			||||||
 | 
					        # Connection has already been saved
 | 
				
			||||||
 | 
					        Event.new(
 | 
				
			||||||
 | 
					            EventAction.SOURCE_LINKED,
 | 
				
			||||||
 | 
					            message="Linked Source",
 | 
				
			||||||
 | 
					            source=self.source,
 | 
				
			||||||
 | 
					        ).from_http(self.request)
 | 
				
			||||||
 | 
					        messages.success(
 | 
				
			||||||
 | 
					            self.request,
 | 
				
			||||||
 | 
					            _("Successfully linked %(source)s!" % {"source": self.source.name}),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        # When request isn't authenticated we jump straight to auth
 | 
				
			||||||
 | 
					        if not self.request.user.is_authenticated:
 | 
				
			||||||
 | 
					            return self.handle_auth_user(connection)
 | 
				
			||||||
 | 
					        return redirect(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_core:if-admin",
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            + f"#/user;page-{self.source.slug}"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def handle_enroll(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        connection: UserSourceConnection,
 | 
				
			||||||
 | 
					    ) -> HttpResponse:
 | 
				
			||||||
 | 
					        """User was not authenticated and previous request was not authenticated."""
 | 
				
			||||||
 | 
					        messages.success(
 | 
				
			||||||
 | 
					            self.request,
 | 
				
			||||||
 | 
					            _(
 | 
				
			||||||
 | 
					                "Successfully authenticated with %(source)s!"
 | 
				
			||||||
 | 
					                % {"source": self.source.name}
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # We run the Flow planner here so we can pass the Pending user in the context
 | 
				
			||||||
 | 
					        if not self.source.enrollment_flow:
 | 
				
			||||||
 | 
					            self._logger.warning("source has no enrollment flow")
 | 
				
			||||||
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
 | 
					        return self._handle_login_flow(
 | 
				
			||||||
 | 
					            self.source.enrollment_flow,
 | 
				
			||||||
 | 
					            **{
 | 
				
			||||||
 | 
					                PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
 | 
				
			||||||
 | 
					                PLAN_CONTEXT_SOURCES_CONNECTION: connection,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
@ -1,32 +1,30 @@
 | 
				
			|||||||
"""OAuth Stages"""
 | 
					"""Source flow manager stages"""
 | 
				
			||||||
from django.http import HttpRequest, HttpResponse
 | 
					from django.http import HttpRequest, HttpResponse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.models import User, UserSourceConnection
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
 | 
					from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
 | 
				
			||||||
from authentik.flows.stage import StageView
 | 
					from authentik.flows.stage import StageView
 | 
				
			||||||
from authentik.sources.oauth.models import UserOAuthSourceConnection
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access"
 | 
					PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PostUserEnrollmentStage(StageView):
 | 
					class PostUserEnrollmentStage(StageView):
 | 
				
			||||||
    """Dynamically injected stage which saves the OAuth Connection after
 | 
					    """Dynamically injected stage which saves the Connection after
 | 
				
			||||||
    the user has been enrolled."""
 | 
					    the user has been enrolled."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
					    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
				
			||||||
        """Stage used after the user has been enrolled"""
 | 
					        """Stage used after the user has been enrolled"""
 | 
				
			||||||
        access: UserOAuthSourceConnection = self.executor.plan.context[
 | 
					        connection: UserSourceConnection = self.executor.plan.context[
 | 
				
			||||||
            PLAN_CONTEXT_SOURCES_OAUTH_ACCESS
 | 
					            PLAN_CONTEXT_SOURCES_CONNECTION
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
 | 
					        user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
 | 
				
			||||||
        access.user = user
 | 
					        connection.user = user
 | 
				
			||||||
        access.save()
 | 
					        connection.save()
 | 
				
			||||||
        UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
 | 
					 | 
				
			||||||
        Event.new(
 | 
					        Event.new(
 | 
				
			||||||
            EventAction.SOURCE_LINKED,
 | 
					            EventAction.SOURCE_LINKED,
 | 
				
			||||||
            message="Linked OAuth Source",
 | 
					            message="Linked Source",
 | 
				
			||||||
            source=access.source,
 | 
					            source=connection.source,
 | 
				
			||||||
        ).from_http(self.request)
 | 
					        ).from_http(self.request)
 | 
				
			||||||
        return self.executor.stage_ok()
 | 
					        return self.executor.stage_ok()
 | 
				
			||||||
@ -75,5 +75,6 @@ def backup_database(self: MonitoredTask):  # pragma: no cover
 | 
				
			|||||||
        Boto3Error,
 | 
					        Boto3Error,
 | 
				
			||||||
        PermissionError,
 | 
					        PermissionError,
 | 
				
			||||||
        CommandConnectorError,
 | 
					        CommandConnectorError,
 | 
				
			||||||
 | 
					        ValueError,
 | 
				
			||||||
    ) as exc:
 | 
					    ) as exc:
 | 
				
			||||||
        self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
 | 
					        self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
 | 
				
			||||||
 | 
				
			|||||||
@ -7,16 +7,15 @@
 | 
				
			|||||||
    <head>
 | 
					    <head>
 | 
				
			||||||
        <meta charset="UTF-8">
 | 
					        <meta charset="UTF-8">
 | 
				
			||||||
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
 | 
					        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
 | 
				
			||||||
        <title>{% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %}</title>
 | 
					        <title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
 | 
				
			||||||
        <link rel="icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
 | 
					 | 
				
			||||||
        <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
 | 
					        <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
 | 
				
			||||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}">
 | 
					        <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}">
 | 
				
			||||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}">
 | 
					        <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}">
 | 
				
			||||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}?v={{ ak_version }}">
 | 
					        <link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}?v={{ ak_version }}">
 | 
				
			||||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}?v={{ ak_version }}">
 | 
					        <link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}?v={{ ak_version }}">
 | 
				
			||||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
 | 
					 | 
				
			||||||
        {% block head_before %}
 | 
					        {% block head_before %}
 | 
				
			||||||
        {% endblock %}
 | 
					        {% endblock %}
 | 
				
			||||||
 | 
					        <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
 | 
				
			||||||
        <script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script>
 | 
					        <script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script>
 | 
				
			||||||
        <script>window["polymerSkipLoadingFontRoboto"] = true;</script>
 | 
					        <script>window["polymerSkipLoadingFontRoboto"] = true;</script>
 | 
				
			||||||
        {% block head %}
 | 
					        {% block head %}
 | 
				
			||||||
 | 
				
			|||||||
@ -3,18 +3,8 @@
 | 
				
			|||||||
{% load static %}
 | 
					{% load static %}
 | 
				
			||||||
{% load i18n %}
 | 
					{% load i18n %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					 | 
				
			||||||
{{ block.super }}
 | 
					 | 
				
			||||||
<style>
 | 
					 | 
				
			||||||
.pf-c-background-image::before {
 | 
					 | 
				
			||||||
    background-image: url("{% static 'dist/assets/images/flow_background.jpg' %}");
 | 
					 | 
				
			||||||
    background-position: center;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
</style>
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block title %}
 | 
					{% block title %}
 | 
				
			||||||
{% trans 'End session' %}
 | 
					{% trans 'End session' %} - {{ tenant.branding_title }}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block card_title %}
 | 
					{% block card_title %}
 | 
				
			||||||
@ -4,7 +4,9 @@
 | 
				
			|||||||
{% load i18n %}
 | 
					{% load i18n %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head_before %}
 | 
					{% block head_before %}
 | 
				
			||||||
 | 
					{% if flow.compatibility_mode %}
 | 
				
			||||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
 | 
					<script>ShadyDOM = { force: !navigator.webdriver };</script>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					{% block head %}
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,10 @@
 | 
				
			|||||||
{% load static %}
 | 
					{% load static %}
 | 
				
			||||||
{% load i18n %}
 | 
					{% load i18n %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block head_before %}
 | 
				
			||||||
 | 
					<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}">
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block body %}
 | 
					{% block body %}
 | 
				
			||||||
<div class="pf-c-background-image">
 | 
					<div class="pf-c-background-image">
 | 
				
			||||||
    <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
 | 
					    <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
 | 
				
			||||||
@ -22,10 +26,7 @@
 | 
				
			|||||||
    <div class="ak-login-container">
 | 
					    <div class="ak-login-container">
 | 
				
			||||||
        <header class="pf-c-login__header">
 | 
					        <header class="pf-c-login__header">
 | 
				
			||||||
            <div class="pf-c-brand ak-brand">
 | 
					            <div class="pf-c-brand ak-brand">
 | 
				
			||||||
                <img src="{{ config.authentik.branding.logo }}" alt="authentik icon" />
 | 
					                <img src="{{ tenant.branding_logo }}" alt="authentik icon" />
 | 
				
			||||||
                {% if config.authentik.branding.title_show %}
 | 
					 | 
				
			||||||
                <p>{{ config.authentik.branding.title }}</p>
 | 
					 | 
				
			||||||
                {% endif %}
 | 
					 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </header>
 | 
					        </header>
 | 
				
			||||||
        {% block main_container %}
 | 
					        {% block main_container %}
 | 
				
			||||||
@ -45,12 +46,12 @@
 | 
				
			|||||||
        <footer class="pf-c-login__footer">
 | 
					        <footer class="pf-c-login__footer">
 | 
				
			||||||
            <p></p>
 | 
					            <p></p>
 | 
				
			||||||
            <ul class="pf-c-list pf-m-inline">
 | 
					            <ul class="pf-c-list pf-m-inline">
 | 
				
			||||||
                {% for link in config.authentik.footer_links %}
 | 
					                {% for link in footer_links %}
 | 
				
			||||||
                <li>
 | 
					                <li>
 | 
				
			||||||
                    <a href="{{ link.href }}">{{ link.name }}</a>
 | 
					                    <a href="{{ link.href }}">{{ link.name }}</a>
 | 
				
			||||||
                </li>
 | 
					                </li>
 | 
				
			||||||
                {% endfor %}
 | 
					                {% endfor %}
 | 
				
			||||||
                {% if config.authentik.branding.title != "authentik" %}
 | 
					                {% if tenant.branding_title != "authentik" %}
 | 
				
			||||||
                <li>
 | 
					                <li>
 | 
				
			||||||
                    <a href="https://goauthentik.io">
 | 
					                    <a href="https://goauthentik.io">
 | 
				
			||||||
                        {% trans 'Powered by authentik' %}
 | 
					                        {% trans 'Powered by authentik' %}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										131
									
								
								authentik/core/tests/test_applications_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								authentik/core/tests/test_applications_api.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,131 @@
 | 
				
			|||||||
 | 
					"""Test Applications API"""
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from django.utils.encoding import force_str
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import Application, User
 | 
				
			||||||
 | 
					from authentik.policies.dummy.models import DummyPolicy
 | 
				
			||||||
 | 
					from authentik.policies.models import PolicyBinding
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestApplicationsAPI(APITestCase):
 | 
				
			||||||
 | 
					    """Test applications API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        self.user = User.objects.get(username="akadmin")
 | 
				
			||||||
 | 
					        self.allowed = Application.objects.create(name="allowed", slug="allowed")
 | 
				
			||||||
 | 
					        self.denied = Application.objects.create(name="denied", slug="denied")
 | 
				
			||||||
 | 
					        PolicyBinding.objects.create(
 | 
				
			||||||
 | 
					            target=self.denied,
 | 
				
			||||||
 | 
					            policy=DummyPolicy.objects.create(
 | 
				
			||||||
 | 
					                name="deny", result=False, wait_min=1, wait_max=2
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            order=0,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_check_access(self):
 | 
				
			||||||
 | 
					        """Test check_access operation"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:application-check-access",
 | 
				
			||||||
 | 
					                kwargs={"slug": self.allowed.slug},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
 | 
					            force_str(response.content), {"messages": [], "passing": True}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:application-check-access",
 | 
				
			||||||
 | 
					                kwargs={"slug": self.denied.slug},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
 | 
					            force_str(response.content), {"messages": ["dummy"], "passing": False}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_list(self):
 | 
				
			||||||
 | 
					        """Test list operation without superuser_full_list"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        response = self.client.get(reverse("authentik_api:application-list"))
 | 
				
			||||||
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
 | 
					            force_str(response.content),
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "pagination": {
 | 
				
			||||||
 | 
					                    "next": 0,
 | 
				
			||||||
 | 
					                    "previous": 0,
 | 
				
			||||||
 | 
					                    "count": 2,
 | 
				
			||||||
 | 
					                    "current": 1,
 | 
				
			||||||
 | 
					                    "total_pages": 1,
 | 
				
			||||||
 | 
					                    "start_index": 1,
 | 
				
			||||||
 | 
					                    "end_index": 2,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                "results": [
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        "pk": str(self.allowed.pk),
 | 
				
			||||||
 | 
					                        "name": "allowed",
 | 
				
			||||||
 | 
					                        "slug": "allowed",
 | 
				
			||||||
 | 
					                        "provider": None,
 | 
				
			||||||
 | 
					                        "provider_obj": None,
 | 
				
			||||||
 | 
					                        "launch_url": None,
 | 
				
			||||||
 | 
					                        "meta_launch_url": "",
 | 
				
			||||||
 | 
					                        "meta_icon": None,
 | 
				
			||||||
 | 
					                        "meta_description": "",
 | 
				
			||||||
 | 
					                        "meta_publisher": "",
 | 
				
			||||||
 | 
					                        "policy_engine_mode": "any",
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_list_superuser_full_list(self):
 | 
				
			||||||
 | 
					        """Test list operation with superuser_full_list"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:application-list") + "?superuser_full_list=true"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
 | 
					            force_str(response.content),
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "pagination": {
 | 
				
			||||||
 | 
					                    "next": 0,
 | 
				
			||||||
 | 
					                    "previous": 0,
 | 
				
			||||||
 | 
					                    "count": 2,
 | 
				
			||||||
 | 
					                    "current": 1,
 | 
				
			||||||
 | 
					                    "total_pages": 1,
 | 
				
			||||||
 | 
					                    "start_index": 1,
 | 
				
			||||||
 | 
					                    "end_index": 2,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                "results": [
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        "pk": str(self.allowed.pk),
 | 
				
			||||||
 | 
					                        "name": "allowed",
 | 
				
			||||||
 | 
					                        "slug": "allowed",
 | 
				
			||||||
 | 
					                        "provider": None,
 | 
				
			||||||
 | 
					                        "provider_obj": None,
 | 
				
			||||||
 | 
					                        "launch_url": None,
 | 
				
			||||||
 | 
					                        "meta_launch_url": "",
 | 
				
			||||||
 | 
					                        "meta_icon": None,
 | 
				
			||||||
 | 
					                        "meta_description": "",
 | 
				
			||||||
 | 
					                        "meta_publisher": "",
 | 
				
			||||||
 | 
					                        "policy_engine_mode": "any",
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        "launch_url": None,
 | 
				
			||||||
 | 
					                        "meta_description": "",
 | 
				
			||||||
 | 
					                        "meta_icon": None,
 | 
				
			||||||
 | 
					                        "meta_launch_url": "",
 | 
				
			||||||
 | 
					                        "meta_publisher": "",
 | 
				
			||||||
 | 
					                        "name": "denied",
 | 
				
			||||||
 | 
					                        "pk": str(self.denied.pk),
 | 
				
			||||||
 | 
					                        "policy_engine_mode": "any",
 | 
				
			||||||
 | 
					                        "provider": None,
 | 
				
			||||||
 | 
					                        "provider_obj": None,
 | 
				
			||||||
 | 
					                        "slug": "denied",
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
							
								
								
									
										31
									
								
								authentik/core/tests/test_authenticated_sessions_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								authentik/core/tests/test_authenticated_sessions_api.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
				
			|||||||
 | 
					"""Test AuthenticatedSessions API"""
 | 
				
			||||||
 | 
					from json import loads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.urls.base import reverse
 | 
				
			||||||
 | 
					from django.utils.encoding import force_str
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestAuthenticatedSessionsAPI(APITestCase):
 | 
				
			||||||
 | 
					    """Test AuthenticatedSessions API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        super().setUp()
 | 
				
			||||||
 | 
					        self.user = User.objects.get(username="akadmin")
 | 
				
			||||||
 | 
					        self.other_user = User.objects.create(username="normal-user")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_list(self):
 | 
				
			||||||
 | 
					        """Test session list endpoint"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_non_admin_list(self):
 | 
				
			||||||
 | 
					        """Test non-admin list"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.other_user)
 | 
				
			||||||
 | 
					        response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        body = loads(force_str(response.content))
 | 
				
			||||||
 | 
					        self.assertEqual(body["pagination"]["count"], 1)
 | 
				
			||||||
@ -17,6 +17,9 @@ class TestImpersonation(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_impersonate_simple(self):
 | 
					    def test_impersonate_simple(self):
 | 
				
			||||||
        """test simple impersonation and un-impersonation"""
 | 
					        """test simple impersonation and un-impersonation"""
 | 
				
			||||||
 | 
					        # test with an inactive user to ensure that still works
 | 
				
			||||||
 | 
					        self.other_user.is_active = False
 | 
				
			||||||
 | 
					        self.other_user.save()
 | 
				
			||||||
        self.client.force_login(self.akadmin)
 | 
					        self.client.force_login(self.akadmin)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.client.get(
 | 
					        self.client.get(
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,14 @@
 | 
				
			|||||||
"""authentik core models tests"""
 | 
					"""authentik core models tests"""
 | 
				
			||||||
from time import sleep
 | 
					from time import sleep
 | 
				
			||||||
 | 
					from typing import Callable, Type
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.test import TestCase
 | 
					from django.test import TestCase
 | 
				
			||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from guardian.shortcuts import get_anonymous_user
 | 
					from guardian.shortcuts import get_anonymous_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Token
 | 
					from authentik.core.models import Provider, Source, Token
 | 
				
			||||||
 | 
					from authentik.flows.models import Stage
 | 
				
			||||||
 | 
					from authentik.lib.utils.reflection import all_subclasses
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestModels(TestCase):
 | 
					class TestModels(TestCase):
 | 
				
			||||||
@ -18,9 +21,46 @@ class TestModels(TestCase):
 | 
				
			|||||||
        self.assertTrue(token.is_expired)
 | 
					        self.assertTrue(token.is_expired)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_token_expire_no_expire(self):
 | 
					    def test_token_expire_no_expire(self):
 | 
				
			||||||
        """Test token expiring with "expiring" set """
 | 
					        """Test token expiring with "expiring" set"""
 | 
				
			||||||
        token = Token.objects.create(
 | 
					        token = Token.objects.create(
 | 
				
			||||||
            expires=now(), user=get_anonymous_user(), expiring=False
 | 
					            expires=now(), user=get_anonymous_user(), expiring=False
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        sleep(0.5)
 | 
					        sleep(0.5)
 | 
				
			||||||
        self.assertFalse(token.is_expired)
 | 
					        self.assertFalse(token.is_expired)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def source_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
				
			||||||
 | 
					    """Test source"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tester(self: TestModels):
 | 
				
			||||||
 | 
					        model_class = None
 | 
				
			||||||
 | 
					        if test_model._meta.abstract:
 | 
				
			||||||
 | 
					            model_class = test_model.__bases__[0]()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            model_class = test_model()
 | 
				
			||||||
 | 
					        model_class.slug = "test"
 | 
				
			||||||
 | 
					        self.assertIsNotNone(model_class.component)
 | 
				
			||||||
 | 
					        _ = model_class.ui_login_button
 | 
				
			||||||
 | 
					        _ = model_class.ui_user_settings
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return tester
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def provider_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
				
			||||||
 | 
					    """Test provider"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def tester(self: TestModels):
 | 
				
			||||||
 | 
					        model_class = None
 | 
				
			||||||
 | 
					        if test_model._meta.abstract:
 | 
				
			||||||
 | 
					            model_class = test_model.__bases__[0]()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            model_class = test_model()
 | 
				
			||||||
 | 
					        self.assertIsNotNone(model_class.component)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return tester
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					for model in all_subclasses(Source):
 | 
				
			||||||
 | 
					    setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model))
 | 
				
			||||||
 | 
					for model in all_subclasses(Provider):
 | 
				
			||||||
 | 
					    setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model))
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										29
									
								
								authentik/core/tests/test_users_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								authentik/core/tests/test_users_api.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,29 @@
 | 
				
			|||||||
 | 
					"""Test Users API"""
 | 
				
			||||||
 | 
					from django.urls.base import reverse
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestUsersAPI(APITestCase):
 | 
				
			||||||
 | 
					    """Test Users API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        self.admin = User.objects.get(username="akadmin")
 | 
				
			||||||
 | 
					        self.user = User.objects.create(username="test-user")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_metrics(self):
 | 
				
			||||||
 | 
					        """Test user's metrics"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.admin)
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_metrics_denied(self):
 | 
				
			||||||
 | 
					        """Test user's metrics (non-superuser)"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 403)
 | 
				
			||||||
@ -5,6 +5,7 @@ from typing import Optional
 | 
				
			|||||||
from rest_framework.fields import CharField
 | 
					from rest_framework.fields import CharField
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
 | 
					from authentik.flows.challenge import Challenge
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@dataclass
 | 
					@dataclass
 | 
				
			||||||
@ -14,24 +15,17 @@ class UILoginButton:
 | 
				
			|||||||
    # Name, ran through i18n
 | 
					    # Name, ran through i18n
 | 
				
			||||||
    name: str
 | 
					    name: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # URL Which Button points to
 | 
					    # Challenge which is presented to the user when they click the button
 | 
				
			||||||
    url: str
 | 
					    challenge: Challenge
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Icon URL, used as-is
 | 
					    # Icon URL, used as-is
 | 
				
			||||||
    icon_url: Optional[str] = None
 | 
					    icon_url: Optional[str] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UILoginButtonSerializer(PassiveSerializer):
 | 
					 | 
				
			||||||
    """Serializer for Login buttons of sources"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    name = CharField()
 | 
					 | 
				
			||||||
    url = CharField()
 | 
					 | 
				
			||||||
    icon_url = CharField(required=False, allow_null=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class UserSettingSerializer(PassiveSerializer):
 | 
					class UserSettingSerializer(PassiveSerializer):
 | 
				
			||||||
    """Serializer for User settings for stages and sources"""
 | 
					    """Serializer for User settings for stages and sources"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    object_uid = CharField()
 | 
					    object_uid = CharField()
 | 
				
			||||||
    component = CharField()
 | 
					    component = CharField()
 | 
				
			||||||
    title = CharField()
 | 
					    title = CharField()
 | 
				
			||||||
 | 
					    configure_url = CharField(required=False)
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,8 @@ from django.views.generic import RedirectView
 | 
				
			|||||||
from django.views.generic.base import TemplateView
 | 
					from django.views.generic.base import TemplateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.views import impersonate
 | 
					from authentik.core.views import impersonate
 | 
				
			||||||
 | 
					from authentik.core.views.interface import FlowInterfaceView
 | 
				
			||||||
 | 
					from authentik.core.views.session import EndSessionView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
urlpatterns = [
 | 
					urlpatterns = [
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
@ -32,7 +34,18 @@ urlpatterns = [
 | 
				
			|||||||
    ),
 | 
					    ),
 | 
				
			||||||
    path(
 | 
					    path(
 | 
				
			||||||
        "if/flow/<slug:flow_slug>/",
 | 
					        "if/flow/<slug:flow_slug>/",
 | 
				
			||||||
        ensure_csrf_cookie(TemplateView.as_view(template_name="if/flow.html")),
 | 
					        ensure_csrf_cookie(FlowInterfaceView.as_view()),
 | 
				
			||||||
        name="if-flow",
 | 
					        name="if-flow",
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					    path(
 | 
				
			||||||
 | 
					        "if/session-end/<slug:application_slug>/",
 | 
				
			||||||
 | 
					        ensure_csrf_cookie(EndSessionView.as_view()),
 | 
				
			||||||
 | 
					        name="if-session-end",
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    # Fallback for WS
 | 
				
			||||||
 | 
					    path("ws/outpost/<uuid:pk>/", TemplateView.as_view(template_name="if/admin.html")),
 | 
				
			||||||
 | 
					    path(
 | 
				
			||||||
 | 
					        "ws/client/",
 | 
				
			||||||
 | 
					        TemplateView.as_view(template_name="if/admin.html"),
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										17
									
								
								authentik/core/views/interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								authentik/core/views/interface.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					"""Interface views"""
 | 
				
			||||||
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.shortcuts import get_object_or_404
 | 
				
			||||||
 | 
					from django.views.generic.base import TemplateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.flows.models import Flow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FlowInterfaceView(TemplateView):
 | 
				
			||||||
 | 
					    """Flow interface"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    template_name = "if/flow.html"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
 | 
				
			||||||
 | 
					        kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
 | 
				
			||||||
 | 
					        return super().get_context_data(**kwargs)
 | 
				
			||||||
@ -1,22 +1,24 @@
 | 
				
			|||||||
"""authentik OAuth2 Session Views"""
 | 
					"""authentik Session Views"""
 | 
				
			||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.shortcuts import get_object_or_404
 | 
					from django.shortcuts import get_object_or_404
 | 
				
			||||||
from django.views.generic.base import TemplateView
 | 
					from django.views.generic.base import TemplateView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application
 | 
					from authentik.core.models import Application
 | 
				
			||||||
 | 
					from authentik.policies.views import PolicyAccessView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EndSessionView(TemplateView):
 | 
					class EndSessionView(TemplateView, PolicyAccessView):
 | 
				
			||||||
    """Allow the client to end the Session"""
 | 
					    """Allow the client to end the Session"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    template_name = "providers/oauth2/end_session.html"
 | 
					    template_name = "if/end_session.html"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
 | 
					    def resolve_provider_application(self):
 | 
				
			||||||
        context = super().get_context_data(**kwargs)
 | 
					        self.application = get_object_or_404(
 | 
				
			||||||
 | 
					 | 
				
			||||||
        context["application"] = get_object_or_404(
 | 
					 | 
				
			||||||
            Application, slug=self.kwargs["application_slug"]
 | 
					            Application, slug=self.kwargs["application_slug"]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
 | 
				
			||||||
 | 
					        context = super().get_context_data(**kwargs)
 | 
				
			||||||
 | 
					        context["application"] = self.application
 | 
				
			||||||
        return context
 | 
					        return context
 | 
				
			||||||
@ -1,10 +1,14 @@
 | 
				
			|||||||
"""Crypto API Views"""
 | 
					"""Crypto API Views"""
 | 
				
			||||||
import django_filters
 | 
					 | 
				
			||||||
from cryptography.hazmat.backends import default_backend
 | 
					from cryptography.hazmat.backends import default_backend
 | 
				
			||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
 | 
					from cryptography.hazmat.primitives.serialization import load_pem_private_key
 | 
				
			||||||
from cryptography.x509 import load_pem_x509_certificate
 | 
					from cryptography.x509 import load_pem_x509_certificate
 | 
				
			||||||
 | 
					from django.http.response import HttpResponse
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from django_filters import FilterSet
 | 
				
			||||||
 | 
					from django_filters.filters import BooleanFilter
 | 
				
			||||||
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
 | 
					from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import (
 | 
					from rest_framework.fields import (
 | 
				
			||||||
    CharField,
 | 
					    CharField,
 | 
				
			||||||
@ -18,6 +22,7 @@ from rest_framework.serializers import ModelSerializer, ValidationError
 | 
				
			|||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
from authentik.crypto.builder import CertificateBuilder
 | 
					from authentik.crypto.builder import CertificateBuilder
 | 
				
			||||||
from authentik.crypto.models import CertificateKeyPair
 | 
					from authentik.crypto.models import CertificateKeyPair
 | 
				
			||||||
@ -31,6 +36,9 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
    cert_subject = SerializerMethodField()
 | 
					    cert_subject = SerializerMethodField()
 | 
				
			||||||
    private_key_available = SerializerMethodField()
 | 
					    private_key_available = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    certificate_download_url = SerializerMethodField()
 | 
				
			||||||
 | 
					    private_key_download_url = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_cert_subject(self, instance: CertificateKeyPair) -> str:
 | 
					    def get_cert_subject(self, instance: CertificateKeyPair) -> str:
 | 
				
			||||||
        """Get certificate subject as full rfc4514"""
 | 
					        """Get certificate subject as full rfc4514"""
 | 
				
			||||||
        return instance.certificate.subject.rfc4514_string()
 | 
					        return instance.certificate.subject.rfc4514_string()
 | 
				
			||||||
@ -39,7 +47,27 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
        """Show if this keypair has a private key configured or not"""
 | 
					        """Show if this keypair has a private key configured or not"""
 | 
				
			||||||
        return instance.key_data != "" and instance.key_data is not None
 | 
					        return instance.key_data != "" and instance.key_data is not None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate_certificate_data(self, value):
 | 
					    def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
 | 
				
			||||||
 | 
					        """Get URL to download certificate"""
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-view-certificate",
 | 
				
			||||||
 | 
					                kwargs={"pk": instance.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            + "?download"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_private_key_download_url(self, instance: CertificateKeyPair) -> str:
 | 
				
			||||||
 | 
					        """Get URL to download private key"""
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-view-private-key",
 | 
				
			||||||
 | 
					                kwargs={"pk": instance.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            + "?download"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate_certificate_data(self, value: str) -> str:
 | 
				
			||||||
        """Verify that input is a valid PEM x509 Certificate"""
 | 
					        """Verify that input is a valid PEM x509 Certificate"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            load_pem_x509_certificate(value.encode("utf-8"), default_backend())
 | 
					            load_pem_x509_certificate(value.encode("utf-8"), default_backend())
 | 
				
			||||||
@ -47,7 +75,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
            raise ValidationError("Unable to load certificate.")
 | 
					            raise ValidationError("Unable to load certificate.")
 | 
				
			||||||
        return value
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate_key_data(self, value):
 | 
					    def validate_key_data(self, value: str) -> str:
 | 
				
			||||||
        """Verify that input is a valid PEM RSA Key"""
 | 
					        """Verify that input is a valid PEM RSA Key"""
 | 
				
			||||||
        # Since this field is optional, data can be empty.
 | 
					        # Since this field is optional, data can be empty.
 | 
				
			||||||
        if value != "":
 | 
					        if value != "":
 | 
				
			||||||
@ -57,8 +85,10 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
                    password=None,
 | 
					                    password=None,
 | 
				
			||||||
                    backend=default_backend(),
 | 
					                    backend=default_backend(),
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            except ValueError:
 | 
					            except (ValueError, TypeError):
 | 
				
			||||||
                raise ValidationError("Unable to load private key.")
 | 
					                raise ValidationError(
 | 
				
			||||||
 | 
					                    "Unable to load private key (possibly encrypted?)."
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
        return value
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
@ -73,6 +103,8 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
            "cert_expiry",
 | 
					            "cert_expiry",
 | 
				
			||||||
            "cert_subject",
 | 
					            "cert_subject",
 | 
				
			||||||
            "private_key_available",
 | 
					            "private_key_available",
 | 
				
			||||||
 | 
					            "certificate_download_url",
 | 
				
			||||||
 | 
					            "private_key_download_url",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        extra_kwargs = {
 | 
					        extra_kwargs = {
 | 
				
			||||||
            "key_data": {"write_only": True},
 | 
					            "key_data": {"write_only": True},
 | 
				
			||||||
@ -96,10 +128,10 @@ class CertificateGenerationSerializer(PassiveSerializer):
 | 
				
			|||||||
    validity_days = IntegerField(initial=365)
 | 
					    validity_days = IntegerField(initial=365)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CertificateKeyPairFilter(django_filters.FilterSet):
 | 
					class CertificateKeyPairFilter(FilterSet):
 | 
				
			||||||
    """Filter for certificates"""
 | 
					    """Filter for certificates"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    has_key = django_filters.BooleanFilter(
 | 
					    has_key = BooleanFilter(
 | 
				
			||||||
        label="Only return certificate-key pairs with keys", method="filter_has_key"
 | 
					        label="Only return certificate-key pairs with keys", method="filter_has_key"
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -113,7 +145,7 @@ class CertificateKeyPairFilter(django_filters.FilterSet):
 | 
				
			|||||||
        fields = ["name"]
 | 
					        fields = ["name"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CertificateKeyPairViewSet(ModelViewSet):
 | 
					class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """CertificateKeyPair Viewset"""
 | 
					    """CertificateKeyPair Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = CertificateKeyPair.objects.all()
 | 
					    queryset = CertificateKeyPair.objects.all()
 | 
				
			||||||
@ -121,9 +153,12 @@ class CertificateKeyPairViewSet(ModelViewSet):
 | 
				
			|||||||
    filterset_class = CertificateKeyPairFilter
 | 
					    filterset_class = CertificateKeyPairFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required(None, ["authentik_crypto.add_certificatekeypair"])
 | 
					    @permission_required(None, ["authentik_crypto.add_certificatekeypair"])
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        request_body=CertificateGenerationSerializer(),
 | 
					        request=CertificateGenerationSerializer(),
 | 
				
			||||||
        responses={200: CertificateKeyPairSerializer, 400: "Bad request"},
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: CertificateKeyPairSerializer,
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Bad request"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=False, methods=["POST"])
 | 
					    @action(detail=False, methods=["POST"])
 | 
				
			||||||
    def generate(self, request: Request) -> Response:
 | 
					    def generate(self, request: Request) -> Response:
 | 
				
			||||||
@ -143,7 +178,16 @@ class CertificateKeyPairViewSet(ModelViewSet):
 | 
				
			|||||||
        serializer = self.get_serializer(instance)
 | 
					        serializer = self.get_serializer(instance)
 | 
				
			||||||
        return Response(serializer.data)
 | 
					        return Response(serializer.data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)})
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                name="download",
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                type=OpenApiTypes.BOOL,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        responses={200: CertificateDataSerializer(many=False)},
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    # pylint: disable=invalid-name, unused-argument
 | 
					    # pylint: disable=invalid-name, unused-argument
 | 
				
			||||||
    def view_certificate(self, request: Request, pk: str) -> Response:
 | 
					    def view_certificate(self, request: Request, pk: str) -> Response:
 | 
				
			||||||
@ -154,11 +198,29 @@ class CertificateKeyPairViewSet(ModelViewSet):
 | 
				
			|||||||
            secret=certificate,
 | 
					            secret=certificate,
 | 
				
			||||||
            type="certificate",
 | 
					            type="certificate",
 | 
				
			||||||
        ).from_http(request)
 | 
					        ).from_http(request)
 | 
				
			||||||
 | 
					        if "download" in request._request.GET:
 | 
				
			||||||
 | 
					            # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
 | 
				
			||||||
 | 
					            response = HttpResponse(
 | 
				
			||||||
 | 
					                certificate.certificate_data, content_type="application/x-pem-file"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            response[
 | 
				
			||||||
 | 
					                "Content-Disposition"
 | 
				
			||||||
 | 
					            ] = f'attachment; filename="{certificate.name}_certificate.pem"'
 | 
				
			||||||
 | 
					            return response
 | 
				
			||||||
        return Response(
 | 
					        return Response(
 | 
				
			||||||
            CertificateDataSerializer({"data": certificate.certificate_data}).data
 | 
					            CertificateDataSerializer({"data": certificate.certificate_data}).data
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)})
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                name="download",
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                type=OpenApiTypes.BOOL,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        responses={200: CertificateDataSerializer(many=False)},
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    # pylint: disable=invalid-name, unused-argument
 | 
					    # pylint: disable=invalid-name, unused-argument
 | 
				
			||||||
    def view_private_key(self, request: Request, pk: str) -> Response:
 | 
					    def view_private_key(self, request: Request, pk: str) -> Response:
 | 
				
			||||||
@ -169,4 +231,13 @@ class CertificateKeyPairViewSet(ModelViewSet):
 | 
				
			|||||||
            secret=certificate,
 | 
					            secret=certificate,
 | 
				
			||||||
            type="private_key",
 | 
					            type="private_key",
 | 
				
			||||||
        ).from_http(request)
 | 
					        ).from_http(request)
 | 
				
			||||||
 | 
					        if "download" in request._request.GET:
 | 
				
			||||||
 | 
					            # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
 | 
				
			||||||
 | 
					            response = HttpResponse(
 | 
				
			||||||
 | 
					                certificate.key_data, content_type="application/x-pem-file"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            response[
 | 
				
			||||||
 | 
					                "Content-Disposition"
 | 
				
			||||||
 | 
					            ] = f'attachment; filename="{certificate.name}_private_key.pem"'
 | 
				
			||||||
 | 
					            return response
 | 
				
			||||||
        return Response(CertificateDataSerializer({"data": certificate.key_data}).data)
 | 
					        return Response(CertificateDataSerializer({"data": certificate.key_data}).data)
 | 
				
			||||||
 | 
				
			|||||||
@ -33,7 +33,7 @@ class CertificateBuilder:
 | 
				
			|||||||
    def save(self) -> Optional[CertificateKeyPair]:
 | 
					    def save(self) -> Optional[CertificateKeyPair]:
 | 
				
			||||||
        """Save generated certificate as model"""
 | 
					        """Save generated certificate as model"""
 | 
				
			||||||
        if not self.__certificate:
 | 
					        if not self.__certificate:
 | 
				
			||||||
            return None
 | 
					            raise ValueError("Certificated hasn't been built yet")
 | 
				
			||||||
        return CertificateKeyPair.objects.create(
 | 
					        return CertificateKeyPair.objects.create(
 | 
				
			||||||
            name=self.common_name,
 | 
					            name=self.common_name,
 | 
				
			||||||
            certificate_data=self.certificate,
 | 
					            certificate_data=self.certificate,
 | 
				
			||||||
 | 
				
			|||||||
@ -2,10 +2,16 @@
 | 
				
			|||||||
import datetime
 | 
					import datetime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.test import TestCase
 | 
					from django.test import TestCase
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import DeleteAction
 | 
				
			||||||
 | 
					from authentik.core.models import User
 | 
				
			||||||
from authentik.crypto.api import CertificateKeyPairSerializer
 | 
					from authentik.crypto.api import CertificateKeyPairSerializer
 | 
				
			||||||
from authentik.crypto.builder import CertificateBuilder
 | 
					from authentik.crypto.builder import CertificateBuilder
 | 
				
			||||||
from authentik.crypto.models import CertificateKeyPair
 | 
					from authentik.crypto.models import CertificateKeyPair
 | 
				
			||||||
 | 
					from authentik.flows.models import Flow
 | 
				
			||||||
 | 
					from authentik.providers.oauth2.generators import generate_client_secret
 | 
				
			||||||
 | 
					from authentik.providers.oauth2.models import OAuth2Provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestCrypto(TestCase):
 | 
					class TestCrypto(TestCase):
 | 
				
			||||||
@ -37,6 +43,8 @@ class TestCrypto(TestCase):
 | 
				
			|||||||
        """Test Builder"""
 | 
					        """Test Builder"""
 | 
				
			||||||
        builder = CertificateBuilder()
 | 
					        builder = CertificateBuilder()
 | 
				
			||||||
        builder.common_name = "test-cert"
 | 
					        builder.common_name = "test-cert"
 | 
				
			||||||
 | 
					        with self.assertRaises(ValueError):
 | 
				
			||||||
 | 
					            builder.save()
 | 
				
			||||||
        builder.build(
 | 
					        builder.build(
 | 
				
			||||||
            subject_alt_names=[],
 | 
					            subject_alt_names=[],
 | 
				
			||||||
            validity_days=3,
 | 
					            validity_days=3,
 | 
				
			||||||
@ -45,3 +53,77 @@ class TestCrypto(TestCase):
 | 
				
			|||||||
        now = datetime.datetime.today()
 | 
					        now = datetime.datetime.today()
 | 
				
			||||||
        self.assertEqual(instance.name, "test-cert")
 | 
					        self.assertEqual(instance.name, "test-cert")
 | 
				
			||||||
        self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
 | 
					        self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_certificate_download(self):
 | 
				
			||||||
 | 
					        """Test certificate export (download)"""
 | 
				
			||||||
 | 
					        self.client.force_login(User.objects.get(username="akadmin"))
 | 
				
			||||||
 | 
					        keypair = CertificateKeyPair.objects.first()
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-view-certificate",
 | 
				
			||||||
 | 
					                kwargs={"pk": keypair.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(200, response.status_code)
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-view-certificate",
 | 
				
			||||||
 | 
					                kwargs={"pk": keypair.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            + "?download",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(200, response.status_code)
 | 
				
			||||||
 | 
					        self.assertIn("Content-Disposition", response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_private_key_download(self):
 | 
				
			||||||
 | 
					        """Test private_key export (download)"""
 | 
				
			||||||
 | 
					        self.client.force_login(User.objects.get(username="akadmin"))
 | 
				
			||||||
 | 
					        keypair = CertificateKeyPair.objects.first()
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-view-private-key",
 | 
				
			||||||
 | 
					                kwargs={"pk": keypair.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(200, response.status_code)
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-view-private-key",
 | 
				
			||||||
 | 
					                kwargs={"pk": keypair.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            + "?download",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(200, response.status_code)
 | 
				
			||||||
 | 
					        self.assertIn("Content-Disposition", response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_used_by(self):
 | 
				
			||||||
 | 
					        """Test used_by endpoint"""
 | 
				
			||||||
 | 
					        self.client.force_login(User.objects.get(username="akadmin"))
 | 
				
			||||||
 | 
					        keypair = CertificateKeyPair.objects.first()
 | 
				
			||||||
 | 
					        provider = OAuth2Provider.objects.create(
 | 
				
			||||||
 | 
					            name="test",
 | 
				
			||||||
 | 
					            client_id="test",
 | 
				
			||||||
 | 
					            client_secret=generate_client_secret(),
 | 
				
			||||||
 | 
					            authorization_flow=Flow.objects.first(),
 | 
				
			||||||
 | 
					            redirect_uris="http://localhost",
 | 
				
			||||||
 | 
					            rsa_key=CertificateKeyPair.objects.first(),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-used-by",
 | 
				
			||||||
 | 
					                kwargs={"pk": keypair.pk},
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(200, response.status_code)
 | 
				
			||||||
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
 | 
					            response.content.decode(),
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "app": "authentik_providers_oauth2",
 | 
				
			||||||
 | 
					                    "model_name": "oauth2provider",
 | 
				
			||||||
 | 
					                    "pk": str(provider.pk),
 | 
				
			||||||
 | 
					                    "name": str(provider),
 | 
				
			||||||
 | 
					                    "action": DeleteAction.SET_NULL.name,
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -2,16 +2,17 @@
 | 
				
			|||||||
import django_filters
 | 
					import django_filters
 | 
				
			||||||
from django.db.models.aggregates import Count
 | 
					from django.db.models.aggregates import Count
 | 
				
			||||||
from django.db.models.fields.json import KeyTextTransform
 | 
					from django.db.models.fields.json import KeyTextTransform
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
 | 
					from drf_spectacular.utils import OpenApiParameter, extend_schema
 | 
				
			||||||
from guardian.shortcuts import get_objects_for_user
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import CharField, DictField, IntegerField
 | 
					from rest_framework.fields import CharField, DictField, IntegerField
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.serializers import ModelSerializer, Serializer
 | 
					from rest_framework.serializers import ModelSerializer
 | 
				
			||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
 | 
					from rest_framework.viewsets import ReadOnlyModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.utils import TypeCreateSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -35,34 +36,17 @@ class EventSerializer(ModelSerializer):
 | 
				
			|||||||
            "client_ip",
 | 
					            "client_ip",
 | 
				
			||||||
            "created",
 | 
					            "created",
 | 
				
			||||||
            "expires",
 | 
					            "expires",
 | 
				
			||||||
 | 
					            "tenant",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EventTopPerUserParams(Serializer):
 | 
					class EventTopPerUserSerializer(PassiveSerializer):
 | 
				
			||||||
    """Query params for top_per_user"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    top_n = IntegerField(default=15)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def create(self, request: Request) -> Response:
 | 
					 | 
				
			||||||
        raise NotImplementedError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def update(self, request: Request) -> Response:
 | 
					 | 
				
			||||||
        raise NotImplementedError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class EventTopPerUserSerializer(Serializer):
 | 
					 | 
				
			||||||
    """Response object of Event's top_per_user"""
 | 
					    """Response object of Event's top_per_user"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    application = DictField()
 | 
					    application = DictField()
 | 
				
			||||||
    counted_events = IntegerField()
 | 
					    counted_events = IntegerField()
 | 
				
			||||||
    unique_users = IntegerField()
 | 
					    unique_users = IntegerField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create(self, request: Request) -> Response:
 | 
					 | 
				
			||||||
        raise NotImplementedError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def update(self, request: Request) -> Response:
 | 
					 | 
				
			||||||
        raise NotImplementedError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EventsFilter(django_filters.FilterSet):
 | 
					class EventsFilter(django_filters.FilterSet):
 | 
				
			||||||
    """Filter for events"""
 | 
					    """Filter for events"""
 | 
				
			||||||
@ -93,6 +77,11 @@ class EventsFilter(django_filters.FilterSet):
 | 
				
			|||||||
        field_name="action",
 | 
					        field_name="action",
 | 
				
			||||||
        lookup_expr="icontains",
 | 
					        lookup_expr="icontains",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					    tenant_name = django_filters.CharFilter(
 | 
				
			||||||
 | 
					        field_name="tenant",
 | 
				
			||||||
 | 
					        lookup_expr="name",
 | 
				
			||||||
 | 
					        label="Tenant name",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def filter_context_model_pk(self, queryset, name, value):
 | 
					    def filter_context_model_pk(self, queryset, name, value):
 | 
				
			||||||
@ -123,16 +112,23 @@ class EventViewSet(ReadOnlyModelViewSet):
 | 
				
			|||||||
    ]
 | 
					    ]
 | 
				
			||||||
    filterset_class = EventsFilter
 | 
					    filterset_class = EventsFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        method="GET",
 | 
					        methods=["GET"],
 | 
				
			||||||
        responses={200: EventTopPerUserSerializer(many=True)},
 | 
					        responses={200: EventTopPerUserSerializer(many=True)},
 | 
				
			||||||
        query_serializer=EventTopPerUserParams,
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                "top_n",
 | 
				
			||||||
 | 
					                type=OpenApiTypes.INT,
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                required=False,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=False, methods=["GET"])
 | 
					    @action(detail=False, methods=["GET"], pagination_class=None)
 | 
				
			||||||
    def top_per_user(self, request: Request):
 | 
					    def top_per_user(self, request: Request):
 | 
				
			||||||
        """Get the top_n events grouped by user count"""
 | 
					        """Get the top_n events grouped by user count"""
 | 
				
			||||||
        filtered_action = request.query_params.get("action", EventAction.LOGIN)
 | 
					        filtered_action = request.query_params.get("action", EventAction.LOGIN)
 | 
				
			||||||
        top_n = request.query_params.get("top_n", 15)
 | 
					        top_n = int(request.query_params.get("top_n", "15"))
 | 
				
			||||||
        return Response(
 | 
					        return Response(
 | 
				
			||||||
            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
					            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
				
			||||||
            .filter(action=filtered_action)
 | 
					            .filter(action=filtered_action)
 | 
				
			||||||
@ -146,7 +142,7 @@ class EventViewSet(ReadOnlyModelViewSet):
 | 
				
			|||||||
            .order_by("-counted_events")[:top_n]
 | 
					            .order_by("-counted_events")[:top_n]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
					    @extend_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def actions(self, request: Request) -> Response:
 | 
					    def actions(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all actions"""
 | 
					        """Get all actions"""
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,13 @@
 | 
				
			|||||||
"""Notification API Views"""
 | 
					"""Notification API Views"""
 | 
				
			||||||
 | 
					from django_filters.rest_framework import DjangoFilterBackend
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
from rest_framework.fields import ReadOnlyField
 | 
					from rest_framework.fields import ReadOnlyField
 | 
				
			||||||
 | 
					from rest_framework.filters import OrderingFilter, SearchFilter
 | 
				
			||||||
from rest_framework.serializers import ModelSerializer
 | 
					from rest_framework.serializers import ModelSerializer
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.api.authorization import OwnerFilter, OwnerPermissions
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.events.api.event import EventSerializer
 | 
					from authentik.events.api.event import EventSerializer
 | 
				
			||||||
from authentik.events.models import Notification
 | 
					from authentik.events.models import Notification
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -32,6 +36,7 @@ class NotificationViewSet(
 | 
				
			|||||||
    mixins.RetrieveModelMixin,
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
    mixins.UpdateModelMixin,
 | 
					    mixins.UpdateModelMixin,
 | 
				
			||||||
    mixins.DestroyModelMixin,
 | 
					    mixins.DestroyModelMixin,
 | 
				
			||||||
 | 
					    UsedByMixin,
 | 
				
			||||||
    mixins.ListModelMixin,
 | 
					    mixins.ListModelMixin,
 | 
				
			||||||
    GenericViewSet,
 | 
					    GenericViewSet,
 | 
				
			||||||
):
 | 
					):
 | 
				
			||||||
@ -46,8 +51,5 @@ class NotificationViewSet(
 | 
				
			|||||||
        "event",
 | 
					        "event",
 | 
				
			||||||
        "seen",
 | 
					        "seen",
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					    permission_classes = [OwnerPermissions]
 | 
				
			||||||
    def get_queryset(self):
 | 
					    filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
 | 
				
			||||||
        if not self.request:
 | 
					 | 
				
			||||||
            return super().get_queryset()
 | 
					 | 
				
			||||||
        return Notification.objects.filter(user=self.request.user)
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -2,26 +2,30 @@
 | 
				
			|||||||
from rest_framework.serializers import ModelSerializer
 | 
					from rest_framework.serializers import ModelSerializer
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.groups import GroupSerializer
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.events.models import NotificationRule
 | 
					from authentik.events.models import NotificationRule
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NotificationRuleSerializer(ModelSerializer):
 | 
					class NotificationRuleSerializer(ModelSerializer):
 | 
				
			||||||
    """NotificationRule Serializer"""
 | 
					    """NotificationRule Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    group_obj = GroupSerializer(read_only=True, source="group")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        model = NotificationRule
 | 
					        model = NotificationRule
 | 
				
			||||||
        depth = 2
 | 
					 | 
				
			||||||
        fields = [
 | 
					        fields = [
 | 
				
			||||||
            "pk",
 | 
					            "pk",
 | 
				
			||||||
            "name",
 | 
					            "name",
 | 
				
			||||||
            "transports",
 | 
					            "transports",
 | 
				
			||||||
            "severity",
 | 
					            "severity",
 | 
				
			||||||
            "group",
 | 
					            "group",
 | 
				
			||||||
 | 
					            "group_obj",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NotificationRuleViewSet(ModelViewSet):
 | 
					class NotificationRuleViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """NotificationRule Viewset"""
 | 
					    """NotificationRule Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = NotificationRule.objects.all()
 | 
					    queryset = NotificationRule.objects.all()
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,6 @@
 | 
				
			|||||||
"""NotificationTransport API Views"""
 | 
					"""NotificationTransport API Views"""
 | 
				
			||||||
from drf_yasg.utils import no_body, swagger_auto_schema
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import CharField, ListField, SerializerMethodField
 | 
					from rest_framework.fields import CharField, ListField, SerializerMethodField
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
@ -8,6 +9,7 @@ from rest_framework.serializers import ModelSerializer, Serializer
 | 
				
			|||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.events.models import (
 | 
					from authentik.events.models import (
 | 
				
			||||||
    Notification,
 | 
					    Notification,
 | 
				
			||||||
    NotificationSeverity,
 | 
					    NotificationSeverity,
 | 
				
			||||||
@ -22,7 +24,7 @@ class NotificationTransportSerializer(ModelSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    mode_verbose = SerializerMethodField()
 | 
					    mode_verbose = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_mode_verbose(self, instance: NotificationTransport):
 | 
					    def get_mode_verbose(self, instance: NotificationTransport) -> str:
 | 
				
			||||||
        """Return selected mode with a UI Label"""
 | 
					        """Return selected mode with a UI Label"""
 | 
				
			||||||
        return TransportMode(instance.mode).label
 | 
					        return TransportMode(instance.mode).label
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -51,19 +53,19 @@ class NotificationTransportTestSerializer(Serializer):
 | 
				
			|||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NotificationTransportViewSet(ModelViewSet):
 | 
					class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """NotificationTransport Viewset"""
 | 
					    """NotificationTransport Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = NotificationTransport.objects.all()
 | 
					    queryset = NotificationTransport.objects.all()
 | 
				
			||||||
    serializer_class = NotificationTransportSerializer
 | 
					    serializer_class = NotificationTransportSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_events.change_notificationtransport")
 | 
					    @permission_required("authentik_events.change_notificationtransport")
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        responses={
 | 
					        responses={
 | 
				
			||||||
            200: NotificationTransportTestSerializer(many=False),
 | 
					            200: NotificationTransportTestSerializer(many=False),
 | 
				
			||||||
            503: "Failed to test transport",
 | 
					            500: OpenApiResponse(description="Failed to test transport"),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        request_body=no_body,
 | 
					        request=OpenApiTypes.NONE,
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
 | 
				
			||||||
    # pylint: disable=invalid-name, unused-argument
 | 
					    # pylint: disable=invalid-name, unused-argument
 | 
				
			||||||
@ -83,4 +85,4 @@ class NotificationTransportViewSet(ModelViewSet):
 | 
				
			|||||||
            response.is_valid()
 | 
					            response.is_valid()
 | 
				
			||||||
            return Response(response.data)
 | 
					            return Response(response.data)
 | 
				
			||||||
        except NotificationTransportError as exc:
 | 
					        except NotificationTransportError as exc:
 | 
				
			||||||
            return Response(str(exc.__cause__ or None), status=503)
 | 
					            return Response(str(exc.__cause__ or None), status=500)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,10 @@
 | 
				
			|||||||
"""authentik events app"""
 | 
					"""authentik events app"""
 | 
				
			||||||
 | 
					from datetime import timedelta
 | 
				
			||||||
from importlib import import_module
 | 
					from importlib import import_module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.apps import AppConfig
 | 
					from django.apps import AppConfig
 | 
				
			||||||
 | 
					from django.db import ProgrammingError
 | 
				
			||||||
 | 
					from django.utils.timezone import now
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AuthentikEventsConfig(AppConfig):
 | 
					class AuthentikEventsConfig(AppConfig):
 | 
				
			||||||
@ -13,3 +16,12 @@ class AuthentikEventsConfig(AppConfig):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def ready(self):
 | 
					    def ready(self):
 | 
				
			||||||
        import_module("authentik.events.signals")
 | 
					        import_module("authentik.events.signals")
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from authentik.events.models import Event
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            date_from = now() - timedelta(days=1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for event in Event.objects.filter(created__gte=date_from):
 | 
				
			||||||
 | 
					                event._set_prom_metrics()
 | 
				
			||||||
 | 
					        except ProgrammingError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,12 @@
 | 
				
			|||||||
"""events GeoIP Reader"""
 | 
					"""events GeoIP Reader"""
 | 
				
			||||||
from typing import Optional
 | 
					from datetime import datetime
 | 
				
			||||||
 | 
					from os import stat
 | 
				
			||||||
 | 
					from time import time
 | 
				
			||||||
 | 
					from typing import Optional, TypedDict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from geoip2.database import Reader
 | 
					from geoip2.database import Reader
 | 
				
			||||||
 | 
					from geoip2.errors import GeoIP2Error
 | 
				
			||||||
 | 
					from geoip2.models import City
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
@ -9,17 +14,78 @@ from authentik.lib.config import CONFIG
 | 
				
			|||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_geoip_reader() -> Optional[Reader]:
 | 
					class GeoIPDict(TypedDict):
 | 
				
			||||||
    """Get GeoIP Reader, if configured, otherwise none"""
 | 
					    """GeoIP Details"""
 | 
				
			||||||
    path = CONFIG.y("authentik.geoip")
 | 
					
 | 
				
			||||||
    if path == "" or not path:
 | 
					    continent: str
 | 
				
			||||||
        return None
 | 
					    country: str
 | 
				
			||||||
    try:
 | 
					    lat: float
 | 
				
			||||||
        reader = Reader(path)
 | 
					    long: float
 | 
				
			||||||
        LOGGER.info("Enabled GeoIP support")
 | 
					    city: str
 | 
				
			||||||
        return reader
 | 
					 | 
				
			||||||
    except OSError:
 | 
					 | 
				
			||||||
        return None
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
GEOIP_READER = get_geoip_reader()
 | 
					class GeoIPReader:
 | 
				
			||||||
 | 
					    """Slim wrapper around GeoIP API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    __reader: Optional[Reader] = None
 | 
				
			||||||
 | 
					    __last_mtime: float = 0.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        self.__open()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __open(self):
 | 
				
			||||||
 | 
					        """Get GeoIP Reader, if configured, otherwise none"""
 | 
				
			||||||
 | 
					        path = CONFIG.y("authentik.geoip")
 | 
				
			||||||
 | 
					        if path == "" or not path:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            reader = Reader(path)
 | 
				
			||||||
 | 
					            self.__reader = reader
 | 
				
			||||||
 | 
					            self.__last_mtime = stat(path).st_mtime
 | 
				
			||||||
 | 
					            LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime)
 | 
				
			||||||
 | 
					        except OSError as exc:
 | 
				
			||||||
 | 
					            LOGGER.warning("Failed to load GeoIP database", exc=exc)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __check_expired(self):
 | 
				
			||||||
 | 
					        """Check if the geoip database has been opened longer than 8 hours,
 | 
				
			||||||
 | 
					        and re-open it, as it will probably will have been re-downloaded"""
 | 
				
			||||||
 | 
					        now = time()
 | 
				
			||||||
 | 
					        diff = datetime.fromtimestamp(now) - datetime.fromtimestamp(self.__last_mtime)
 | 
				
			||||||
 | 
					        diff_hours = diff.total_seconds() // 3600
 | 
				
			||||||
 | 
					        if diff_hours >= 8:
 | 
				
			||||||
 | 
					            LOGGER.info("GeoIP databased loaded too long, re-opening", diff=diff)
 | 
				
			||||||
 | 
					            self.__open()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def enabled(self) -> bool:
 | 
				
			||||||
 | 
					        """Check if GeoIP is enabled"""
 | 
				
			||||||
 | 
					        return bool(self.__reader)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def city(self, ip_address: str) -> Optional[City]:
 | 
				
			||||||
 | 
					        """Wrapper for Reader.city"""
 | 
				
			||||||
 | 
					        if not self.enabled:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        self.__check_expired()
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return self.__reader.city(ip_address)
 | 
				
			||||||
 | 
					        except (GeoIP2Error, ValueError):
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def city_dict(self, ip_address: str) -> Optional[GeoIPDict]:
 | 
				
			||||||
 | 
					        """Wrapper for self.city that returns a dict"""
 | 
				
			||||||
 | 
					        city = self.city(ip_address)
 | 
				
			||||||
 | 
					        if not city:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        city_dict: GeoIPDict = {
 | 
				
			||||||
 | 
					            "continent": city.continent.code,
 | 
				
			||||||
 | 
					            "country": city.country.iso_code,
 | 
				
			||||||
 | 
					            "lat": city.location.latitude,
 | 
				
			||||||
 | 
					            "long": city.location.longitude,
 | 
				
			||||||
 | 
					            "city": "",
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if city.city.name:
 | 
				
			||||||
 | 
					            city_dict["city"] = city.city.name
 | 
				
			||||||
 | 
					        return city_dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					GEOIP_READER = GeoIPReader()
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,7 @@
 | 
				
			|||||||
from functools import partial
 | 
					from functools import partial
 | 
				
			||||||
from typing import Callable
 | 
					from typing import Callable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.conf import settings
 | 
				
			||||||
from django.db.models import Model
 | 
					from django.db.models import Model
 | 
				
			||||||
from django.db.models.signals import post_save, pre_delete
 | 
					from django.db.models.signals import post_save, pre_delete
 | 
				
			||||||
from django.http import HttpRequest, HttpResponse
 | 
					from django.http import HttpRequest, HttpResponse
 | 
				
			||||||
@ -12,6 +13,7 @@ from authentik.core.models import User
 | 
				
			|||||||
from authentik.events.models import Event, EventAction, Notification
 | 
					from authentik.events.models import Event, EventAction, Notification
 | 
				
			||||||
from authentik.events.signals import EventNewThread
 | 
					from authentik.events.signals import EventNewThread
 | 
				
			||||||
from authentik.events.utils import model_to_dict
 | 
					from authentik.events.utils import model_to_dict
 | 
				
			||||||
 | 
					from authentik.lib.utils.errors import exception_to_string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AuditMiddleware:
 | 
					class AuditMiddleware:
 | 
				
			||||||
@ -54,10 +56,19 @@ class AuditMiddleware:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def process_exception(self, request: HttpRequest, exception: Exception):
 | 
					    def process_exception(self, request: HttpRequest, exception: Exception):
 | 
				
			||||||
        """Unregister handlers in case of exception"""
 | 
					        """Disconnect handlers in case of exception"""
 | 
				
			||||||
        post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
 | 
					        post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
 | 
				
			||||||
        pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
 | 
					        pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if settings.DEBUG:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        thread = EventNewThread(
 | 
				
			||||||
 | 
					            EventAction.SYSTEM_EXCEPTION,
 | 
				
			||||||
 | 
					            request,
 | 
				
			||||||
 | 
					            message=exception_to_string(exception),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        thread.run()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def post_save_handler(
 | 
					    def post_save_handler(
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										45
									
								
								authentik/events/migrations/0015_alter_event_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								authentik/events/migrations/0015_alter_event_action.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,45 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.3 on 2021-06-09 07:58
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_events", "0014_expiry"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="event",
 | 
				
			||||||
 | 
					            name="action",
 | 
				
			||||||
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                choices=[
 | 
				
			||||||
 | 
					                    ("login", "Login"),
 | 
				
			||||||
 | 
					                    ("login_failed", "Login Failed"),
 | 
				
			||||||
 | 
					                    ("logout", "Logout"),
 | 
				
			||||||
 | 
					                    ("user_write", "User Write"),
 | 
				
			||||||
 | 
					                    ("suspicious_request", "Suspicious Request"),
 | 
				
			||||||
 | 
					                    ("password_set", "Password Set"),
 | 
				
			||||||
 | 
					                    ("secret_view", "Secret View"),
 | 
				
			||||||
 | 
					                    ("invitation_used", "Invite Used"),
 | 
				
			||||||
 | 
					                    ("authorize_application", "Authorize Application"),
 | 
				
			||||||
 | 
					                    ("source_linked", "Source Linked"),
 | 
				
			||||||
 | 
					                    ("impersonation_started", "Impersonation Started"),
 | 
				
			||||||
 | 
					                    ("impersonation_ended", "Impersonation Ended"),
 | 
				
			||||||
 | 
					                    ("policy_execution", "Policy Execution"),
 | 
				
			||||||
 | 
					                    ("policy_exception", "Policy Exception"),
 | 
				
			||||||
 | 
					                    ("property_mapping_exception", "Property Mapping Exception"),
 | 
				
			||||||
 | 
					                    ("system_task_execution", "System Task Execution"),
 | 
				
			||||||
 | 
					                    ("system_task_exception", "System Task Exception"),
 | 
				
			||||||
 | 
					                    ("configuration_error", "Configuration Error"),
 | 
				
			||||||
 | 
					                    ("model_created", "Model Created"),
 | 
				
			||||||
 | 
					                    ("model_updated", "Model Updated"),
 | 
				
			||||||
 | 
					                    ("model_deleted", "Model Deleted"),
 | 
				
			||||||
 | 
					                    ("email_sent", "Email Sent"),
 | 
				
			||||||
 | 
					                    ("update_available", "Update Available"),
 | 
				
			||||||
 | 
					                    ("custom_", "Custom Prefix"),
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										55
									
								
								authentik/events/migrations/0016_add_tenant.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								authentik/events/migrations/0016_add_tenant.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,55 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.4 on 2021-06-14 15:33
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import authentik.events.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_events", "0015_alter_event_action"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name="event",
 | 
				
			||||||
 | 
					            name="tenant",
 | 
				
			||||||
 | 
					            field=models.JSONField(
 | 
				
			||||||
 | 
					                blank=True, default=authentik.events.models.default_tenant
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="event",
 | 
				
			||||||
 | 
					            name="action",
 | 
				
			||||||
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                choices=[
 | 
				
			||||||
 | 
					                    ("login", "Login"),
 | 
				
			||||||
 | 
					                    ("login_failed", "Login Failed"),
 | 
				
			||||||
 | 
					                    ("logout", "Logout"),
 | 
				
			||||||
 | 
					                    ("user_write", "User Write"),
 | 
				
			||||||
 | 
					                    ("suspicious_request", "Suspicious Request"),
 | 
				
			||||||
 | 
					                    ("password_set", "Password Set"),
 | 
				
			||||||
 | 
					                    ("secret_view", "Secret View"),
 | 
				
			||||||
 | 
					                    ("invitation_used", "Invite Used"),
 | 
				
			||||||
 | 
					                    ("authorize_application", "Authorize Application"),
 | 
				
			||||||
 | 
					                    ("source_linked", "Source Linked"),
 | 
				
			||||||
 | 
					                    ("impersonation_started", "Impersonation Started"),
 | 
				
			||||||
 | 
					                    ("impersonation_ended", "Impersonation Ended"),
 | 
				
			||||||
 | 
					                    ("policy_execution", "Policy Execution"),
 | 
				
			||||||
 | 
					                    ("policy_exception", "Policy Exception"),
 | 
				
			||||||
 | 
					                    ("property_mapping_exception", "Property Mapping Exception"),
 | 
				
			||||||
 | 
					                    ("system_task_execution", "System Task Execution"),
 | 
				
			||||||
 | 
					                    ("system_task_exception", "System Task Exception"),
 | 
				
			||||||
 | 
					                    ("system_exception", "System Exception"),
 | 
				
			||||||
 | 
					                    ("configuration_error", "Configuration Error"),
 | 
				
			||||||
 | 
					                    ("model_created", "Model Created"),
 | 
				
			||||||
 | 
					                    ("model_updated", "Model Updated"),
 | 
				
			||||||
 | 
					                    ("model_deleted", "Model Deleted"),
 | 
				
			||||||
 | 
					                    ("email_sent", "Email Sent"),
 | 
				
			||||||
 | 
					                    ("update_available", "Update Available"),
 | 
				
			||||||
 | 
					                    ("custom_", "Custom Prefix"),
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -10,7 +10,7 @@ from django.db import models
 | 
				
			|||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
from geoip2.errors import GeoIP2Error
 | 
					from prometheus_client import Gauge
 | 
				
			||||||
from requests import RequestException, post
 | 
					from requests import RequestException, post
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -21,13 +21,19 @@ from authentik.core.middleware import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
from authentik.core.models import ExpiringModel, Group, User
 | 
					from authentik.core.models import ExpiringModel, Group, User
 | 
				
			||||||
from authentik.events.geo import GEOIP_READER
 | 
					from authentik.events.geo import GEOIP_READER
 | 
				
			||||||
from authentik.events.utils import cleanse_dict, get_user, sanitize_dict
 | 
					from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
 | 
				
			||||||
from authentik.lib.sentry import SentryIgnoredException
 | 
					from authentik.lib.sentry import SentryIgnoredException
 | 
				
			||||||
from authentik.lib.utils.http import get_client_ip
 | 
					from authentik.lib.utils.http import get_client_ip
 | 
				
			||||||
from authentik.policies.models import PolicyBindingModel
 | 
					from authentik.policies.models import PolicyBindingModel
 | 
				
			||||||
from authentik.stages.email.utils import TemplateEmailMessage
 | 
					from authentik.stages.email.utils import TemplateEmailMessage
 | 
				
			||||||
 | 
					from authentik.tenants.utils import DEFAULT_TENANT
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger("authentik.events")
 | 
					LOGGER = get_logger("authentik.events")
 | 
				
			||||||
 | 
					GAUGE_EVENTS = Gauge(
 | 
				
			||||||
 | 
					    "authentik_events",
 | 
				
			||||||
 | 
					    "Events in authentik",
 | 
				
			||||||
 | 
					    ["action", "user_username", "app", "client_ip"],
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def default_event_duration():
 | 
					def default_event_duration():
 | 
				
			||||||
@ -35,6 +41,11 @@ def default_event_duration():
 | 
				
			|||||||
    return now() + timedelta(days=365)
 | 
					    return now() + timedelta(days=365)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def default_tenant():
 | 
				
			||||||
 | 
					    """Get a default value for tenant"""
 | 
				
			||||||
 | 
					    return sanitize_dict(model_to_dict(DEFAULT_TENANT))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NotificationTransportError(SentryIgnoredException):
 | 
					class NotificationTransportError(SentryIgnoredException):
 | 
				
			||||||
    """Error raised when a notification fails to be delivered"""
 | 
					    """Error raised when a notification fails to be delivered"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -66,12 +77,14 @@ class EventAction(models.TextChoices):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    SYSTEM_TASK_EXECUTION = "system_task_execution"
 | 
					    SYSTEM_TASK_EXECUTION = "system_task_execution"
 | 
				
			||||||
    SYSTEM_TASK_EXCEPTION = "system_task_exception"
 | 
					    SYSTEM_TASK_EXCEPTION = "system_task_exception"
 | 
				
			||||||
 | 
					    SYSTEM_EXCEPTION = "system_exception"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    CONFIGURATION_ERROR = "configuration_error"
 | 
					    CONFIGURATION_ERROR = "configuration_error"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    MODEL_CREATED = "model_created"
 | 
					    MODEL_CREATED = "model_created"
 | 
				
			||||||
    MODEL_UPDATED = "model_updated"
 | 
					    MODEL_UPDATED = "model_updated"
 | 
				
			||||||
    MODEL_DELETED = "model_deleted"
 | 
					    MODEL_DELETED = "model_deleted"
 | 
				
			||||||
 | 
					    EMAIL_SENT = "email_sent"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    UPDATE_AVAILABLE = "update_available"
 | 
					    UPDATE_AVAILABLE = "update_available"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -88,6 +101,7 @@ class Event(ExpiringModel):
 | 
				
			|||||||
    context = models.JSONField(default=dict, blank=True)
 | 
					    context = models.JSONField(default=dict, blank=True)
 | 
				
			||||||
    client_ip = models.GenericIPAddressField(null=True)
 | 
					    client_ip = models.GenericIPAddressField(null=True)
 | 
				
			||||||
    created = models.DateTimeField(auto_now_add=True)
 | 
					    created = models.DateTimeField(auto_now_add=True)
 | 
				
			||||||
 | 
					    tenant = models.JSONField(default=default_tenant, blank=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Shadow the expires attribute from ExpiringModel to override the default duration
 | 
					    # Shadow the expires attribute from ExpiringModel to override the default duration
 | 
				
			||||||
    expires = models.DateTimeField(default=default_event_duration)
 | 
					    expires = models.DateTimeField(default=default_event_duration)
 | 
				
			||||||
@ -126,6 +140,13 @@ class Event(ExpiringModel):
 | 
				
			|||||||
        """Add data from a Django-HttpRequest, allowing the creation of
 | 
					        """Add data from a Django-HttpRequest, allowing the creation of
 | 
				
			||||||
        Events independently from requests.
 | 
					        Events independently from requests.
 | 
				
			||||||
        `user` arguments optionally overrides user from requests."""
 | 
					        `user` arguments optionally overrides user from requests."""
 | 
				
			||||||
 | 
					        if request:
 | 
				
			||||||
 | 
					            self.context["http_request"] = {
 | 
				
			||||||
 | 
					                "path": request.get_full_path(),
 | 
				
			||||||
 | 
					                "method": request.method,
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        if hasattr(request, "tenant"):
 | 
				
			||||||
 | 
					            self.tenant = sanitize_dict(model_to_dict(request.tenant))
 | 
				
			||||||
        if hasattr(request, "user"):
 | 
					        if hasattr(request, "user"):
 | 
				
			||||||
            original_user = None
 | 
					            original_user = None
 | 
				
			||||||
            if hasattr(request, "session"):
 | 
					            if hasattr(request, "session"):
 | 
				
			||||||
@ -143,7 +164,7 @@ class Event(ExpiringModel):
 | 
				
			|||||||
                    request.session[SESSION_IMPERSONATE_USER]
 | 
					                    request.session[SESSION_IMPERSONATE_USER]
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
        # User 255.255.255.255 as fallback if IP cannot be determined
 | 
					        # User 255.255.255.255 as fallback if IP cannot be determined
 | 
				
			||||||
        self.client_ip = get_client_ip(request) or "255.255.255.255"
 | 
					        self.client_ip = get_client_ip(request)
 | 
				
			||||||
        # Apply GeoIP Data, when enabled
 | 
					        # Apply GeoIP Data, when enabled
 | 
				
			||||||
        self.with_geoip()
 | 
					        self.with_geoip()
 | 
				
			||||||
        # If there's no app set, we get it from the requests too
 | 
					        # If there's no app set, we get it from the requests too
 | 
				
			||||||
@ -152,22 +173,20 @@ class Event(ExpiringModel):
 | 
				
			|||||||
        self.save()
 | 
					        self.save()
 | 
				
			||||||
        return self
 | 
					        return self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def with_geoip(self):
 | 
					    def with_geoip(self):  # pragma: no cover
 | 
				
			||||||
        """Apply GeoIP Data, when enabled"""
 | 
					        """Apply GeoIP Data, when enabled"""
 | 
				
			||||||
        if not GEOIP_READER:
 | 
					        city = GEOIP_READER.city_dict(self.client_ip)
 | 
				
			||||||
 | 
					        if not city:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        try:
 | 
					        self.context["geo"] = city
 | 
				
			||||||
            response = GEOIP_READER.city(self.client_ip)
 | 
					
 | 
				
			||||||
            self.context["geo"] = {
 | 
					    def _set_prom_metrics(self):
 | 
				
			||||||
                "continent": response.continent.code,
 | 
					        GAUGE_EVENTS.labels(
 | 
				
			||||||
                "country": response.country.iso_code,
 | 
					            action=self.action,
 | 
				
			||||||
                "lat": response.location.latitude,
 | 
					            user_username=self.user.get("username"),
 | 
				
			||||||
                "long": response.location.longitude,
 | 
					            app=self.app,
 | 
				
			||||||
            }
 | 
					            client_ip=self.client_ip,
 | 
				
			||||||
            if response.city.name:
 | 
					        ).set(self.created.timestamp())
 | 
				
			||||||
                self.context["geo"]["city"] = response.city.name
 | 
					 | 
				
			||||||
        except GeoIP2Error as exc:
 | 
					 | 
				
			||||||
            LOGGER.warning("Failed to add geoIP Data to event", exc=exc)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self, *args, **kwargs):
 | 
					    def save(self, *args, **kwargs):
 | 
				
			||||||
        if self._state.adding:
 | 
					        if self._state.adding:
 | 
				
			||||||
@ -178,7 +197,8 @@ class Event(ExpiringModel):
 | 
				
			|||||||
                client_ip=self.client_ip,
 | 
					                client_ip=self.client_ip,
 | 
				
			||||||
                user=self.user,
 | 
					                user=self.user,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        return super().save(*args, **kwargs)
 | 
					        super().save(*args, **kwargs)
 | 
				
			||||||
 | 
					        self._set_prom_metrics()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def summary(self) -> str:
 | 
					    def summary(self) -> str:
 | 
				
			||||||
 | 
				
			|||||||
@ -2,14 +2,22 @@
 | 
				
			|||||||
from dataclasses import dataclass, field
 | 
					from dataclasses import dataclass, field
 | 
				
			||||||
from datetime import datetime
 | 
					from datetime import datetime
 | 
				
			||||||
from enum import Enum
 | 
					from enum import Enum
 | 
				
			||||||
 | 
					from timeit import default_timer
 | 
				
			||||||
from traceback import format_tb
 | 
					from traceback import format_tb
 | 
				
			||||||
from typing import Any, Optional
 | 
					from typing import Any, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from celery import Task
 | 
					from celery import Task
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
 | 
					from prometheus_client import Gauge
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					GAUGE_TASKS = Gauge(
 | 
				
			||||||
 | 
					    "authentik_system_tasks",
 | 
				
			||||||
 | 
					    "System tasks and their status",
 | 
				
			||||||
 | 
					    ["task_name", "task_uid", "status"],
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TaskResultStatus(Enum):
 | 
					class TaskResultStatus(Enum):
 | 
				
			||||||
    """Possible states of tasks"""
 | 
					    """Possible states of tasks"""
 | 
				
			||||||
@ -43,7 +51,9 @@ class TaskInfo:
 | 
				
			|||||||
    """Info about a task run"""
 | 
					    """Info about a task run"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    task_name: str
 | 
					    task_name: str
 | 
				
			||||||
    finish_timestamp: datetime
 | 
					    start_timestamp: float
 | 
				
			||||||
 | 
					    finish_timestamp: float
 | 
				
			||||||
 | 
					    finish_time: datetime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    result: TaskResult
 | 
					    result: TaskResult
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -73,12 +83,28 @@ class TaskInfo:
 | 
				
			|||||||
        """Delete task info from cache"""
 | 
					        """Delete task info from cache"""
 | 
				
			||||||
        return cache.delete(f"task_{self.task_name}")
 | 
					        return cache.delete(f"task_{self.task_name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_prom_metrics(self):
 | 
				
			||||||
 | 
					        """Update prometheus metrics"""
 | 
				
			||||||
 | 
					        start = default_timer()
 | 
				
			||||||
 | 
					        if hasattr(self, "start_timestamp"):
 | 
				
			||||||
 | 
					            start = self.start_timestamp
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            duration = max(self.finish_timestamp - start, 0)
 | 
				
			||||||
 | 
					        except TypeError:
 | 
				
			||||||
 | 
					            duration = 0
 | 
				
			||||||
 | 
					        GAUGE_TASKS.labels(
 | 
				
			||||||
 | 
					            task_name=self.task_name,
 | 
				
			||||||
 | 
					            task_uid=self.result.uid or "",
 | 
				
			||||||
 | 
					            status=self.result.status,
 | 
				
			||||||
 | 
					        ).set(duration)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def save(self, timeout_hours=6):
 | 
					    def save(self, timeout_hours=6):
 | 
				
			||||||
        """Save task into cache"""
 | 
					        """Save task into cache"""
 | 
				
			||||||
        key = f"task_{self.task_name}"
 | 
					        key = f"task_{self.task_name}"
 | 
				
			||||||
        if self.result.uid:
 | 
					        if self.result.uid:
 | 
				
			||||||
            key += f"_{self.result.uid}"
 | 
					            key += f"_{self.result.uid}"
 | 
				
			||||||
            self.task_name += f"_{self.result.uid}"
 | 
					            self.task_name += f"_{self.result.uid}"
 | 
				
			||||||
 | 
					        self.set_prom_metrics()
 | 
				
			||||||
        cache.set(key, self, timeout=timeout_hours * 60 * 60)
 | 
					        cache.set(key, self, timeout=timeout_hours * 60 * 60)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -98,6 +124,7 @@ class MonitoredTask(Task):
 | 
				
			|||||||
        self._uid = None
 | 
					        self._uid = None
 | 
				
			||||||
        self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[])
 | 
					        self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[])
 | 
				
			||||||
        self.result_timeout_hours = 6
 | 
					        self.result_timeout_hours = 6
 | 
				
			||||||
 | 
					        self.start = default_timer()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def set_uid(self, uid: str):
 | 
					    def set_uid(self, uid: str):
 | 
				
			||||||
        """Set UID, so in the case of an unexpected error its saved correctly"""
 | 
					        """Set UID, so in the case of an unexpected error its saved correctly"""
 | 
				
			||||||
@ -117,7 +144,9 @@ class MonitoredTask(Task):
 | 
				
			|||||||
            TaskInfo(
 | 
					            TaskInfo(
 | 
				
			||||||
                task_name=self.__name__,
 | 
					                task_name=self.__name__,
 | 
				
			||||||
                task_description=self.__doc__,
 | 
					                task_description=self.__doc__,
 | 
				
			||||||
                finish_timestamp=datetime.now(),
 | 
					                start_timestamp=self.start,
 | 
				
			||||||
 | 
					                finish_timestamp=default_timer(),
 | 
				
			||||||
 | 
					                finish_time=datetime.now(),
 | 
				
			||||||
                result=self._result,
 | 
					                result=self._result,
 | 
				
			||||||
                task_call_module=self.__module__,
 | 
					                task_call_module=self.__module__,
 | 
				
			||||||
                task_call_func=self.__name__,
 | 
					                task_call_func=self.__name__,
 | 
				
			||||||
@ -133,7 +162,9 @@ class MonitoredTask(Task):
 | 
				
			|||||||
        TaskInfo(
 | 
					        TaskInfo(
 | 
				
			||||||
            task_name=self.__name__,
 | 
					            task_name=self.__name__,
 | 
				
			||||||
            task_description=self.__doc__,
 | 
					            task_description=self.__doc__,
 | 
				
			||||||
            finish_timestamp=datetime.now(),
 | 
					            start_timestamp=self.start,
 | 
				
			||||||
 | 
					            finish_timestamp=default_timer(),
 | 
				
			||||||
 | 
					            finish_time=datetime.now(),
 | 
				
			||||||
            result=self._result,
 | 
					            result=self._result,
 | 
				
			||||||
            task_call_module=self.__module__,
 | 
					            task_call_module=self.__module__,
 | 
				
			||||||
            task_call_func=self.__name__,
 | 
					            task_call_func=self.__name__,
 | 
				
			||||||
@ -151,3 +182,7 @@ class MonitoredTask(Task):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def run(self, *args, **kwargs):
 | 
					    def run(self, *args, **kwargs):
 | 
				
			||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					for task in TaskInfo.all().values():
 | 
				
			||||||
 | 
					    task.set_prom_metrics()
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
"""Event notification tasks"""
 | 
					"""Event notification tasks"""
 | 
				
			||||||
from guardian.shortcuts import get_anonymous_user
 | 
					from guardian.shortcuts import get_anonymous_user
 | 
				
			||||||
from structlog import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.models import User
 | 
				
			||||||
from authentik.events.models import (
 | 
					from authentik.events.models import (
 | 
				
			||||||
@ -35,7 +35,10 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
 | 
				
			|||||||
        LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
 | 
					        LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
    event: Event = events.first()
 | 
					    event: Event = events.first()
 | 
				
			||||||
    trigger: NotificationRule = NotificationRule.objects.get(name=trigger_name)
 | 
					    triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name)
 | 
				
			||||||
 | 
					    if not triggers.exists():
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    trigger = triggers.first()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if "policy_uuid" in event.context:
 | 
					    if "policy_uuid" in event.context:
 | 
				
			||||||
        policy_uuid = event.context["policy_uuid"]
 | 
					        policy_uuid = event.context["policy_uuid"]
 | 
				
			||||||
@ -58,7 +61,13 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
 | 
				
			|||||||
        return
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger)
 | 
					    LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger)
 | 
				
			||||||
    user = User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user()
 | 
					    try:
 | 
				
			||||||
 | 
					        user = (
 | 
				
			||||||
 | 
					            User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except User.DoesNotExist:
 | 
				
			||||||
 | 
					        LOGGER.warning("e(trigger): failed to get user", trigger=trigger)
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
    policy_engine = PolicyEngine(trigger, user)
 | 
					    policy_engine = PolicyEngine(trigger, user)
 | 
				
			||||||
    policy_engine.mode = PolicyEngineMode.MODE_ANY
 | 
					    policy_engine.mode = PolicyEngineMode.MODE_ANY
 | 
				
			||||||
    policy_engine.empty_result = False
 | 
					    policy_engine.empty_result = False
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										26
									
								
								authentik/events/tests/test_geoip.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								authentik/events/tests/test_geoip.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					"""Test GeoIP Wrapper"""
 | 
				
			||||||
 | 
					from django.test import TestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.events.geo import GeoIPReader
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestGeoIP(TestCase):
 | 
				
			||||||
 | 
					    """Test GeoIP Wrapper"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
 | 
					        self.reader = GeoIPReader()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_simple(self):
 | 
				
			||||||
 | 
					        """Test simple city wrapper"""
 | 
				
			||||||
 | 
					        # IPs from
 | 
				
			||||||
 | 
					        # https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            self.reader.city_dict("2.125.160.216"),
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "city": "Boxford",
 | 
				
			||||||
 | 
					                "continent": "EU",
 | 
				
			||||||
 | 
					                "country": "GB",
 | 
				
			||||||
 | 
					                "lat": 51.75,
 | 
				
			||||||
 | 
					                "long": -1.25,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
@ -2,6 +2,7 @@
 | 
				
			|||||||
from rest_framework.serializers import ModelSerializer
 | 
					from rest_framework.serializers import ModelSerializer
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.flows.api.stages import StageSerializer
 | 
					from authentik.flows.api.stages import StageSerializer
 | 
				
			||||||
from authentik.flows.models import FlowStageBinding
 | 
					from authentik.flows.models import FlowStageBinding
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -27,7 +28,7 @@ class FlowStageBindingSerializer(ModelSerializer):
 | 
				
			|||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FlowStageBindingViewSet(ModelViewSet):
 | 
					class FlowStageBindingViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """FlowStageBinding Viewset"""
 | 
					    """FlowStageBinding Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = FlowStageBinding.objects.all()
 | 
					    queryset = FlowStageBinding.objects.all()
 | 
				
			||||||
 | 
				
			|||||||
@ -6,10 +6,11 @@ from django.db.models import Model
 | 
				
			|||||||
from django.http.response import HttpResponseBadRequest, JsonResponse
 | 
					from django.http.response import HttpResponseBadRequest, JsonResponse
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
from drf_yasg import openapi
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
from drf_yasg.utils import no_body, swagger_auto_schema
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
 | 
				
			||||||
from guardian.shortcuts import get_objects_for_user
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
 | 
					from rest_framework.fields import BooleanField, FileField, ReadOnlyField
 | 
				
			||||||
from rest_framework.parsers import MultiPartParser
 | 
					from rest_framework.parsers import MultiPartParser
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
@ -23,6 +24,7 @@ from rest_framework.viewsets import ModelViewSet
 | 
				
			|||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import CacheSerializer, LinkSerializer
 | 
					from authentik.core.api.utils import CacheSerializer, LinkSerializer
 | 
				
			||||||
from authentik.flows.exceptions import FlowNonApplicableException
 | 
					from authentik.flows.exceptions import FlowNonApplicableException
 | 
				
			||||||
from authentik.flows.models import Flow
 | 
					from authentik.flows.models import Flow
 | 
				
			||||||
@ -41,10 +43,18 @@ class FlowSerializer(ModelSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    cache_count = SerializerMethodField()
 | 
					    cache_count = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_cache_count(self, flow: Flow):
 | 
					    background = ReadOnlyField(source="background_url")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    export_url = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_cache_count(self, flow: Flow) -> int:
 | 
				
			||||||
        """Get count of cached flows"""
 | 
					        """Get count of cached flows"""
 | 
				
			||||||
        return len(cache.keys(f"{cache_key(flow)}*"))
 | 
					        return len(cache.keys(f"{cache_key(flow)}*"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_export_url(self, flow: Flow) -> str:
 | 
				
			||||||
 | 
					        """Get export URL for flow"""
 | 
				
			||||||
 | 
					        return reverse("authentik_api:flow-export", kwargs={"slug": flow.slug})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        model = Flow
 | 
					        model = Flow
 | 
				
			||||||
@ -60,7 +70,12 @@ class FlowSerializer(ModelSerializer):
 | 
				
			|||||||
            "policies",
 | 
					            "policies",
 | 
				
			||||||
            "cache_count",
 | 
					            "cache_count",
 | 
				
			||||||
            "policy_engine_mode",
 | 
					            "policy_engine_mode",
 | 
				
			||||||
 | 
					            "compatibility_mode",
 | 
				
			||||||
 | 
					            "export_url",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					        extra_kwargs = {
 | 
				
			||||||
 | 
					            "background": {"read_only": True},
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FlowDiagramSerializer(Serializer):
 | 
					class FlowDiagramSerializer(Serializer):
 | 
				
			||||||
@ -87,7 +102,7 @@ class DiagramElement:
 | 
				
			|||||||
        return f"{self.identifier}=>{self.type}: {self.rest}"
 | 
					        return f"{self.identifier}=>{self.type}: {self.rest}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FlowViewSet(ModelViewSet):
 | 
					class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """Flow Viewset"""
 | 
					    """Flow Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = Flow.objects.all()
 | 
					    queryset = Flow.objects.all()
 | 
				
			||||||
@ -97,16 +112,19 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
    filterset_fields = ["flow_uuid", "name", "slug", "designation"]
 | 
					    filterset_fields = ["flow_uuid", "name", "slug", "designation"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required(None, ["authentik_flows.view_flow_cache"])
 | 
					    @permission_required(None, ["authentik_flows.view_flow_cache"])
 | 
				
			||||||
    @swagger_auto_schema(responses={200: CacheSerializer(many=False)})
 | 
					    @extend_schema(responses={200: CacheSerializer(many=False)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def cache_info(self, request: Request) -> Response:
 | 
					    def cache_info(self, request: Request) -> Response:
 | 
				
			||||||
        """Info about cached flows"""
 | 
					        """Info about cached flows"""
 | 
				
			||||||
        return Response(data={"count": len(cache.keys("flow_*"))})
 | 
					        return Response(data={"count": len(cache.keys("flow_*"))})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required(None, ["authentik_flows.clear_flow_cache"])
 | 
					    @permission_required(None, ["authentik_flows.clear_flow_cache"])
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        request_body=no_body,
 | 
					        request=OpenApiTypes.NONE,
 | 
				
			||||||
        responses={204: "Successfully cleared cache", 400: "Bad request"},
 | 
					        responses={
 | 
				
			||||||
 | 
					            204: OpenApiResponse(description="Successfully cleared cache"),
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Bad request"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=False, methods=["POST"])
 | 
					    @action(detail=False, methods=["POST"])
 | 
				
			||||||
    def cache_clear(self, request: Request) -> Response:
 | 
					    def cache_clear(self, request: Request) -> Response:
 | 
				
			||||||
@ -133,17 +151,16 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
            "authentik_stages_prompt.change_prompt",
 | 
					            "authentik_stages_prompt.change_prompt",
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        request_body=no_body,
 | 
					        request={
 | 
				
			||||||
        manual_parameters=[
 | 
					            "multipart/form-data": inline_serializer(
 | 
				
			||||||
            openapi.Parameter(
 | 
					                "SetIcon", fields={"file": FileField()}
 | 
				
			||||||
                name="file",
 | 
					 | 
				
			||||||
                in_=openapi.IN_FORM,
 | 
					 | 
				
			||||||
                type=openapi.TYPE_FILE,
 | 
					 | 
				
			||||||
                required=True,
 | 
					 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        ],
 | 
					        },
 | 
				
			||||||
        responses={204: "Successfully imported flow", 400: "Bad request"},
 | 
					        responses={
 | 
				
			||||||
 | 
					            204: OpenApiResponse(description="Successfully imported flow"),
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Bad request"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
 | 
					    @action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
 | 
				
			||||||
    def import_flow(self, request: Request) -> Response:
 | 
					    def import_flow(self, request: Request) -> Response:
 | 
				
			||||||
@ -157,8 +174,8 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
            return HttpResponseBadRequest()
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
        successful = importer.apply()
 | 
					        successful = importer.apply()
 | 
				
			||||||
        if not successful:
 | 
					        if not successful:
 | 
				
			||||||
            return Response(status=204)
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
        return HttpResponseBadRequest()
 | 
					        return Response(status=204)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required(
 | 
					    @permission_required(
 | 
				
			||||||
        "authentik_flows.export_flow",
 | 
					        "authentik_flows.export_flow",
 | 
				
			||||||
@ -171,11 +188,9 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
            "authentik_stages_prompt.view_prompt",
 | 
					            "authentik_stages_prompt.view_prompt",
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        responses={
 | 
					        responses={
 | 
				
			||||||
            "200": openapi.Response(
 | 
					            "200": OpenApiResponse(response=OpenApiTypes.BINARY),
 | 
				
			||||||
                "File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE)
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
@ -188,7 +203,7 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
        response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"'
 | 
					        response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"'
 | 
				
			||||||
        return response
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: FlowDiagramSerializer()})
 | 
					    @extend_schema(responses={200: FlowDiagramSerializer()})
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def diagram(self, request: Request, slug: str) -> Response:
 | 
					    def diagram(self, request: Request, slug: str) -> Response:
 | 
				
			||||||
@ -210,6 +225,7 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
                    request.user, "authentik_policies.view_policybinding"
 | 
					                    request.user, "authentik_policies.view_policybinding"
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                .filter(target=stage_binding)
 | 
					                .filter(target=stage_binding)
 | 
				
			||||||
 | 
					                .exclude(policy__isnull=True)
 | 
				
			||||||
                .order_by("order")
 | 
					                .order_by("order")
 | 
				
			||||||
            ):
 | 
					            ):
 | 
				
			||||||
                body.append(
 | 
					                body.append(
 | 
				
			||||||
@ -258,17 +274,20 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
        return Response({"diagram": diagram})
 | 
					        return Response({"diagram": diagram})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required("authentik_flows.change_flow")
 | 
					    @permission_required("authentik_flows.change_flow")
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        request_body=no_body,
 | 
					        request={
 | 
				
			||||||
        manual_parameters=[
 | 
					            "multipart/form-data": inline_serializer(
 | 
				
			||||||
            openapi.Parameter(
 | 
					                "SetIcon",
 | 
				
			||||||
                name="file",
 | 
					                fields={
 | 
				
			||||||
                in_=openapi.IN_FORM,
 | 
					                    "file": FileField(required=False),
 | 
				
			||||||
                type=openapi.TYPE_FILE,
 | 
					                    "clear": BooleanField(default=False),
 | 
				
			||||||
                required=True,
 | 
					                },
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        ],
 | 
					        },
 | 
				
			||||||
        responses={200: "Success", 400: "Bad request"},
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: OpenApiResponse(description="Success"),
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Bad request"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(
 | 
					    @action(
 | 
				
			||||||
        detail=True,
 | 
					        detail=True,
 | 
				
			||||||
@ -280,16 +299,53 @@ class FlowViewSet(ModelViewSet):
 | 
				
			|||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def set_background(self, request: Request, slug: str):
 | 
					    def set_background(self, request: Request, slug: str):
 | 
				
			||||||
        """Set Flow background"""
 | 
					        """Set Flow background"""
 | 
				
			||||||
        app: Flow = self.get_object()
 | 
					        flow: Flow = self.get_object()
 | 
				
			||||||
        icon = request.FILES.get("file", None)
 | 
					        background = request.FILES.get("file", None)
 | 
				
			||||||
        if not icon:
 | 
					        clear = request.data.get("clear", "false").lower() == "true"
 | 
				
			||||||
 | 
					        if clear:
 | 
				
			||||||
 | 
					            if flow.background_url.startswith("/media"):
 | 
				
			||||||
 | 
					                # .delete() saves the model by default
 | 
				
			||||||
 | 
					                flow.background.delete()
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                flow.background = None
 | 
				
			||||||
 | 
					                flow.save()
 | 
				
			||||||
 | 
					            return Response({})
 | 
				
			||||||
 | 
					        if background:
 | 
				
			||||||
 | 
					            flow.background = background
 | 
				
			||||||
 | 
					            flow.save()
 | 
				
			||||||
 | 
					            return Response({})
 | 
				
			||||||
 | 
					        return HttpResponseBadRequest()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @permission_required("authentik_core.change_application")
 | 
				
			||||||
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        request=inline_serializer("SetIconURL", fields={"url": CharField()}),
 | 
				
			||||||
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: OpenApiResponse(description="Success"),
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Bad request"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @action(
 | 
				
			||||||
 | 
					        detail=True,
 | 
				
			||||||
 | 
					        pagination_class=None,
 | 
				
			||||||
 | 
					        filter_backends=[],
 | 
				
			||||||
 | 
					        methods=["POST"],
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def set_background_url(self, request: Request, slug: str):
 | 
				
			||||||
 | 
					        """Set Flow background (as URL)"""
 | 
				
			||||||
 | 
					        flow: Flow = self.get_object()
 | 
				
			||||||
 | 
					        url = request.data.get("url", None)
 | 
				
			||||||
 | 
					        if not url:
 | 
				
			||||||
            return HttpResponseBadRequest()
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
        app.background = icon
 | 
					        flow.background.name = url
 | 
				
			||||||
        app.save()
 | 
					        flow.save()
 | 
				
			||||||
        return Response({})
 | 
					        return Response({})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        responses={200: LinkSerializer(many=False), 400: "Flow not applicable"},
 | 
					        responses={
 | 
				
			||||||
 | 
					            200: LinkSerializer(many=False),
 | 
				
			||||||
 | 
					            400: OpenApiResponse(description="Flow not applicable"),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
				
			|||||||
@ -1,16 +1,17 @@
 | 
				
			|||||||
"""Flow Stage API Views"""
 | 
					"""Flow Stage API Views"""
 | 
				
			||||||
from typing import Iterable
 | 
					from typing import Iterable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from drf_yasg.utils import swagger_auto_schema
 | 
					from django.urls.base import reverse
 | 
				
			||||||
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from rest_framework import mixins
 | 
					from rest_framework import mixins
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import BooleanField
 | 
					 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
					from rest_framework.serializers import ModelSerializer, SerializerMethodField
 | 
				
			||||||
from rest_framework.viewsets import GenericViewSet
 | 
					from rest_framework.viewsets import GenericViewSet
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
 | 
					from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
 | 
				
			||||||
from authentik.core.types import UserSettingSerializer
 | 
					from authentik.core.types import UserSettingSerializer
 | 
				
			||||||
from authentik.flows.api.flows import FlowSerializer
 | 
					from authentik.flows.api.flows import FlowSerializer
 | 
				
			||||||
@ -20,12 +21,6 @@ from authentik.lib.utils.reflection import all_subclasses
 | 
				
			|||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StageUserSettingSerializer(UserSettingSerializer):
 | 
					 | 
				
			||||||
    """User settings but can include a configure flow"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    configure_flow = BooleanField(required=False)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class StageSerializer(ModelSerializer, MetaNameSerializer):
 | 
					class StageSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			||||||
    """Stage Serializer"""
 | 
					    """Stage Serializer"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -55,6 +50,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
class StageViewSet(
 | 
					class StageViewSet(
 | 
				
			||||||
    mixins.RetrieveModelMixin,
 | 
					    mixins.RetrieveModelMixin,
 | 
				
			||||||
    mixins.DestroyModelMixin,
 | 
					    mixins.DestroyModelMixin,
 | 
				
			||||||
 | 
					    UsedByMixin,
 | 
				
			||||||
    mixins.ListModelMixin,
 | 
					    mixins.ListModelMixin,
 | 
				
			||||||
    GenericViewSet,
 | 
					    GenericViewSet,
 | 
				
			||||||
):
 | 
					):
 | 
				
			||||||
@ -65,10 +61,10 @@ class StageViewSet(
 | 
				
			|||||||
    search_fields = ["name"]
 | 
					    search_fields = ["name"]
 | 
				
			||||||
    filterset_fields = ["name"]
 | 
					    filterset_fields = ["name"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_queryset(self):
 | 
					    def get_queryset(self):  # pragma: no cover
 | 
				
			||||||
        return Stage.objects.select_subclasses()
 | 
					        return Stage.objects.select_subclasses()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
					    @extend_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def types(self, request: Request) -> Response:
 | 
					    def types(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all creatable stage types"""
 | 
					        """Get all creatable stage types"""
 | 
				
			||||||
@ -86,7 +82,7 @@ class StageViewSet(
 | 
				
			|||||||
        data = sorted(data, key=lambda x: x["name"])
 | 
					        data = sorted(data, key=lambda x: x["name"])
 | 
				
			||||||
        return Response(TypeCreateSerializer(data, many=True).data)
 | 
					        return Response(TypeCreateSerializer(data, many=True).data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @swagger_auto_schema(responses={200: StageUserSettingSerializer(many=True)})
 | 
					    @extend_schema(responses={200: UserSettingSerializer(many=True)})
 | 
				
			||||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
					    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
				
			||||||
    def user_settings(self, request: Request) -> Response:
 | 
					    def user_settings(self, request: Request) -> Response:
 | 
				
			||||||
        """Get all stages the user can configure"""
 | 
					        """Get all stages the user can configure"""
 | 
				
			||||||
@ -97,9 +93,10 @@ class StageViewSet(
 | 
				
			|||||||
            if not user_settings:
 | 
					            if not user_settings:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            user_settings.initial_data["object_uid"] = str(stage.pk)
 | 
					            user_settings.initial_data["object_uid"] = str(stage.pk)
 | 
				
			||||||
            if hasattr(stage, "configure_flow"):
 | 
					            if hasattr(stage, "configure_flow") and stage.configure_flow:
 | 
				
			||||||
                user_settings.initial_data["configure_flow"] = bool(
 | 
					                user_settings.initial_data["configure_url"] = reverse(
 | 
				
			||||||
                    stage.configure_flow
 | 
					                    "authentik_flows:configure",
 | 
				
			||||||
 | 
					                    kwargs={"stage_uuid": stage.pk},
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            if not user_settings.is_valid():
 | 
					            if not user_settings.is_valid():
 | 
				
			||||||
                LOGGER.warning(user_settings.errors)
 | 
					                LOGGER.warning(user_settings.errors)
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,9 @@
 | 
				
			|||||||
from importlib import import_module
 | 
					from importlib import import_module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.apps import AppConfig
 | 
					from django.apps import AppConfig
 | 
				
			||||||
 | 
					from django.db.utils import ProgrammingError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.lib.utils.reflection import all_subclasses
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AuthentikFlowsConfig(AppConfig):
 | 
					class AuthentikFlowsConfig(AppConfig):
 | 
				
			||||||
@ -14,3 +17,10 @@ class AuthentikFlowsConfig(AppConfig):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def ready(self):
 | 
					    def ready(self):
 | 
				
			||||||
        import_module("authentik.flows.signals")
 | 
					        import_module("authentik.flows.signals")
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            from authentik.flows.models import Stage
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for stage in all_subclasses(Stage):
 | 
				
			||||||
 | 
					                _ = stage().type
 | 
				
			||||||
 | 
					        except ProgrammingError:
 | 
				
			||||||
 | 
					            pass
 | 
				
			||||||
 | 
				
			|||||||
@ -28,6 +28,14 @@ class ErrorDetailSerializer(PassiveSerializer):
 | 
				
			|||||||
    code = CharField()
 | 
					    code = CharField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ContextualFlowInfo(PassiveSerializer):
 | 
				
			||||||
 | 
					    """Contextual flow information for a challenge"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    title = CharField(required=False, allow_blank=True)
 | 
				
			||||||
 | 
					    background = CharField(required=False)
 | 
				
			||||||
 | 
					    cancel_url = CharField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Challenge(PassiveSerializer):
 | 
					class Challenge(PassiveSerializer):
 | 
				
			||||||
    """Challenge that gets sent to the client based on which stage
 | 
					    """Challenge that gets sent to the client based on which stage
 | 
				
			||||||
    is currently active"""
 | 
					    is currently active"""
 | 
				
			||||||
@ -35,9 +43,8 @@ class Challenge(PassiveSerializer):
 | 
				
			|||||||
    type = ChoiceField(
 | 
					    type = ChoiceField(
 | 
				
			||||||
        choices=[(x.value, x.name) for x in ChallengeTypes],
 | 
					        choices=[(x.value, x.name) for x in ChallengeTypes],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    component = CharField(required=False)
 | 
					    flow_info = ContextualFlowInfo(required=False)
 | 
				
			||||||
    title = CharField(required=False)
 | 
					    component = CharField(default="")
 | 
				
			||||||
    background = CharField(required=False)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    response_errors = DictField(
 | 
					    response_errors = DictField(
 | 
				
			||||||
        child=ErrorDetailSerializer(many=True), allow_empty=True, required=False
 | 
					        child=ErrorDetailSerializer(many=True), allow_empty=True, required=False
 | 
				
			||||||
@ -48,18 +55,20 @@ class RedirectChallenge(Challenge):
 | 
				
			|||||||
    """Challenge type to redirect the client"""
 | 
					    """Challenge type to redirect the client"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    to = CharField()
 | 
					    to = CharField()
 | 
				
			||||||
 | 
					    component = CharField(default="xak-flow-redirect")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ShellChallenge(Challenge):
 | 
					class ShellChallenge(Challenge):
 | 
				
			||||||
    """Legacy challenge type to render HTML as-is"""
 | 
					    """challenge type to render HTML as-is"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    body = CharField()
 | 
					    body = CharField()
 | 
				
			||||||
 | 
					    component = CharField(default="xak-flow-shell")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class WithUserInfoChallenge(Challenge):
 | 
					class WithUserInfoChallenge(Challenge):
 | 
				
			||||||
    """Challenge base which shows some user info"""
 | 
					    """Challenge base which shows some user info"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    pending_user = CharField()
 | 
					    pending_user = CharField(allow_blank=True)
 | 
				
			||||||
    pending_user_avatar = CharField()
 | 
					    pending_user_avatar = CharField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -67,6 +76,7 @@ class AccessDeniedChallenge(Challenge):
 | 
				
			|||||||
    """Challenge when a flow's active stage calls `stage_invalid()`."""
 | 
					    """Challenge when a flow's active stage calls `stage_invalid()`."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    error_message = CharField(required=False)
 | 
					    error_message = CharField(required=False)
 | 
				
			||||||
 | 
					    component = CharField(default="ak-stage-access-denied")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PermissionSerializer(PassiveSerializer):
 | 
					class PermissionSerializer(PassiveSerializer):
 | 
				
			||||||
@ -80,6 +90,7 @@ class ChallengeResponse(PassiveSerializer):
 | 
				
			|||||||
    """Base class for all challenge responses"""
 | 
					    """Base class for all challenge responses"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    stage: Optional["StageView"]
 | 
					    stage: Optional["StageView"]
 | 
				
			||||||
 | 
					    component = CharField(default="xak-flow-response-default")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, instance=None, data=None, **kwargs):
 | 
					    def __init__(self, instance=None, data=None, **kwargs):
 | 
				
			||||||
        self.stage = kwargs.pop("stage", None)
 | 
					        self.stage = kwargs.pop("stage", None)
 | 
				
			||||||
 | 
				
			|||||||
@ -6,6 +6,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.flows.models import FlowDesignation
 | 
					from authentik.flows.models import FlowDesignation
 | 
				
			||||||
from authentik.stages.identification.models import UserFields
 | 
					from authentik.stages.identification.models import UserFields
 | 
				
			||||||
 | 
					from authentik.stages.password import BACKEND_DJANGO, BACKEND_LDAP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def create_default_authentication_flow(
 | 
					def create_default_authentication_flow(
 | 
				
			||||||
@ -31,7 +32,7 @@ def create_default_authentication_flow(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create(
 | 
					    password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create(
 | 
				
			||||||
        name="default-authentication-password",
 | 
					        name="default-authentication-password",
 | 
				
			||||||
        defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]},
 | 
					        defaults={"backends": [BACKEND_DJANGO, BACKEND_LDAP]},
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create(
 | 
					    login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create(
 | 
				
			||||||
 | 
				
			|||||||
@ -15,13 +15,10 @@ PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently run
 | 
				
			|||||||
# by injecting "pending_user"
 | 
					# by injecting "pending_user"
 | 
				
			||||||
akadmin = ak_user_by(username="akadmin")
 | 
					akadmin = ak_user_by(username="akadmin")
 | 
				
			||||||
context["pending_user"] = akadmin
 | 
					context["pending_user"] = akadmin
 | 
				
			||||||
# We're also setting the backend for the user, so we can
 | 
					 | 
				
			||||||
# directly login without having to identify again
 | 
					 | 
				
			||||||
context["user_backend"] = "django.contrib.auth.backends.ModelBackend"
 | 
					 | 
				
			||||||
return True"""
 | 
					return True"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
					def create_default_oobe_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			||||||
    from authentik.stages.prompt.models import FieldTypes
 | 
					    from authentik.stages.prompt.models import FieldTypes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    User = apps.get_model("authentik_core", "User")
 | 
					    User = apps.get_model("authentik_core", "User")
 | 
				
			||||||
@ -52,20 +49,20 @@ def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor)
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    # Create a policy that sets the flow's user
 | 
					    # Create a policy that sets the flow's user
 | 
				
			||||||
    prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
 | 
					    prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
 | 
				
			||||||
        name="default-oob-prefill-user",
 | 
					        name="default-oobe-prefill-user",
 | 
				
			||||||
        defaults={"expression": PREFILL_POLICY_EXPRESSION},
 | 
					        defaults={"expression": PREFILL_POLICY_EXPRESSION},
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    password_usable_policy, _ = ExpressionPolicy.objects.using(
 | 
					    password_usable_policy, _ = ExpressionPolicy.objects.using(
 | 
				
			||||||
        db_alias
 | 
					        db_alias
 | 
				
			||||||
    ).update_or_create(
 | 
					    ).update_or_create(
 | 
				
			||||||
        name="default-oob-password-usable",
 | 
					        name="default-oobe-password-usable",
 | 
				
			||||||
        defaults={"expression": PW_USABLE_POLICY_EXPRESSION},
 | 
					        defaults={"expression": PW_USABLE_POLICY_EXPRESSION},
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    prompt_header, _ = Prompt.objects.using(db_alias).update_or_create(
 | 
					    prompt_header, _ = Prompt.objects.using(db_alias).update_or_create(
 | 
				
			||||||
        field_key="oob-header-text",
 | 
					        field_key="oobe-header-text",
 | 
				
			||||||
        defaults={
 | 
					        defaults={
 | 
				
			||||||
            "label": "oob-header-text",
 | 
					            "label": "oobe-header-text",
 | 
				
			||||||
            "type": FieldTypes.STATIC,
 | 
					            "type": FieldTypes.STATIC,
 | 
				
			||||||
            "placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.",
 | 
					            "placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.",
 | 
				
			||||||
            "order": 100,
 | 
					            "order": 100,
 | 
				
			||||||
@ -84,7 +81,7 @@ def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor)
 | 
				
			|||||||
    password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat")
 | 
					    password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
 | 
					    prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
 | 
				
			||||||
        name="default-oob-password",
 | 
					        name="default-oobe-password",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    prompt_stage.fields.set(
 | 
					    prompt_stage.fields.set(
 | 
				
			||||||
        [prompt_header, prompt_email, password_first, password_second]
 | 
					        [prompt_header, prompt_email, password_first, password_second]
 | 
				
			||||||
@ -102,7 +99,7 @@ def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor)
 | 
				
			|||||||
        slug="initial-setup",
 | 
					        slug="initial-setup",
 | 
				
			||||||
        designation=FlowDesignation.STAGE_CONFIGURATION,
 | 
					        designation=FlowDesignation.STAGE_CONFIGURATION,
 | 
				
			||||||
        defaults={
 | 
					        defaults={
 | 
				
			||||||
            "name": "default-oob-setup",
 | 
					            "name": "default-oobe-setup",
 | 
				
			||||||
            "title": "Welcome to authentik!",
 | 
					            "title": "Welcome to authentik!",
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
@ -146,5 +143,5 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    operations = [
 | 
					    operations = [
 | 
				
			||||||
        migrations.RunPython(create_default_oob_flow),
 | 
					        migrations.RunPython(create_default_oobe_flow),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										23
									
								
								authentik/flows/migrations/0019_alter_flow_background.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/flows/migrations/0019_alter_flow_background.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.3 on 2021-06-05 17:34
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_flows", "0018_oob_flows"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="flow",
 | 
				
			||||||
 | 
					            name="background",
 | 
				
			||||||
 | 
					            field=models.FileField(
 | 
				
			||||||
 | 
					                default=None,
 | 
				
			||||||
 | 
					                help_text="Background shown during execution",
 | 
				
			||||||
 | 
					                null=True,
 | 
				
			||||||
 | 
					                upload_to="flow-backgrounds/",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user