Compare commits
	
		
			989 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 31a58e2c25 | |||
| 229715acb2 | |||
| 2a670afd02 | |||
| b69248dd55 | |||
| 5ff5edf769 | |||
| 939889e0ec | |||
| 19ae6585dc | |||
| a81c847392 | |||
| c6ede78fba | |||
| cea1289186 | |||
| c297f28552 | |||
| 35b25bd76e | |||
| 64d7610b13 | |||
| 2c8fcff832 | |||
| 054e76d02a | |||
| 80fa132dd9 | |||
| 4c59c3abef | |||
| 22d319c0e7 | |||
| 89edd77484 | |||
| 04e52d8ba6 | |||
| 9b5e3921cb | |||
| 2bbad64dc3 | |||
| f6026fdb13 | |||
| 49def45ca3 | |||
| a4856969f4 | |||
| 2aa7266688 | |||
| 25817cae6b | |||
| 5383ae2c19 | |||
| c0c246edab | |||
| 831b32c279 | |||
| 70ccc63702 | |||
| de954250e5 | |||
| f268bd4c69 | |||
| 57a48b6350 | |||
| 9aac114115 | |||
| 66e3cbdc46 | |||
| 2d76d23f7b | |||
| 4327b35bc3 | |||
| f7047df40e | |||
| ef77a4b64e | |||
| 5d7d21076f | |||
| ede072889e | |||
| 9cb7e6c606 | |||
| e7d36c095d | |||
| b88eb430c1 | |||
| 641872a33a | |||
| 405c690193 | |||
| 932cf48d2b | |||
| 402819107d | |||
| 41f135126b | |||
| 591a339302 | |||
| 35f2c5d96a | |||
| fe6963c428 | |||
| 19cac4bf43 | |||
| 4ca564490e | |||
| fcb795c273 | |||
| 14c70b3e4a | |||
| ac880c28d7 | |||
| f3c6b9a4f6 | |||
| cba0cf0d76 | |||
| 73b67cf0f0 | |||
| 23a8052cc8 | |||
| 57c49c3865 | |||
| cbea51ae5b | |||
| 8962081d92 | |||
| e743f13f81 | |||
| b20a8b7c17 | |||
| b53c94d76a | |||
| d4419d66c1 | |||
| 79044368d2 | |||
| 426686957d | |||
| 28cb803fd9 | |||
| 85c3a36b62 | |||
| 9ba8a715b1 | |||
| 358750f66e | |||
| b9918529b8 | |||
| a5673b4ec8 | |||
| d9287d0c0e | |||
| d9c2b64116 | |||
| 2b150d3077 | |||
| dec7a9cfb9 | |||
| e0f48a30b7 | |||
| 973f14d911 | |||
| e8978adc1b | |||
| 3ca8d9c968 | |||
| 42636142fa | |||
| 57c459348f | |||
| 493b34cf0d | |||
| f0493f418b | |||
| d45a292652 | |||
| b21ea360db | |||
| 6816f8b851 | |||
| de714f0390 | |||
| 800df332b5 | |||
| 16c194d2dc | |||
| 53100a72fe | |||
| ec4c3f44cb | |||
| f10bd432b3 | |||
| 4de927ba5b | |||
| 74e578c2bf | |||
| e584fd1344 | |||
| 0e02925a3d | |||
| 5b837c3ccc | |||
| 2580371f94 | |||
| 4e9be85353 | |||
| 79508e1965 | |||
| 3a88dde545 | |||
| 31fc4d1cb9 | |||
| 09cd8f8f63 | |||
| d824b09365 | |||
| cabbd18880 | |||
| c9dda17c68 | |||
| bb8559ee18 | |||
| 5ae32e525c | |||
| 0832145a01 | |||
| 4167276c8f | |||
| afb84c7bc5 | |||
| 82b2c7e3f0 | |||
| fc8004db2b | |||
| ddfc943bba | |||
| 8c0c12292e | |||
| 803490d98b | |||
| 16835ab478 | |||
| 572b8d87b5 | |||
| 31d2ea65fd | |||
| f4ac2f50e2 | |||
| 969a3f0ddd | |||
| 4e18f47f28 | |||
| f10286edf8 | |||
| d789dcc28f | |||
| 715a71427e | |||
| 84c21d16cf | |||
| 2e4e17adb7 | |||
| 00cbaaf672 | |||
| 74e4e8f6aa | |||
| d78fda990a | |||
| 10d949f7a9 | |||
| 6661af032d | |||
| fb5e4a3af8 | |||
| 1dfad83a34 | |||
| 70025c648c | |||
| 676b77aa7c | |||
| e35e096266 | |||
| 7af12d4fec | |||
| 8d6db0fabf | |||
| 8ddcf99bf7 | |||
| 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 | |||
| 4e5eeacf0a | |||
| e7b498e8b4 | |||
| b55cb2b40c | |||
| 25c001f2cd | |||
| 2a409215d3 | |||
| ad8ee83697 | |||
| d1d28722d2 | |||
| 1efd09fcd5 | |||
| 35f0e6b88d | |||
| a6e528d209 | |||
| bb2c4423b0 | |||
| ad9f29566b | |||
| e76bb6bc13 | |||
| a68642779d | |||
| 3c04fcaa9f | |||
| 5955d28073 | |||
| a6fb6161d7 | |||
| 6b0e0610c6 | |||
| 2c70301f56 | |||
| 07b9923bf6 | |||
| 8b3923200d | |||
| 3dcd67c1a3 | |||
| 2a9feafb90 | |||
| 580e88c6fc | |||
| d82c01aa61 | |||
| 1af3357826 | |||
| ed49d7824e | |||
| 378402fcf0 | |||
| 50f0c11c0b | |||
| 58712828a4 | |||
| b2b9093c95 | |||
| afa2afe1d4 | |||
| 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.4 | current_version = 2021.6.2 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||||
| @ -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: | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | |||||||
|  | # 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 | ||||||
|  |   - pr_wanted | ||||||
|  | # 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. | ||||||
							
								
								
									
										187
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										187
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,90 +3,143 @@ 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.4 |           push: ${{ github.event_name == 'release' }} | ||||||
|           -t beryju/authentik:latest |           tags: | | ||||||
|           -f Dockerfile . |             beryju/authentik:2021.6.2, | ||||||
|       - name: Push Docker Container to Registry (versioned) |             beryju/authentik:latest, | ||||||
|         run: docker push beryju/authentik:2021.4.4 |             ghcr.io/goauthentik/server:2021.6.2, | ||||||
|       - 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) | ||||||
|  |         if: ${{ github.event_name == 'release' && !contains('2021.6.2', 'rc') }} | ||||||
|  |         run: | | ||||||
|  |           docker pull beryju/authentik:latest | ||||||
|  |           docker tag beryju/authentik:latest beryju/authentik:stable | ||||||
|  |           docker push beryju/authentik:stable | ||||||
|  |           docker pull ghcr.io/goauthentik/server:latest | ||||||
|  |           docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable | ||||||
|  |           docker push ghcr.io/goauthentik/server:stable | ||||||
|   build-proxy: |   build-proxy: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - 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 | ||||||
|  |         uses: docker/build-push-action@v2 | ||||||
|  |         with: | ||||||
|  |           push: ${{ github.event_name == 'release' }} | ||||||
|  |           tags: | | ||||||
|  |             beryju/authentik-proxy:2021.6.2, | ||||||
|  |             beryju/authentik-proxy:latest, | ||||||
|  |             ghcr.io/goauthentik/proxy:2021.6.2, | ||||||
|  |             ghcr.io/goauthentik/proxy:latest | ||||||
|  |           file: outpost/proxy.Dockerfile | ||||||
|  |           platforms: linux/amd64,linux/arm64 | ||||||
|  |       - name: Building Docker Image (stable) | ||||||
|  |         if: ${{ github.event_name == 'release' && !contains('2021.6.2', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           cd outpost/ |           docker pull beryju/authentik-proxy:latest | ||||||
|           docker build \ |           docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable | ||||||
|           --no-cache \ |           docker push beryju/authentik-proxy:stable | ||||||
|           -t beryju/authentik-proxy:2021.4.4 \ |           docker pull ghcr.io/goauthentik/proxy:latest | ||||||
|           -t beryju/authentik-proxy:latest \ |           docker tag ghcr.io/goauthentik/proxy:latest ghcr.io/goauthentik/proxy:stable | ||||||
|           -f proxy.Dockerfile . |           docker push ghcr.io/goauthentik/proxy:stable | ||||||
|       - name: Push Docker Container to Registry (versioned) |   build-ldap: | ||||||
|         run: docker push beryju/authentik-proxy:2021.4.4 |  | ||||||
|       - name: Push Docker Container to Registry (latest) |  | ||||||
|         run: docker push beryju/authentik-proxy:latest |  | ||||||
|   build-static: |  | ||||||
|     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 | ||||||
|  |         uses: docker/build-push-action@v2 | ||||||
|  |         with: | ||||||
|  |           push: ${{ github.event_name == 'release' }} | ||||||
|  |           tags: | | ||||||
|  |             beryju/authentik-ldap:2021.6.2, | ||||||
|  |             beryju/authentik-ldap:latest, | ||||||
|  |             ghcr.io/goauthentik/ldap:2021.6.2, | ||||||
|  |             ghcr.io/goauthentik/ldap:latest | ||||||
|  |           file: outpost/ldap.Dockerfile | ||||||
|  |           platforms: linux/amd64,linux/arm64 | ||||||
|  |       - name: Building Docker Image (stable) | ||||||
|  |         if: ${{ github.event_name == 'release' && !contains('2021.6.2', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           cd web/ |           docker pull beryju/authentik-ldap:latest | ||||||
|           docker build \ |           docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable | ||||||
|           --no-cache \ |           docker push beryju/authentik-ldap:stable | ||||||
|           -t beryju/authentik-static:2021.4.4 \ |           docker pull ghcr.io/goauthentik/ldap:latest | ||||||
|           -t beryju/authentik-static:latest \ |           docker tag ghcr.io/goauthentik/ldap:latest ghcr.io/goauthentik/ldap:stable | ||||||
|           -f Dockerfile . |           docker push ghcr.io/goauthentik/ldap:stable | ||||||
|       - name: Push Docker Container to Registry (versioned) |  | ||||||
|         run: docker push beryju/authentik-static:2021.4.4 |  | ||||||
|       - name: Push Docker Container to Registry (latest) |  | ||||||
|         run: docker push beryju/authentik-static:latest |  | ||||||
|   test-release: |   test-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 +148,34 @@ 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: Setup Node.js environment | ||||||
|  |         uses: actions/setup-node@v2.1.5 | ||||||
|  |         with: | ||||||
|  |           node-version: 12.x | ||||||
|  |       - name: Build web api client and web ui | ||||||
|  |         run: | | ||||||
|  |           export NODE_ENV=production | ||||||
|  |           make gen-web | ||||||
|  |           cd web | ||||||
|  |           npm i | ||||||
|  |           npm run build | ||||||
|       - name: Create a Sentry.io release |       - name: Create a Sentry.io release | ||||||
|         uses: tclindner/sentry-releases-action@v1.2.0 |         uses: getsentry/action-release@v1 | ||||||
|  |         if: ${{ github.event_name == 'release' }} | ||||||
|         env: |         env: | ||||||
|           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} |           SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} | ||||||
|           SENTRY_ORG: beryjuorg |           SENTRY_ORG: beryjuorg | ||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           tagName: 2021.4.4 |           version: authentik@2021.6.2 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|  |           sourcemaps: './web/dist' | ||||||
|  | |||||||
							
								
								
									
										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 | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								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,16 @@ urllib3 = {extras = ["secure"],version = "*"} | |||||||
| uvicorn = {extras = ["standard"],version = "*"} | uvicorn = {extras = ["standard"],version = "*"} | ||||||
| webauthn = "*" | webauthn = "*" | ||||||
| xmlsec = "*" | xmlsec = "*" | ||||||
|  | duo-client = "*" | ||||||
|  | ua-parser = "*" | ||||||
|  | deepmerge = "*" | ||||||
|  |  | ||||||
| [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 +62,4 @@ pylint-django = "*" | |||||||
| pytest = "*" | pytest = "*" | ||||||
| pytest-django = "*" | pytest-django = "*" | ||||||
| selenium = "*" | selenium = "*" | ||||||
|  | requests-mock = "*" | ||||||
|  | |||||||
							
								
								
									
										831
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										831
									
								
								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.4" | __version__ = "2021.6.2" | ||||||
| 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() | ||||||
|  | |||||||
| @ -3,23 +3,33 @@ from traceback import format_tb | |||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
|  | from guardian.utils import get_anonymous_user | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import PropertyMapping, User | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.lib.expression.evaluator import BaseEvaluator | from authentik.lib.expression.evaluator import BaseEvaluator | ||||||
|  | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
| class PropertyMappingEvaluator(BaseEvaluator): | class PropertyMappingEvaluator(BaseEvaluator): | ||||||
|     """Custom Evalautor that adds some different context variables.""" |     """Custom Evalautor that adds some different context variables.""" | ||||||
|  |  | ||||||
|     def set_context( |     def set_context( | ||||||
|         self, user: Optional[User], request: Optional[HttpRequest], **kwargs |         self, | ||||||
|  |         user: Optional[User], | ||||||
|  |         request: Optional[HttpRequest], | ||||||
|  |         mapping: PropertyMapping, | ||||||
|  |         **kwargs, | ||||||
|     ): |     ): | ||||||
|         """Update context with context from PropertyMapping's evaluate""" |         """Update context with context from PropertyMapping's evaluate""" | ||||||
|  |         req = PolicyRequest(user=get_anonymous_user()) | ||||||
|  |         req.obj = mapping | ||||||
|         if user: |         if user: | ||||||
|  |             req.user = user | ||||||
|             self._context["user"] = user |             self._context["user"] = user | ||||||
|         if request: |         if request: | ||||||
|             self._context["request"] = request |             req.http_request = request | ||||||
|  |         self._context["request"] = req | ||||||
|         self._context.update(**kwargs) |         self._context.update(**kwargs) | ||||||
|  |  | ||||||
|     def handle_error(self, exc: Exception, expression_source: str): |     def handle_error(self, exc: Exception, expression_source: str): | ||||||
| @ -30,9 +40,8 @@ class PropertyMappingEvaluator(BaseEvaluator): | |||||||
|             expression=expression_source, |             expression=expression_source, | ||||||
|             message=error_string, |             message=error_string, | ||||||
|         ) |         ) | ||||||
|         if "user" in self._context: |  | ||||||
|             event.set_user(self._context["user"]) |  | ||||||
|         if "request" in self._context: |         if "request" in self._context: | ||||||
|             event.from_http(self._context["request"]) |             req: PolicyRequest = self._context["request"] | ||||||
|  |             event.from_http(req.http_request, req.user) | ||||||
|             return |             return | ||||||
|         event.save() |         event.save() | ||||||
|  | |||||||
| @ -26,6 +26,8 @@ class ImpersonateMiddleware: | |||||||
|  |  | ||||||
|         if SESSION_IMPERSONATE_USER in request.session: |         if SESSION_IMPERSONATE_USER in request.session: | ||||||
|             request.user = request.session[SESSION_IMPERSONATE_USER] |             request.user = request.session[SESSION_IMPERSONATE_USER] | ||||||
|  |             # Ensure that the user is active, otherwise nothing will work | ||||||
|  |             request.user.is_active = True | ||||||
|  |  | ||||||
|         return self.get_response(request) |         return self.get_response(request) | ||||||
|  |  | ||||||
| @ -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,12 @@ 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 deepmerge import always_merger | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.models import AbstractUser | from django.contrib.auth.models import AbstractUser | ||||||
| from django.contrib.auth.models import UserManager as DjangoUserManager | from django.contrib.auth.models import UserManager as DjangoUserManager | ||||||
|  | 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 +26,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) | ||||||
| @ -108,8 +115,8 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|         including the users attributes""" |         including the users attributes""" | ||||||
|         final_attributes = {} |         final_attributes = {} | ||||||
|         for group in self.ak_groups.all().order_by("name"): |         for group in self.ak_groups.all().order_by("name"): | ||||||
|             final_attributes.update(group.attributes) |             always_merger.merge(final_attributes, group.attributes) | ||||||
|         final_attributes.update(self.attributes) |         always_merger.merge(final_attributes, self.attributes) | ||||||
|         return final_attributes |         return final_attributes | ||||||
|  |  | ||||||
|     @cached_property |     @cached_property | ||||||
| @ -136,21 +143,25 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|     @property |     @property | ||||||
|     def avatar(self) -> str: |     def avatar(self) -> str: | ||||||
|         """Get avatar, depending on authentik.avatar setting""" |         """Get avatar, depending on authentik.avatar setting""" | ||||||
|         mode = CONFIG.raw.get("authentik").get("avatars") |         mode: str = CONFIG.y("avatars", "none") | ||||||
|         if mode == "none": |         if mode == "none": | ||||||
|             return DEFAULT_AVATAR |             return DEFAULT_AVATAR | ||||||
|  |         # gravatar uses md5 for their URLs, so md5 can't be avoided | ||||||
|  |         mail_hash = md5(self.email.encode("utf-8")).hexdigest()  # nosec | ||||||
|         if mode == "gravatar": |         if mode == "gravatar": | ||||||
|             parameters = [ |             parameters = [ | ||||||
|                 ("s", "158"), |                 ("s", "158"), | ||||||
|                 ("r", "g"), |                 ("r", "g"), | ||||||
|             ] |             ] | ||||||
|             # gravatar uses md5 for their URLs, so md5 can't be avoided |  | ||||||
|             mail_hash = md5(self.email.encode("utf-8")).hexdigest()  # nosec |  | ||||||
|             gravatar_url = ( |             gravatar_url = ( | ||||||
|                 f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" |                 f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" | ||||||
|             ) |             ) | ||||||
|             return escape(gravatar_url) |             return escape(gravatar_url) | ||||||
|         raise ValueError(f"Invalid avatar mode {mode}") |         return mode % { | ||||||
|  |             "username": self.username, | ||||||
|  |             "mail_hash": mail_hash, | ||||||
|  |             "upn": self.attributes.get("upn", ""), | ||||||
|  |         } | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -206,17 +217,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 +269,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 +325,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 +350,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 +365,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 +414,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 | ||||||
| @ -399,7 +465,7 @@ class PropertyMapping(SerializerModel, ManagedModel): | |||||||
|         from authentik.core.expression import PropertyMappingEvaluator |         from authentik.core.expression import PropertyMappingEvaluator | ||||||
|  |  | ||||||
|         evaluator = PropertyMappingEvaluator() |         evaluator = PropertyMappingEvaluator() | ||||||
|         evaluator.set_context(user, request, **kwargs) |         evaluator.set_context(user, request, self, **kwargs) | ||||||
|         try: |         try: | ||||||
|             return evaluator.evaluate(self.expression) |             return evaluator.evaluate(self.expression) | ||||||
|         except (ValueError, SyntaxError) as exc: |         except (ValueError, SyntaxError) as exc: | ||||||
| @ -412,3 +478,37 @@ 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 | ||||||
|  |     ) -> Optional["AuthenticatedSession"]: | ||||||
|  |         """Create a new session from a http request""" | ||||||
|  |         if not hasattr(request, "session") or not request.session.session_key: | ||||||
|  |             return None | ||||||
|  |         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,39 @@ 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 | ||||||
|  |  | ||||||
|  |     session = AuthenticatedSession.from_request(request, user) | ||||||
|  |     if session: | ||||||
|  |         session.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
									
								
							
							
								
								
									
										289
									
								
								authentik/core/sources/flow_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										289
									
								
								authentik/core/sources/flow_manager.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,289 @@ | |||||||
|  | """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 not self.source.enrollment_flow: | ||||||
|  |             return [] | ||||||
|  |         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,13 +7,14 @@ | |||||||
|     <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 }}"> | ||||||
|  |         {% block head_before %} | ||||||
|  |         {% endblock %} | ||||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}"> |         <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> | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <section class="pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|     <div class="pf-c-empty-state"> |     <div class="pf-c-empty-state"> | ||||||
|         <div class="pf-c-empty-state__content"> |         <div class="pf-c-empty-state__content"> | ||||||
|             <i class="fas fa-exclamation-circle pf-c-empty-state__icon" aria-hidden="true"></i> |             <i class="fas fa-exclamation-circle pf-c-empty-state__icon" aria-hidden="true"></i> | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container></ak-message-container> | <ak-message-container></ak-message-container> | ||||||
| <ak-interface-admin> | <ak-interface-admin> | ||||||
|     <section class="ak-initial-load pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|                 <span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}"> |                 <span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}"> | ||||||
|  | |||||||
| @ -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 %} | ||||||
| @ -3,14 +3,25 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
|  | {% block head_before %} | ||||||
|  | {% if flow.compatibility_mode %} | ||||||
|  | <script>ShadyDOM = { force: !navigator.webdriver };</script> | ||||||
|  | {% endif %} | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script> | <script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script> | ||||||
|  | <style> | ||||||
|  | .pf-c-background-image::before { | ||||||
|  |     background-image: url("{{ flow.background_url }}"); | ||||||
|  | } | ||||||
|  | </style> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container></ak-message-container> | <ak-message-container></ak-message-container> | ||||||
| <ak-flow-executor> | <ak-flow-executor> | ||||||
|     <section class="ak-initial-load pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|                 <span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}"> |                 <span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}"> | ||||||
|  | |||||||
| @ -3,6 +3,18 @@ | |||||||
| {% 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 head %} | ||||||
|  | <style> | ||||||
|  | .pf-c-background-image::before { | ||||||
|  |     background-image: url("/static/dist/assets/images/flow_background.jpg"); | ||||||
|  | } | ||||||
|  | </style> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <div class="pf-c-background-image"> | <div class="pf-c-background-image"> | ||||||
|     <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> |     <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> | ||||||
| @ -22,10 +34,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 +54,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, | ||||||
|  | |||||||
| @ -55,11 +55,16 @@ class CertificateKeyPair(CreatedUpdatedModel): | |||||||
|     def private_key(self) -> Optional[RSAPrivateKey]: |     def private_key(self) -> Optional[RSAPrivateKey]: | ||||||
|         """Get python cryptography PrivateKey instance""" |         """Get python cryptography PrivateKey instance""" | ||||||
|         if not self._private_key and self._private_key != "": |         if not self._private_key and self._private_key != "": | ||||||
|             self._private_key = load_pem_private_key( |             try: | ||||||
|                 str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])), |                 self._private_key = load_pem_private_key( | ||||||
|                 password=None, |                     str.encode( | ||||||
|                 backend=default_backend(), |                         "\n".join([x.strip() for x in self.key_data.split("\n")]) | ||||||
|             ) |                     ), | ||||||
|  |                     password=None, | ||||||
|  |                     backend=default_backend(), | ||||||
|  |                 ) | ||||||
|  |             except ValueError: | ||||||
|  |                 return None | ||||||
|         return self._private_key |         return self._private_key | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	