Compare commits
	
		
			860 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f4a6c70e98 | |||
| 5f198e7fe4 | |||
| d172d32817 | |||
| af3fb5c2cd | |||
| 885efb526e | |||
| 3bfb8b2cb2 | |||
| 9fc5ff4b77 | |||
| dd8b579dd6 | |||
| e12cbd8711 | |||
| 62d35f8f8c | |||
| 49be504c13 | |||
| edad55e51d | |||
| 38086fa8bb | |||
| c4f9a3e9a7 | |||
| 930df791bd | |||
| 9a6086634c | |||
| b68e65355a | |||
| 72d33a91dd | |||
| 7067e3d69a | |||
| 4db370d24e | |||
| 41e7b9b73f | |||
| 7f47f93e4e | |||
| 89abd44b76 | |||
| 14c7d8c4f4 | |||
| 525976a81b | |||
| 64a2126ea4 | |||
| 994c5882ab | |||
| ad64d51e85 | |||
| a184a7518a | |||
| 943fd80920 | |||
| 01bb18b8c4 | |||
| 94baaaa5a5 | |||
| 40b164ce94 | |||
| 1d7c7801e7 | |||
| 0db0a12ef3 | |||
| 8008aba450 | |||
| eaeab27004 | |||
| 111fbf119b | |||
| 300ad88447 | |||
| 92cc0c9c64 | |||
| 18ff803370 | |||
| 819af78e2b | |||
| 6338785ce1 | |||
| 973e151dff | |||
| fae6d83f27 | |||
| ed84fe0b8d | |||
| 1ee603403e | |||
| 7db7b7cc4d | |||
| 68a98cd86c | |||
| e758db5727 | |||
| 4d7d700afa | |||
| f9a5add01d | |||
| 2986b56389 | |||
| 58f79b525d | |||
| 0a1c0dae05 | |||
| e18ef8dab6 | |||
| 3cacc59bec | |||
| 4eea46d399 | |||
| 11e25617bd | |||
| 4817126811 | |||
| 0181361efa | |||
| 8ff8e1d5f7 | |||
| 19d5902a92 | |||
| 71dffb21a9 | |||
| bd283c506d | |||
| ef564e5f1a | |||
| 2543224c7c | |||
| 077eee9310 | |||
| d894eeaa67 | |||
| 452bfb39bf | |||
| 6b6702521f | |||
| c07b8d95d0 | |||
| bf347730b3 | |||
| ececfc3a30 | |||
| b76546de0c | |||
| 424d490a60 | |||
| 127dd85214 | |||
| 10570ac7f8 | |||
| dc5667b0b8 | |||
| ec9cacb610 | |||
| 0027dbc0e5 | |||
| c15e4b24a1 | |||
| b6f518ffe6 | |||
| 4e476fd4e9 | |||
| 03503363e5 | |||
| 22d6621b02 | |||
| 0023df64c8 | |||
| 59a259e43a | |||
| c6f39f5eb4 | |||
| e3c0aad48a | |||
| 91dd33cee6 | |||
| 5a2c367e89 | |||
| 3b05c9cb1a | |||
| 6e53f1689d | |||
| e3be0f2550 | |||
| 294f2243c1 | |||
| 7b1373e8d6 | |||
| e70b486f20 | |||
| b90174f153 | |||
| 7d7acd8494 | |||
| 4d9d7c5efb | |||
| d614b3608d | |||
| beb2715fa7 | |||
| 5769ff45b5 | |||
| 9d6f79558f | |||
| 41d5bff9d3 | |||
| ec84ba9b6d | |||
| 042a62f99e | |||
| 907f02cfee | |||
| 53fe412bf9 | |||
| ef9e177fe9 | |||
| 28e675596b | |||
| 9b7f57cc75 | |||
| 935a8f4d58 | |||
| 01fcbb325b | |||
| 7d3d17acb9 | |||
| e434321f7c | |||
| ebd476be14 | |||
| 31ba543c62 | |||
| a101d48b5a | |||
| 4c166dcf52 | |||
| 47b1f025e1 | |||
| 8f44c792ac | |||
| e57b6f2347 | |||
| 275d0dfd03 | |||
| f18cbace7a | |||
| 212220554f | |||
| a596392bc3 | |||
| 3e22740eac | |||
| d18a691f63 | |||
| 3cd5e68bc1 | |||
| c741c13132 | |||
| 924f6f104a | |||
| 454594025b | |||
| e72097292c | |||
| ab17a12184 | |||
| 776f3f69a5 | |||
| 8560c7150a | |||
| 301386fb4a | |||
| 68e8b6990b | |||
| 4f800c4758 | |||
| 90c31c2214 | |||
| 50e3d317b2 | |||
| 3eed7bb010 | |||
| 0ef8edc9f1 | |||
| a6373ebb33 | |||
| bf8ce55eea | |||
| 61b4fcb5f3 | |||
| 81275e3bd1 | |||
| 7988bf7748 | |||
| 00d8eec360 | |||
| 82150c8e84 | |||
| 1dbd749a74 | |||
| a96479f16c | |||
| 5d5fb1f37e | |||
| b6f4d6a5eb | |||
| 8ab5c04c2c | |||
| 386944117e | |||
| 9154b9b85d | |||
| fc19372709 | |||
| e5d9c6537c | |||
| bf5cbac314 | |||
| 5cca637a3d | |||
| 5bfb8b454b | |||
| 4d96437972 | |||
| d03b0b8152 | |||
| c249b55ff5 | |||
| 1e1876b34c | |||
| a27493ad1b | |||
| 95b1ab820e | |||
| 5cf9f0002b | |||
| fc7a452b0c | |||
| 25ee0e4b45 | |||
| 46f12e62e8 | |||
| 4245dea25a | |||
| 908db3df81 | |||
| ef4f9aa437 | |||
| 902dd83c67 | |||
| 1c4b78b5f4 | |||
| d854d819d1 | |||
| f246da6b73 | |||
| 4a56b5e827 | |||
| 53b10e64f8 | |||
| 27e4c7027c | |||
| 410d1b97cd | |||
| f93f7e635b | |||
| 74eba04735 | |||
| 01bdaffe36 | |||
| f6b556713a | |||
| abe38bb16a | |||
| f2b8d45999 | |||
| 3f61dff1cb | |||
| b19da6d774 | |||
| 7c55616e29 | |||
| 952a7f07c1 | |||
| 6510b97c1e | |||
| 19b707a0fb | |||
| 320a600349 | |||
| 10110deae5 | |||
| 884c546f32 | |||
| abec906677 | |||
| 22d1dd801c | |||
| 03891cbe09 | |||
| 3c5157dfd4 | |||
| d241e8d51d | |||
| 7ba15884ed | |||
| 47356915b1 | |||
| 2520c92b78 | |||
| e7e0e6d213 | |||
| ca0250e19f | |||
| cf4c7c1bcb | |||
| 670af8789a | |||
| 5c5634830f | |||
| b6b0edb7ad | |||
| 45440abc80 | |||
| 9c42b75567 | |||
| e9a477c1eb | |||
| fa60655a5d | |||
| 5d729b4878 | |||
| 8692f7233f | |||
| 457e17fec3 | |||
| 87e99625e6 | |||
| 6f32eeea43 | |||
| dfcf8b2d40 | |||
| 846006f2e3 | |||
| f557b2129f | |||
| 6dc2003e34 | |||
| 0149c89003 | |||
| f458cae954 | |||
| f01d117ce6 | |||
| 2bde43e5dc | |||
| 84cc0b5490 | |||
| 2f3026084e | |||
| 89696edbee | |||
| c1f0833c09 | |||
| c77f804b77 | |||
| 8e83209631 | |||
| 2e48e0cc2f | |||
| e72f0ab160 | |||
| a3c681cc44 | |||
| 5b3a9e29fb | |||
| 15803dc67d | |||
| ff37e064c9 | |||
| ef8e922e2a | |||
| 34b11524f1 | |||
| 9e2492be5c | |||
| b3ba083ff0 | |||
| 22a8603892 | |||
| d83d058a4b | |||
| ec3fd4a3ab | |||
| 0764668b14 | |||
| 16b6c17305 | |||
| e60509697a | |||
| 85364af9e9 | |||
| cf4b4030aa | |||
| 74dc025869 | |||
| cabdc53553 | |||
| 29e9f399bd | |||
| dad43017a0 | |||
| 7fb939f97b | |||
| 88859b1c26 | |||
| c78236a2a2 | |||
| ba55538a34 | |||
| f742c73e24 | |||
| ca314c262c | |||
| b932b6c963 | |||
| 3c048a1921 | |||
| 8a60a7e26f | |||
| f10b57ba0b | |||
| e53114a645 | |||
| 2e50532518 | |||
| 1936ddfecb | |||
| 4afef46cb8 | |||
| 92b4244e81 | |||
| dfbf7027bc | |||
| eca2ef20d0 | |||
| cac5c7b3ea | |||
| 37ee555c8e | |||
| f910da0f8a | |||
| fc9d270992 | |||
| dcbc3d788a | |||
| 4658018a90 | |||
| 577b7ee515 | |||
| 621773c1ea | |||
| 3da526f20e | |||
| 052e465041 | |||
| c843f18743 | |||
| 80d0b14bb8 | |||
| 68637cf7cf | |||
| 82acba26af | |||
| ff8a812823 | |||
| 7f5fed2aea | |||
| a5c30fd9c7 | |||
| ef23a0da52 | |||
| ba527e7141 | |||
| 8edc254ab5 | |||
| 42627d21b0 | |||
| 2479b157d0 | |||
| 602573f83f | |||
| 20c33fa011 | |||
| 8599d9efe0 | |||
| 8e6fcfe350 | |||
| 558aa45201 | |||
| e9910732bc | |||
| 246dd4b062 | |||
| 4425f8d183 | |||
| c410bb8c36 | |||
| 44f62a4773 | |||
| b6ff04694f | |||
| d4ce0e8e41 | |||
| 362d72da8c | |||
| 88d0f8d8a8 | |||
| 61097b9400 | |||
| 7a73ddfb60 | |||
| d66f13c249 | |||
| 8cc3cb6a42 | |||
| 4c5537ddfe | |||
| a95779157d | |||
| 70256727fd | |||
| ac6afb2b82 | |||
| 2ea7bd86e8 | |||
| 95bce9c9e7 | |||
| 71a22c2a34 | |||
| f3eb85877d | |||
| 273f5211a0 | |||
| db06428ab9 | |||
| 109d8e48d4 | |||
| 2ca115285c | |||
| f5459645a5 | |||
| 14c159500d | |||
| 03da87991f | |||
| e38ee9c580 | |||
| 3bf53b2db1 | |||
| f33190caa5 | |||
| 741822424a | |||
| 0ca6fbb224 | |||
| f72b652b24 | |||
| 0a2c1eb419 | |||
| eb9593a847 | |||
| 7c71c52791 | |||
| 59493c02c4 | |||
| 83089b47d3 | |||
| 103e723d8c | |||
| 7d6e88061f | |||
| f8aab40e3e | |||
| 5123bc1316 | |||
| 30e8408e85 | |||
| bb34474101 | |||
| a105760123 | |||
| f410a77010 | |||
| 6ff8fdcc49 | |||
| 50ca3dc772 | |||
| 2a09fc0ae2 | |||
| fbb6756488 | |||
| f45fb2eac0 | |||
| 7b8cde17e6 | |||
| 186634fc67 | |||
| c84b1b7997 | |||
| 6e83467481 | |||
| 72db17f23b | |||
| ee4e176039 | |||
| e18e681c2b | |||
| 10fe67e08d | |||
| fc1db83be7 | |||
| 3740e65906 | |||
| 30386cd899 | |||
| 64a10e9a46 | |||
| 77d6242cce | |||
| 9a86dcaec3 | |||
| 0b00768b84 | |||
| d162c79373 | |||
| 05db352a0f | |||
| 5bf3d7fe02 | |||
| 1ae1cbebf4 | |||
| 8c16dfc478 | |||
| c6a3286e4c | |||
| 44cfd7e5b0 | |||
| 210d4c5058 | |||
| 6b39d616b1 | |||
| 32ace1bece | |||
| 54f893b84f | |||
| b5685ec072 | |||
| 5854833240 | |||
| 4b2437a6f1 | |||
| 2981ac7b10 | |||
| 59a51c859a | |||
| 47bab6c182 | |||
| 4e6714fffe | |||
| aa6b595545 | |||
| 0131b1f6cc | |||
| 9f53c359dd | |||
| 28e4dba3e8 | |||
| 2afd46e1df | |||
| f5991b19be | |||
| 5cc75cb25c | |||
| 68c1df2d39 | |||
| c83724f45c | |||
| 5f91c150df | |||
| 0bfe999442 | |||
| 58440b16c4 | |||
| 57757a2ff5 | |||
| 2993f506a7 | |||
| e4841d54a1 | |||
| 4f05dcec89 | |||
| ede6bcd31e | |||
| 728c8e994d | |||
| 5290b64415 | |||
| fec6de1ba2 | |||
| 69678dcfa6 | |||
| 4911a243ff | |||
| 70316b37da | |||
| 307cb94e3b | |||
| ace53a8fa5 | |||
| 0544dc3f83 | |||
| 708ff300a3 | |||
| 4e63f0f215 | |||
| 141481df3a | |||
| 29241cc287 | |||
| e81e97d404 | |||
| a5182e5c24 | |||
| cf5ff6e160 | |||
| f2b3a2ec91 | |||
| 69780c67a9 | |||
| ac9cf590bc | |||
| cb6edcb198 | |||
| 8eecc28c3c | |||
| 10b16bc36a | |||
| 2fe88cfea9 | |||
| caab396b56 | |||
| 5f0f4284a2 | |||
| c11be2284d | |||
| aa321196d7 | |||
| ff03db61a8 | |||
| f3b3ce6572 | |||
| 09b02e1aec | |||
| 451a9aaf01 | |||
| eaee7cb562 | |||
| a010c91a52 | |||
| 709194330f | |||
| 5914bbf173 | |||
| 5e9166f859 | |||
| 35b8ef6592 | |||
| 772a939f17 | |||
| 24971801cf | |||
| 43aebe8cb2 | |||
| 19cfc87c84 | |||
| f920f183c8 | |||
| 97f979c81e | |||
| e61411d396 | |||
| c4f985f542 | |||
| 302dee7ab2 | |||
| 83c12ad483 | |||
| 4224fd5c6f | |||
| 597ce1eb42 | |||
| 5ef385f0bb | |||
| cda4be3d47 | |||
| 8cdf22fc94 | |||
| 6efc7578ef | |||
| 4e2457560d | |||
| 2ddf122d27 | |||
| a24651437a | |||
| 30bb7acb17 | |||
| 7859145138 | |||
| 8a8aafec81 | |||
| deebdf2bcc | |||
| 4982c4abcb | |||
| 1486f90077 | |||
| f4988bc45e | |||
| 8abc9cc031 | |||
| 534689895c | |||
| 8a0dd6be24 | |||
| 65d2eed82d | |||
| e450e7b107 | |||
| 552ddda909 | |||
| bafeff7306 | |||
| 6791436302 | |||
| 7eda794070 | |||
| e3129c1067 | |||
| ff481ba6e7 | |||
| a106bad2db | |||
| 3a1c311d02 | |||
| 6465333f4f | |||
| b761659227 | |||
| 9321c355f8 | |||
| 86c8e79ea1 | |||
| 8916b1f8ab | |||
| 41fcf2aba6 | |||
| 87e72b08a9 | |||
| b2fcd42e3c | |||
| fc1b47a80f | |||
| af14e3502e | |||
| a2faa5ceb5 | |||
| 63a19a1381 | |||
| b472dcb7e7 | |||
| 6303909031 | |||
| 4bdc06865b | |||
| 2ee48cd039 | |||
| 893d5f452b | |||
| 340a9bc8ee | |||
| cb3d9f83f1 | |||
| 4ba55aa8e9 | |||
| bab6f501ec | |||
| 7327939684 | |||
| ffb0135f06 | |||
| ee0ddc3d17 | |||
| 5dd979d66c | |||
| a9bd34f3c5 | |||
| db316b59c5 | |||
| 6209714f87 | |||
| 1ed2bddba7 | |||
| 26b35c9b7b | |||
| 86a9271f75 | |||
| 402ed9bd20 | |||
| 68a0684569 | |||
| bd2e453218 | |||
| 1f31c63e57 | |||
| 480410efa2 | |||
| e9bfee52ed | |||
| 326b574d54 | |||
| 0a7abcf2ad | |||
| 9e5019881e | |||
| 8071750681 | |||
| f2f0931904 | |||
| a91204e5b9 | |||
| b14c22cbff | |||
| b3e40c6aed | |||
| 873aa4bb22 | |||
| c1ea78c422 | |||
| 3c8bbc2621 | |||
| 42a9979d91 | |||
| b7f94df4d9 | |||
| 4143d3fe28 | |||
| f95c06b76f | |||
| e3e9178ccc | |||
| b694816e7b | |||
| e046000f36 | |||
| edb5caae9b | |||
| 02d27651f3 | |||
| 44cd4d847d | |||
| 472256794d | |||
| cbb6887983 | |||
| 317e9ec605 | |||
| ada2a16412 | |||
| 61f6b0f122 | |||
| 6a3f7e45cf | |||
| 2b78c4ba86 | |||
| 680ef641fb | |||
| 2b5504ff63 | |||
| f8a6aa3250 | |||
| 6c23fc4b2b | |||
| 639c2f5c2e | |||
| e44632f9a0 | |||
| 3f2ce34468 | |||
| 426cef998f | |||
| 8ddb62ed0f | |||
| 572f6d4ea0 | |||
| 8db68410c6 | |||
| caa3c3de32 | |||
| 23b5ca761a | |||
| f1b9021e3e | |||
| 99c62af89e | |||
| 8ae50814fe | |||
| 2e2b491ec7 | |||
| ac432e78e2 | |||
| 83ac42ac43 | |||
| 4bd1cd127b | |||
| 2eb5a5cc76 | |||
| 75051687e6 | |||
| 7e316b5fc2 | |||
| 5594ad0b36 | |||
| ea097afeae | |||
| b77b4b5c80 | |||
| f8dc7f48f2 | |||
| 692e75b057 | |||
| 02771683a6 | |||
| 40404ff41d | |||
| fdd5211253 | |||
| 85a417d22e | |||
| 66c530ea06 | |||
| 347c3793fc | |||
| cf78c89830 | |||
| 20c738c384 | |||
| 4f54ce6afb | |||
| f0d7edb963 | |||
| e42ad8db93 | |||
| e917e756cc | |||
| b4963bec76 | |||
| 0d23796989 | |||
| d0ceafe79e | |||
| f2023a7af2 | |||
| 31d597005f | |||
| 62dc86be7b | |||
| 7aa8e35f87 | |||
| 60b95271eb | |||
| 382b0e8941 | |||
| 3b068610b9 | |||
| 9a8f62f42e | |||
| 632e3cf7dc | |||
| e7144649d5 | |||
| dd8909c9b2 | |||
| e6818c1f6a | |||
| 10c4e3c717 | |||
| b8425867c8 | |||
| a05da8cdbf | |||
| c3aeefa653 | |||
| 62c840df21 | |||
| 45d1db8880 | |||
| b34f30f1dd | |||
| 7a54e84eb4 | |||
| 917eef96fb | |||
| 9a393848b2 | |||
| a6abeb50c6 | |||
| 39acb044fb | |||
| 7d2f622f4b | |||
| a2b38caf64 | |||
| 1193b9fd22 | |||
| e3a5ef1907 | |||
| e597bb4542 | |||
| c31df2b3f9 | |||
| 3f2637cffa | |||
| 3b6d9bec0a | |||
| b184210610 | |||
| d2010808ee | |||
| f5b185dd06 | |||
| ae161c1ba9 | |||
| 109283b189 | |||
| 235d283def | |||
| 96a86b3298 | |||
| db9ea8603c | |||
| 8b7f698c7b | |||
| 813c13ce45 | |||
| 629a0e1a4d | |||
| d1e2c018a3 | |||
| 1e86844823 | |||
| b58875d4c7 | |||
| 03e0eecb1d | |||
| 7aa61d86e4 | |||
| 0e6a799e6d | |||
| bc6afdf94f | |||
| 80364b04a9 | |||
| 0948e0ee1c | |||
| 5c54de66fc | |||
| 937edc73bc | |||
| 2c0d8d8943 | |||
| 059ccdd592 | |||
| 0ec0d3f1aa | |||
| 0a0eee138a | |||
| 3ed4c38101 | |||
| de8cf65503 | |||
| 121b36f35f | |||
| 363aed2a47 | |||
| ef994e0084 | |||
| e1ef196283 | |||
| f81ffd54f3 | |||
| f9bfae9190 | |||
| 0d686465a4 | |||
| e13b4a561f | |||
| f6417f95e5 | |||
| 9c6bf5f4ae | |||
| d2d7acb50e | |||
| c7681dde32 | |||
| 8cf9661e08 | |||
| 2dbd76cf90 | |||
| 28d39f4d80 | |||
| 760428aa18 | |||
| 49bbac7441 | |||
| 0b8cfd437b | |||
| b69aaf9417 | |||
| 758d1bdfd4 | |||
| ab501ca971 | |||
| 9657741a3d | |||
| 29b7368f42 | |||
| 75724b6f8d | |||
| 7c9f821bfd | |||
| 5b9e6bed6c | |||
| 6113d7d768 | |||
| 0e3602d7eb | |||
| 2b94e9a687 | |||
| 6ed7d842e4 | |||
| 8794c840cf | |||
| 9c9c00755a | |||
| 6703c0a5d1 | |||
| 060f19ce06 | |||
| b2d2e7cbc8 | |||
| 91fd792f88 | |||
| 2d9cd28221 | |||
| aa64cf898f | |||
| 27d109c1fe | |||
| 1b4a14f3ee | |||
| 9835785864 | |||
| d785998c5a | |||
| 8ba9553220 | |||
| 6eb132c48b | |||
| b523cd064b | |||
| 355b832cc3 | |||
| 8f5af464a2 | |||
| fb70769358 | |||
| ad06778c34 | |||
| bcb4451fb7 | |||
| 110d558572 | |||
| e32d4f0095 | |||
| 0e413acd61 | |||
| d3397c349f | |||
| fb18a10e61 | |||
| 9bb0d04aeb | |||
| 666cf77b04 | |||
| 90ca1b8e5a | |||
| f1e95b8816 | |||
| dad8547212 | |||
| a957e1fc45 | |||
| 39e3f02503 | |||
| 2b999e922c | |||
| 4224134a19 | |||
| eda260dddd | |||
| 8a1dd521e1 | |||
| 1c5e91de1d | |||
| 4b1744fad0 | |||
| f17b83010d | |||
| 12ddf9e73c | |||
| 0b3b300333 | |||
| 23f1a19765 | |||
| b27e998615 | |||
| 2b928146a8 | |||
| a94b0504b7 | |||
| 4fcbfa7709 | |||
| 986e01db20 | |||
| 9092d1189b | |||
| 605ed94ba2 | |||
| 4cbeeb9a0c | |||
| 993dee6aad | |||
| c663deb659 | |||
| 61621e7d60 | |||
| 0ee9b07172 | |||
| 431ba6b4ef | |||
| 146818793e | |||
| 0ce663bce4 | |||
| 923ba4fb42 | |||
| bb6eed0db1 | |||
| d1bd8f333b | |||
| 2ac9f5426d | |||
| 8d1fd48003 | |||
| 241cb01ec6 | |||
| 65b4139997 | |||
| 1431be8c44 | |||
| 049fceeeee | |||
| e6638afa3c | |||
| 465898c7d0 | |||
| c363b1cfde | |||
| b30ffd1318 | |||
| fe0d3a64c8 | |||
| ae9f1c1063 | |||
| ea63d384fd | |||
| c28d75754d | |||
| 518b691e00 | |||
| cd845be45d | |||
| a813d8e05e | |||
| 75f850f4d2 | |||
| c84265c6f0 | |||
| a477ea29cd | |||
| f6aa85e340 | |||
| 0aeedb3ad8 | |||
| 4b29f238b5 | |||
| 34157db06a | |||
| 84b9e66a97 | |||
| e831e4fb94 | |||
| 956922820b | |||
| b0fac9c9f1 | |||
| f4db09cd59 | |||
| 047030f901 | |||
| 638e8d741f | |||
| 425b87a6d0 | |||
| e7dc763612 | |||
| a80cc94da9 | |||
| 547dd3cb7a | |||
| 95739a934c | |||
| d12e24017e | |||
| e4a0345231 | |||
| 078633c2af | |||
| 4b8b800648 | |||
| 6f9ed001a1 | |||
| e4095dfffe | |||
| d5341c2284 | |||
| 357bd65028 | |||
| 867fb0dac0 | |||
| 2666aa2c73 | |||
| f0e9bafa35 | |||
| 0d739f5c1a | |||
| e08077c73a | |||
| 7cf8a31057 | |||
| c43049a981 | |||
| 1a9ace6f9d | |||
| b8d86bc482 | |||
| f7044e41c6 | |||
| fa59fec17a | |||
| e29afa289e | |||
| 4d4193a586 | |||
| 59343ff441 | |||
| cab564152d | |||
| 97b814ab33 | |||
| 88516ba2ca | |||
| f069cfb643 | |||
| 4ce3c2341c | |||
| 77e42d60cb | |||
| cacb919c6f | |||
| 2a3b049b01 | |||
| e4a5e86c93 | |||
| 3a51bcd890 | |||
| c28f68400d | |||
| 5d50fc281a | |||
| 9f7d1466e9 | |||
| c815d24806 | |||
| d1200a7e40 | |||
| edd4f9ceae | |||
| 1cfe81887b | |||
| bb5e0ebab1 | |||
| dfda76d896 | |||
| 8fc5114ce4 | |||
| e7b4363d21 | |||
| 53905d1a89 | |||
| 0ad1392632 | |||
| 6db1c914ee | |||
| 00324f922d | |||
| 8a24ddad28 | |||
| 0f85fe3c29 | |||
| 1f05eaa420 | |||
| 84e126a32c | |||
| 9ae69866bd | |||
| 56576a7f44 | |||
| 7f0295ba53 | |||
| 5553b3ff36 | |||
| 6f969525fe | |||
| bac12246fb | |||
| b53ef6e529 | |||
| 39c62afb93 | |||
| c98bdbacc5 | |||
| 1e8d45dc15 | |||
| 202b057ce9 | |||
| d5d8641b37 | |||
| 9dd37689e3 | |||
| cc0832f487 | |||
| b515bf7d2e | |||
| 34fbf3941b | |||
| e73606b54d | |||
| 0a413fe21a | |||
| d1b9f1e6b8 | |||
| e5a6e128e4 | |||
| 9295d1ed0b | |||
| 5d479a6c8f | |||
| 4a773b2b4f | |||
| 8003d67844 | |||
| 58baf97e2d | |||
| 51783c1cbb | |||
| 94290c7e36 | |||
| 123ff7ad1f | |||
| 8f3e863cce | |||
| 3d6c459349 | |||
| 6a583bae49 | |||
| 78e5879d9a | |||
| fdcac2a9ed | |||
| e81715caef | 
@ -1,5 +1,5 @@
 | 
				
			|||||||
[bumpversion]
 | 
					[bumpversion]
 | 
				
			||||||
current_version = 2021.10.3
 | 
					current_version = 2022.1.1
 | 
				
			||||||
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>.*)
 | 
				
			||||||
@ -17,7 +17,7 @@ values =
 | 
				
			|||||||
	beta
 | 
						beta
 | 
				
			||||||
	stable
 | 
						stable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:website/docs/installation/docker-compose.md]
 | 
					[bumpversion:file:pyproject.toml]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:docker-compose.yml]
 | 
					[bumpversion:file:docker-compose.yml]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -30,7 +30,3 @@ values =
 | 
				
			|||||||
[bumpversion:file:internal/constants/constants.go]
 | 
					[bumpversion:file:internal/constants/constants.go]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[bumpversion:file:web/src/constants.ts]
 | 
					[bumpversion:file:web/src/constants.ts]
 | 
				
			||||||
 | 
					 | 
				
			||||||
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]
 | 
					 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							@ -7,6 +7,7 @@ exemptLabels:
 | 
				
			|||||||
  - pinned
 | 
					  - pinned
 | 
				
			||||||
  - security
 | 
					  - security
 | 
				
			||||||
  - pr_wanted
 | 
					  - pr_wanted
 | 
				
			||||||
 | 
					  - enhancement/confirmed
 | 
				
			||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
 | 
					# Comment to post when marking an issue as stale. Set to `false` to disable
 | 
				
			||||||
markComment: >
 | 
					markComment: >
 | 
				
			||||||
  This issue has been automatically marked as stale because it has not had
 | 
					  This issue has been automatically marked as stale because it has not had
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										276
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										276
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							@ -18,112 +18,51 @@ env:
 | 
				
			|||||||
  POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
 | 
					  POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  lint-pylint:
 | 
					  lint:
 | 
				
			||||||
 | 
					    strategy:
 | 
				
			||||||
 | 
					      fail-fast: false
 | 
				
			||||||
 | 
					      matrix:
 | 
				
			||||||
 | 
					        job:
 | 
				
			||||||
 | 
					          - pylint
 | 
				
			||||||
 | 
					          - black
 | 
				
			||||||
 | 
					          - isort
 | 
				
			||||||
 | 
					          - bandit
 | 
				
			||||||
 | 
					          - pyright
 | 
				
			||||||
 | 
					          - pending-migrations
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: '3.9'
 | 
					 | 
				
			||||||
      - id: cache-pipenv
 | 
					 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					 | 
				
			||||||
      - name: run pylint
 | 
					 | 
				
			||||||
        run: pipenv run pylint authentik tests lifecycle
 | 
					 | 
				
			||||||
  lint-black:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: '3.9'
 | 
					 | 
				
			||||||
      - id: cache-pipenv
 | 
					 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					 | 
				
			||||||
      - name: run black
 | 
					 | 
				
			||||||
        run: pipenv run black --check authentik tests lifecycle
 | 
					 | 
				
			||||||
  lint-isort:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: '3.9'
 | 
					 | 
				
			||||||
      - id: cache-pipenv
 | 
					 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					 | 
				
			||||||
      - name: run isort
 | 
					 | 
				
			||||||
        run: pipenv run isort --check authentik tests lifecycle
 | 
					 | 
				
			||||||
  lint-bandit:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: '3.9'
 | 
					 | 
				
			||||||
      - id: cache-pipenv
 | 
					 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					 | 
				
			||||||
        env:
 | 
					 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					 | 
				
			||||||
      - name: run bandit
 | 
					 | 
				
			||||||
        run: pipenv run bandit -r authentik tests lifecycle
 | 
					 | 
				
			||||||
  lint-pyright:
 | 
					 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
    steps:
 | 
					 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: '3.9'
 | 
					 | 
				
			||||||
      - uses: actions/setup-node@v2
 | 
					      - uses: actions/setup-node@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          node-version: '16'
 | 
					          node-version: '16'
 | 
				
			||||||
 | 
					      - id: cache-poetry
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        run: |
 | 
					        env:
 | 
				
			||||||
          scripts/ci_prepare.sh
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
          npm install -g pyright@1.1.136
 | 
					        run: scripts/ci_prepare.sh
 | 
				
			||||||
      - name: run bandit
 | 
					      - name: run job
 | 
				
			||||||
        run: pipenv run pyright e2e lifecycle
 | 
					        run: poetry run make ci-${{ matrix.job }}
 | 
				
			||||||
  test-migrations:
 | 
					  test-migrations:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
 | 
					      - id: cache-poetry
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          python-version: '3.9'
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
      - id: cache-pipenv
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					        run: scripts/ci_prepare.sh
 | 
				
			||||||
      - name: run migrations
 | 
					      - name: run migrations
 | 
				
			||||||
        run: pipenv run python -m lifecycle.migrate
 | 
					        run: poetry run python -m lifecycle.migrate
 | 
				
			||||||
  test-migrations-from-stable:
 | 
					  test-migrations-from-stable:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
@ -131,68 +70,69 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          fetch-depth: 0
 | 
					          fetch-depth: 0
 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: '3.9'
 | 
					 | 
				
			||||||
      - name: prepare variables
 | 
					      - name: prepare variables
 | 
				
			||||||
        id: ev
 | 
					        id: ev
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          python ./scripts/gh_env.py
 | 
					          python ./scripts/gh_env.py
 | 
				
			||||||
      - id: cache-pipenv
 | 
					          sudo pip install -U pipenv
 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					      - id: cache-poetry
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
      - name: checkout stable
 | 
					      - name: checkout stable
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          # Copy current, latest config to local
 | 
					          # Copy current, latest config to local
 | 
				
			||||||
          cp authentik/lib/default.yml local.env.yml
 | 
					          cp authentik/lib/default.yml local.env.yml
 | 
				
			||||||
 | 
					          cp -R .github ..
 | 
				
			||||||
 | 
					          cp -R scripts ..
 | 
				
			||||||
 | 
					          cp -R poetry.lock pyproject.toml ..
 | 
				
			||||||
          git checkout $(git describe --abbrev=0 --match 'version/*')
 | 
					          git checkout $(git describe --abbrev=0 --match 'version/*')
 | 
				
			||||||
          git checkout ${{ steps.ev.outputs.branchName }} -- .github
 | 
					          rm -rf .github/ scripts/
 | 
				
			||||||
          git checkout ${{ steps.ev.outputs.branchName }} -- scripts
 | 
					          mv ../.github ../scripts ../poetry.lock ../pyproject.toml .
 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          scripts/ci_prepare.sh
 | 
					          scripts/ci_prepare.sh
 | 
				
			||||||
          # Sync anyways since stable will have different dependencies
 | 
					          # install anyways since stable will have different dependencies
 | 
				
			||||||
          pipenv sync --dev
 | 
					          poetry install
 | 
				
			||||||
      - name: run migrations to stable
 | 
					      - name: run migrations to stable
 | 
				
			||||||
        run: pipenv run python -m lifecycle.migrate
 | 
					        run: poetry run python -m lifecycle.migrate
 | 
				
			||||||
      - name: checkout current code
 | 
					      - name: checkout current code
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          set -x
 | 
					          set -x
 | 
				
			||||||
          git fetch
 | 
					          git fetch
 | 
				
			||||||
          git checkout ${{ steps.ev.outputs.branchName }}
 | 
					          git reset --hard HEAD
 | 
				
			||||||
          pipenv sync --dev
 | 
					          git checkout $GITHUB_SHA
 | 
				
			||||||
 | 
					          poetry install
 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					        run: scripts/ci_prepare.sh
 | 
				
			||||||
      - name: migrate to latest
 | 
					      - name: migrate to latest
 | 
				
			||||||
        run: pipenv run python -m lifecycle.migrate
 | 
					        run: poetry run python -m lifecycle.migrate
 | 
				
			||||||
  test-unittest:
 | 
					  test-unittest:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
 | 
					      - id: cache-poetry
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          python-version: '3.9'
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
      - id: cache-pipenv
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					        run: scripts/ci_prepare.sh
 | 
				
			||||||
      - uses: testspace-com/setup-testspace@v1
 | 
					      - uses: testspace-com/setup-testspace@v1
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          domain: ${{github.repository_owner}}
 | 
					          domain: ${{github.repository_owner}}
 | 
				
			||||||
      - name: run unittest
 | 
					      - name: run unittest
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          pipenv run make test
 | 
					          poetry run make test
 | 
				
			||||||
          pipenv run coverage xml
 | 
					          poetry run coverage xml
 | 
				
			||||||
      - name: run testspace
 | 
					      - name: run testspace
 | 
				
			||||||
        if: ${{ always() }}
 | 
					        if: ${{ always() }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
@ -204,16 +144,14 @@ jobs:
 | 
				
			|||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
 | 
					      - id: cache-poetry
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          python-version: '3.9'
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
      - id: cache-pipenv
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
        run: scripts/ci_prepare.sh
 | 
					        run: scripts/ci_prepare.sh
 | 
				
			||||||
      - uses: testspace-com/setup-testspace@v1
 | 
					      - uses: testspace-com/setup-testspace@v1
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
@ -222,21 +160,19 @@ jobs:
 | 
				
			|||||||
        uses: helm/kind-action@v1.2.0
 | 
					        uses: helm/kind-action@v1.2.0
 | 
				
			||||||
      - name: run integration
 | 
					      - name: run integration
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          pipenv run make test-integration
 | 
					          poetry run make test-integration
 | 
				
			||||||
          pipenv run coverage xml
 | 
					          poetry run coverage xml
 | 
				
			||||||
      - name: run testspace
 | 
					      - name: run testspace
 | 
				
			||||||
        if: ${{ always() }}
 | 
					        if: ${{ always() }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          testspace [integration]unittest.xml --link=codecov
 | 
					          testspace [integration]unittest.xml --link=codecov
 | 
				
			||||||
      - if: ${{ always() }}
 | 
					      - if: ${{ always() }}
 | 
				
			||||||
        uses: codecov/codecov-action@v2
 | 
					        uses: codecov/codecov-action@v2
 | 
				
			||||||
  test-e2e:
 | 
					  test-e2e-provider:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          python-version: '3.9'
 | 
					 | 
				
			||||||
      - uses: actions/setup-node@v2
 | 
					      - uses: actions/setup-node@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          node-version: '16'
 | 
					          node-version: '16'
 | 
				
			||||||
@ -245,19 +181,19 @@ jobs:
 | 
				
			|||||||
      - uses: testspace-com/setup-testspace@v1
 | 
					      - uses: testspace-com/setup-testspace@v1
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          domain: ${{github.repository_owner}}
 | 
					          domain: ${{github.repository_owner}}
 | 
				
			||||||
      - id: cache-pipenv
 | 
					      - id: cache-poetry
 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          scripts/ci_prepare.sh
 | 
					          scripts/ci_prepare.sh
 | 
				
			||||||
          docker-compose -f tests/e2e/docker-compose.yml up -d
 | 
					          docker-compose -f tests/e2e/docker-compose.yml up -d
 | 
				
			||||||
      - id: cache-web
 | 
					      - id: cache-web
 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          path: web/dist
 | 
					          path: web/dist
 | 
				
			||||||
          key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
 | 
					          key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
 | 
				
			||||||
@ -269,35 +205,90 @@ jobs:
 | 
				
			|||||||
          npm run build
 | 
					          npm run build
 | 
				
			||||||
      - name: run e2e
 | 
					      - name: run e2e
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          pipenv run make test-e2e
 | 
					          poetry run make test-e2e-provider
 | 
				
			||||||
          pipenv run coverage xml
 | 
					          poetry run coverage xml
 | 
				
			||||||
      - name: run testspace
 | 
					      - name: run testspace
 | 
				
			||||||
        if: ${{ always() }}
 | 
					        if: ${{ always() }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          testspace [e2e]unittest.xml --link=codecov
 | 
					          testspace [e2e-provider]unittest.xml --link=codecov
 | 
				
			||||||
      - if: ${{ always() }}
 | 
					      - if: ${{ always() }}
 | 
				
			||||||
        uses: codecov/codecov-action@v2
 | 
					        uses: codecov/codecov-action@v2
 | 
				
			||||||
  build:
 | 
					  test-e2e-rest:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
 | 
					      - uses: actions/setup-node@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          node-version: '16'
 | 
				
			||||||
 | 
					          cache: 'npm'
 | 
				
			||||||
 | 
					          cache-dependency-path: web/package-lock.json
 | 
				
			||||||
 | 
					      - uses: testspace-com/setup-testspace@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          domain: ${{github.repository_owner}}
 | 
				
			||||||
 | 
					      - id: cache-poetry
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
 | 
					      - name: prepare
 | 
				
			||||||
 | 
					        env:
 | 
				
			||||||
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          scripts/ci_prepare.sh
 | 
				
			||||||
 | 
					          docker-compose -f tests/e2e/docker-compose.yml up -d
 | 
				
			||||||
 | 
					      - id: cache-web
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: web/dist
 | 
				
			||||||
 | 
					          key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
 | 
				
			||||||
 | 
					      - name: prepare web ui
 | 
				
			||||||
 | 
					        if: steps.cache-web.outputs.cache-hit != 'true'
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          cd web
 | 
				
			||||||
 | 
					          npm i
 | 
				
			||||||
 | 
					          npm run build
 | 
				
			||||||
 | 
					      - name: run e2e
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          poetry run make test-e2e-rest
 | 
				
			||||||
 | 
					          poetry run coverage xml
 | 
				
			||||||
 | 
					      - name: run testspace
 | 
				
			||||||
 | 
					        if: ${{ always() }}
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          testspace [e2e-rest]unittest.xml --link=codecov
 | 
				
			||||||
 | 
					      - if: ${{ always() }}
 | 
				
			||||||
 | 
					        uses: codecov/codecov-action@v2
 | 
				
			||||||
 | 
					  ci-core-mark:
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - lint-pylint
 | 
					      - lint
 | 
				
			||||||
      - lint-black
 | 
					 | 
				
			||||||
      - lint-isort
 | 
					 | 
				
			||||||
      - lint-bandit
 | 
					 | 
				
			||||||
      - lint-pyright
 | 
					 | 
				
			||||||
      - test-migrations
 | 
					      - test-migrations
 | 
				
			||||||
      - test-migrations-from-stable
 | 
					      - test-migrations-from-stable
 | 
				
			||||||
      - test-unittest
 | 
					      - test-unittest
 | 
				
			||||||
      - test-integration
 | 
					      - test-integration
 | 
				
			||||||
      - test-e2e
 | 
					      - test-e2e-rest
 | 
				
			||||||
 | 
					      - test-e2e-provider
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - run: echo mark
 | 
				
			||||||
 | 
					  build:
 | 
				
			||||||
 | 
					    needs: ci-core-mark
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    timeout-minutes: 120
 | 
				
			||||||
 | 
					    strategy:
 | 
				
			||||||
 | 
					      fail-fast: false
 | 
				
			||||||
 | 
					      matrix:
 | 
				
			||||||
 | 
					        arch:
 | 
				
			||||||
 | 
					          - 'linux/amd64'
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
 | 
					      - name: Set up QEMU
 | 
				
			||||||
 | 
					        uses: docker/setup-qemu-action@v1.2.0
 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
        uses: docker/setup-buildx-action@v1
 | 
					        uses: docker/setup-buildx-action@v1
 | 
				
			||||||
      - name: prepare variables
 | 
					      - name: prepare variables
 | 
				
			||||||
        id: ev
 | 
					        id: ev
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }}
 | 
					          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          python ./scripts/gh_env.py
 | 
					          python ./scripts/gh_env.py
 | 
				
			||||||
      - name: Login to Container Registry
 | 
					      - name: Login to Container Registry
 | 
				
			||||||
@ -316,3 +307,4 @@ jobs:
 | 
				
			|||||||
            ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
 | 
					            ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
 | 
				
			||||||
          build-args: |
 | 
					          build-args: |
 | 
				
			||||||
            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
					            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
				
			||||||
 | 
					          platforms: ${{ matrix.arch }}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										59
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										59
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							@ -17,7 +17,7 @@ jobs:
 | 
				
			|||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-go@v2
 | 
					      - uses: actions/setup-go@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          go-version: '^1.16.3'
 | 
					          go-version: "^1.17"
 | 
				
			||||||
      - name: Run linter
 | 
					      - name: Run linter
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          # Create folder structure for go embeds
 | 
					          # Create folder structure for go embeds
 | 
				
			||||||
@ -28,25 +28,37 @@ jobs:
 | 
				
			|||||||
            --rm \
 | 
					            --rm \
 | 
				
			||||||
            -v $(pwd):/app \
 | 
					            -v $(pwd):/app \
 | 
				
			||||||
            -w /app \
 | 
					            -w /app \
 | 
				
			||||||
            golangci/golangci-lint:v1.39.0 \
 | 
					            golangci/golangci-lint:v1.43 \
 | 
				
			||||||
            golangci-lint run -v --timeout 200s
 | 
					            golangci-lint run -v --timeout 200s
 | 
				
			||||||
  build:
 | 
					  ci-outpost-mark:
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - lint-golint
 | 
					      - lint-golint
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - run: echo mark
 | 
				
			||||||
 | 
					  build:
 | 
				
			||||||
 | 
					    timeout-minutes: 120
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - ci-outpost-mark
 | 
				
			||||||
    strategy:
 | 
					    strategy:
 | 
				
			||||||
 | 
					      fail-fast: false
 | 
				
			||||||
      matrix:
 | 
					      matrix:
 | 
				
			||||||
        type:
 | 
					        type:
 | 
				
			||||||
          - proxy
 | 
					          - proxy
 | 
				
			||||||
          - ldap
 | 
					          - ldap
 | 
				
			||||||
 | 
					        arch:
 | 
				
			||||||
 | 
					          - 'linux/amd64'
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
 | 
					      - name: Set up QEMU
 | 
				
			||||||
 | 
					        uses: docker/setup-qemu-action@v1.2.0
 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
        uses: docker/setup-buildx-action@v1
 | 
					        uses: docker/setup-buildx-action@v1
 | 
				
			||||||
      - name: prepare variables
 | 
					      - name: prepare variables
 | 
				
			||||||
        id: ev
 | 
					        id: ev
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }}
 | 
					          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          python ./scripts/gh_env.py
 | 
					          python ./scripts/gh_env.py
 | 
				
			||||||
      - name: Login to Container Registry
 | 
					      - name: Login to Container Registry
 | 
				
			||||||
@ -67,3 +79,42 @@ jobs:
 | 
				
			|||||||
          file: ${{ matrix.type }}.Dockerfile
 | 
					          file: ${{ matrix.type }}.Dockerfile
 | 
				
			||||||
          build-args: |
 | 
					          build-args: |
 | 
				
			||||||
            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
					            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
				
			||||||
 | 
					          platforms: ${{ matrix.arch }}
 | 
				
			||||||
 | 
					  build-outpost-binary:
 | 
				
			||||||
 | 
					    timeout-minutes: 120
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - ci-outpost-mark
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    strategy:
 | 
				
			||||||
 | 
					      fail-fast: false
 | 
				
			||||||
 | 
					      matrix:
 | 
				
			||||||
 | 
					        type:
 | 
				
			||||||
 | 
					          - proxy
 | 
				
			||||||
 | 
					          - ldap
 | 
				
			||||||
 | 
					        goos: [linux]
 | 
				
			||||||
 | 
					        goarch: [amd64, arm64]
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
 | 
					      - uses: actions/setup-go@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          go-version: "^1.17"
 | 
				
			||||||
 | 
					      - uses: actions/setup-node@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          node-version: '16'
 | 
				
			||||||
 | 
					          cache: 'npm'
 | 
				
			||||||
 | 
					          cache-dependency-path: web/package-lock.json
 | 
				
			||||||
 | 
					      - name: Build web
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          cd web
 | 
				
			||||||
 | 
					          npm install
 | 
				
			||||||
 | 
					          npm run build-proxy
 | 
				
			||||||
 | 
					      - name: Build outpost
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          set -x
 | 
				
			||||||
 | 
					          export GOOS=${{ matrix.goos }}
 | 
				
			||||||
 | 
					          export GOARCH=${{ matrix.goarch }}
 | 
				
			||||||
 | 
					          go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
 | 
				
			||||||
 | 
					      - uses: actions/upload-artifact@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
 | 
				
			||||||
 | 
					          path: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										8
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							@ -65,12 +65,18 @@ jobs:
 | 
				
			|||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cd web
 | 
					          cd web
 | 
				
			||||||
          npm run lit-analyse
 | 
					          npm run lit-analyse
 | 
				
			||||||
  build:
 | 
					  ci-web-mark:
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - lint-eslint
 | 
					      - lint-eslint
 | 
				
			||||||
      - lint-prettier
 | 
					      - lint-prettier
 | 
				
			||||||
      - lint-lit-analyse
 | 
					      - lint-lit-analyse
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - run: echo mark
 | 
				
			||||||
 | 
					  build:
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - ci-web-mark
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-node@v2
 | 
					      - uses: actions/setup-node@v2
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										130
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										130
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -30,14 +30,14 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          push: ${{ github.event_name == 'release' }}
 | 
					          push: ${{ github.event_name == 'release' }}
 | 
				
			||||||
          tags: |
 | 
					          tags: |
 | 
				
			||||||
            beryju/authentik:2021.10.3,
 | 
					            beryju/authentik:2022.1.1,
 | 
				
			||||||
            beryju/authentik:latest,
 | 
					            beryju/authentik:latest,
 | 
				
			||||||
            ghcr.io/goauthentik/server:2021.10.3,
 | 
					            ghcr.io/goauthentik/server:2022.1.1,
 | 
				
			||||||
            ghcr.io/goauthentik/server:latest
 | 
					            ghcr.io/goauthentik/server:latest
 | 
				
			||||||
          platforms: linux/amd64,linux/arm64
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
          context: .
 | 
					          context: .
 | 
				
			||||||
      - name: Building Docker Image (stable)
 | 
					      - name: Building Docker Image (stable)
 | 
				
			||||||
        if: ${{ github.event_name == 'release' && !contains('2021.10.3', 'rc') }}
 | 
					        if: ${{ github.event_name == 'release' && !contains('2022.1.1', 'rc') }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          docker pull beryju/authentik:latest
 | 
					          docker pull beryju/authentik:latest
 | 
				
			||||||
          docker tag beryju/authentik:latest beryju/authentik:stable
 | 
					          docker tag beryju/authentik:latest beryju/authentik:stable
 | 
				
			||||||
@ -45,13 +45,19 @@ jobs:
 | 
				
			|||||||
          docker pull ghcr.io/goauthentik/server:latest
 | 
					          docker pull ghcr.io/goauthentik/server:latest
 | 
				
			||||||
          docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable
 | 
					          docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable
 | 
				
			||||||
          docker push ghcr.io/goauthentik/server:stable
 | 
					          docker push ghcr.io/goauthentik/server:stable
 | 
				
			||||||
  build-proxy:
 | 
					  build-outpost:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    strategy:
 | 
				
			||||||
 | 
					      fail-fast: false
 | 
				
			||||||
 | 
					      matrix:
 | 
				
			||||||
 | 
					        type:
 | 
				
			||||||
 | 
					          - proxy
 | 
				
			||||||
 | 
					          - ldap
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-go@v2
 | 
					      - uses: actions/setup-go@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          go-version: "^1.15"
 | 
					          go-version: "^1.17"
 | 
				
			||||||
      - name: Set up QEMU
 | 
					      - name: Set up QEMU
 | 
				
			||||||
        uses: docker/setup-qemu-action@v1.2.0
 | 
					        uses: docker/setup-qemu-action@v1.2.0
 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
@ -72,68 +78,65 @@ jobs:
 | 
				
			|||||||
        with:
 | 
					        with:
 | 
				
			||||||
          push: ${{ github.event_name == 'release' }}
 | 
					          push: ${{ github.event_name == 'release' }}
 | 
				
			||||||
          tags: |
 | 
					          tags: |
 | 
				
			||||||
            beryju/authentik-proxy:2021.10.3,
 | 
					            beryju/authentik-${{ matrix.type }}:2022.1.1,
 | 
				
			||||||
            beryju/authentik-proxy:latest,
 | 
					            beryju/authentik-${{ matrix.type }}:latest,
 | 
				
			||||||
            ghcr.io/goauthentik/proxy:2021.10.3,
 | 
					            ghcr.io/goauthentik/${{ matrix.type }}:2022.1.1,
 | 
				
			||||||
            ghcr.io/goauthentik/proxy:latest
 | 
					            ghcr.io/goauthentik/${{ matrix.type }}:latest
 | 
				
			||||||
          file: proxy.Dockerfile
 | 
					          file: ${{ matrix.type }}.Dockerfile
 | 
				
			||||||
          platforms: linux/amd64,linux/arm64
 | 
					          platforms: linux/amd64,linux/arm64
 | 
				
			||||||
      - name: Building Docker Image (stable)
 | 
					      - name: Building Docker Image (stable)
 | 
				
			||||||
        if: ${{ github.event_name == 'release' && !contains('2021.10.3', 'rc') }}
 | 
					        if: ${{ github.event_name == 'release' && !contains('2022.1.1', 'rc') }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          docker pull beryju/authentik-proxy:latest
 | 
					          docker pull beryju/authentik-${{ matrix.type }}:latest
 | 
				
			||||||
          docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable
 | 
					          docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable
 | 
				
			||||||
          docker push beryju/authentik-proxy:stable
 | 
					          docker push beryju/authentik-${{ matrix.type }}:stable
 | 
				
			||||||
          docker pull ghcr.io/goauthentik/proxy:latest
 | 
					          docker pull ghcr.io/goauthentik/${{ matrix.type }}:latest
 | 
				
			||||||
          docker tag ghcr.io/goauthentik/proxy:latest ghcr.io/goauthentik/proxy:stable
 | 
					          docker tag ghcr.io/goauthentik/${{ matrix.type }}:latest ghcr.io/goauthentik/${{ matrix.type }}:stable
 | 
				
			||||||
          docker push ghcr.io/goauthentik/proxy:stable
 | 
					          docker push ghcr.io/goauthentik/${{ matrix.type }}:stable
 | 
				
			||||||
  build-ldap:
 | 
					  build-outpost-binary:
 | 
				
			||||||
 | 
					    timeout-minutes: 120
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    strategy:
 | 
				
			||||||
 | 
					      fail-fast: false
 | 
				
			||||||
 | 
					      matrix:
 | 
				
			||||||
 | 
					        type:
 | 
				
			||||||
 | 
					          - proxy
 | 
				
			||||||
 | 
					          - ldap
 | 
				
			||||||
 | 
					        goos: [linux, darwin]
 | 
				
			||||||
 | 
					        goarch: [amd64, arm64]
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-go@v2
 | 
					      - uses: actions/setup-go@v2
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          go-version: "^1.15"
 | 
					          go-version: "^1.17"
 | 
				
			||||||
      - name: Set up QEMU
 | 
					      - uses: actions/setup-node@v2
 | 
				
			||||||
        uses: docker/setup-qemu-action@v1.2.0
 | 
					 | 
				
			||||||
      - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
        uses: docker/setup-buildx-action@v1
 | 
					 | 
				
			||||||
      - name: Docker Login Registry
 | 
					 | 
				
			||||||
        uses: docker/login-action@v1
 | 
					 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
					          node-version: '16'
 | 
				
			||||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
					          cache: 'npm'
 | 
				
			||||||
      - name: Login to GitHub Container Registry
 | 
					          cache-dependency-path: web/package-lock.json
 | 
				
			||||||
        uses: docker/login-action@v1
 | 
					      - name: Build web
 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          registry: ghcr.io
 | 
					 | 
				
			||||||
          username: ${{ github.repository_owner }}
 | 
					 | 
				
			||||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
					 | 
				
			||||||
      - name: Building Docker Image
 | 
					 | 
				
			||||||
        uses: docker/build-push-action@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          push: ${{ github.event_name == 'release' }}
 | 
					 | 
				
			||||||
          tags: |
 | 
					 | 
				
			||||||
            beryju/authentik-ldap:2021.10.3,
 | 
					 | 
				
			||||||
            beryju/authentik-ldap:latest,
 | 
					 | 
				
			||||||
            ghcr.io/goauthentik/ldap:2021.10.3,
 | 
					 | 
				
			||||||
            ghcr.io/goauthentik/ldap:latest
 | 
					 | 
				
			||||||
          file: ldap.Dockerfile
 | 
					 | 
				
			||||||
          platforms: linux/amd64,linux/arm64
 | 
					 | 
				
			||||||
      - name: Building Docker Image (stable)
 | 
					 | 
				
			||||||
        if: ${{ github.event_name == 'release' && !contains('2021.10.3', 'rc') }}
 | 
					 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          docker pull beryju/authentik-ldap:latest
 | 
					          cd web
 | 
				
			||||||
          docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable
 | 
					          npm install
 | 
				
			||||||
          docker push beryju/authentik-ldap:stable
 | 
					          npm run build-proxy
 | 
				
			||||||
          docker pull ghcr.io/goauthentik/ldap:latest
 | 
					      - name: Build outpost
 | 
				
			||||||
          docker tag ghcr.io/goauthentik/ldap:latest ghcr.io/goauthentik/ldap:stable
 | 
					        run: |
 | 
				
			||||||
          docker push ghcr.io/goauthentik/ldap:stable
 | 
					          set -x
 | 
				
			||||||
 | 
					          export GOOS=${{ matrix.goos }}
 | 
				
			||||||
 | 
					          export GOARCH=${{ matrix.goarch }}
 | 
				
			||||||
 | 
					          go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
 | 
				
			||||||
 | 
					      - name: Upload binaries to release
 | 
				
			||||||
 | 
					        uses: svenstaro/upload-release-action@v2
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          repo_token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					          file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
 | 
				
			||||||
 | 
					          asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
 | 
				
			||||||
 | 
					          tag: ${{ github.ref }}
 | 
				
			||||||
  test-release:
 | 
					  test-release:
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - build-server
 | 
					      - build-server
 | 
				
			||||||
      - build-proxy
 | 
					      - build-outpost
 | 
				
			||||||
      - build-ldap
 | 
					      - build-outpost-binary
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
@ -147,20 +150,17 @@ jobs:
 | 
				
			|||||||
          docker-compose run -u root server test
 | 
					          docker-compose run -u root server test
 | 
				
			||||||
  sentry-release:
 | 
					  sentry-release:
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
      - test-release
 | 
					      - build-server
 | 
				
			||||||
 | 
					      - build-outpost
 | 
				
			||||||
 | 
					      - build-outpost-binary
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - name: Setup Node.js environment
 | 
					      - name: Get static files from docker image
 | 
				
			||||||
        uses: actions/setup-node@v2
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          node-version: '16'
 | 
					 | 
				
			||||||
      - name: Build web api client and web ui
 | 
					 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          export NODE_ENV=production
 | 
					          docker pull ghcr.io/goauthentik/server:latest
 | 
				
			||||||
          cd web
 | 
					          container=$(docker container create ghcr.io/goauthentik/server:latest)
 | 
				
			||||||
          npm i
 | 
					          docker cp ${container}:web/ .
 | 
				
			||||||
          npm run build
 | 
					 | 
				
			||||||
      - name: Create a Sentry.io release
 | 
					      - name: Create a Sentry.io release
 | 
				
			||||||
        uses: getsentry/action-release@v1
 | 
					        uses: getsentry/action-release@v1
 | 
				
			||||||
        if: ${{ github.event_name == 'release' }}
 | 
					        if: ${{ github.event_name == 'release' }}
 | 
				
			||||||
@ -170,7 +170,7 @@ jobs:
 | 
				
			|||||||
          SENTRY_PROJECT: authentik
 | 
					          SENTRY_PROJECT: authentik
 | 
				
			||||||
          SENTRY_URL: https://sentry.beryju.org
 | 
					          SENTRY_URL: https://sentry.beryju.org
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          version: authentik@2021.10.3
 | 
					          version: authentik@2022.1.1
 | 
				
			||||||
          environment: beryjuorg-prod
 | 
					          environment: beryjuorg-prod
 | 
				
			||||||
          sourcemaps: './web/dist'
 | 
					          sourcemaps: './web/dist'
 | 
				
			||||||
          url_prefix: '~/static/dist'
 | 
					          url_prefix: '~/static/dist'
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							@ -15,6 +15,7 @@ jobs:
 | 
				
			|||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          echo "PG_PASS=$(openssl rand -base64 32)" >> .env
 | 
					          echo "PG_PASS=$(openssl rand -base64 32)" >> .env
 | 
				
			||||||
          echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
 | 
					          echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
 | 
				
			||||||
 | 
					          docker buildx install
 | 
				
			||||||
          docker build \
 | 
					          docker build \
 | 
				
			||||||
            --no-cache \
 | 
					            --no-cache \
 | 
				
			||||||
            -t testing:latest \
 | 
					            -t testing:latest \
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										26
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							@ -4,6 +4,9 @@ on:
 | 
				
			|||||||
    branches: [ master ]
 | 
					    branches: [ master ]
 | 
				
			||||||
    paths:
 | 
					    paths:
 | 
				
			||||||
      - '/locale/'
 | 
					      - '/locale/'
 | 
				
			||||||
 | 
					  pull_request:
 | 
				
			||||||
 | 
					    paths:
 | 
				
			||||||
 | 
					      - '/locale/'
 | 
				
			||||||
  schedule:
 | 
					  schedule:
 | 
				
			||||||
  - cron: "0 */2 * * *"
 | 
					  - cron: "0 */2 * * *"
 | 
				
			||||||
  workflow_dispatch:
 | 
					  workflow_dispatch:
 | 
				
			||||||
@ -19,28 +22,35 @@ jobs:
 | 
				
			|||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@v2
 | 
					      - uses: actions/checkout@v2
 | 
				
			||||||
      - uses: actions/setup-python@v2
 | 
					      - uses: actions/setup-python@v2
 | 
				
			||||||
 | 
					      - id: cache-poetry
 | 
				
			||||||
 | 
					        uses: actions/cache@v2.1.7
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          python-version: '3.9'
 | 
					          path: ~/.cache/pypoetry/virtualenvs
 | 
				
			||||||
      - id: cache-pipenv
 | 
					          key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }}
 | 
				
			||||||
        uses: actions/cache@v2.1.6
 | 
					 | 
				
			||||||
        with:
 | 
					 | 
				
			||||||
          path: ~/.local/share/virtualenvs
 | 
					 | 
				
			||||||
          key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
 | 
					 | 
				
			||||||
      - name: prepare
 | 
					      - name: prepare
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
 | 
					          INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }}
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          sudo apt-get update
 | 
					          sudo apt-get update
 | 
				
			||||||
          sudo apt-get install -y gettext
 | 
					          sudo apt-get install -y gettext
 | 
				
			||||||
          scripts/ci_prepare.sh
 | 
					          scripts/ci_prepare.sh
 | 
				
			||||||
      - name: run compile
 | 
					      - name: run compile
 | 
				
			||||||
        run: pipenv run ./manage.py compilemessages
 | 
					        run: poetry run ./manage.py compilemessages
 | 
				
			||||||
      - name: Create Pull Request
 | 
					      - name: Create Pull Request
 | 
				
			||||||
        uses: peter-evans/create-pull-request@v3
 | 
					        uses: peter-evans/create-pull-request@v3
 | 
				
			||||||
 | 
					        id: cpr
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          token: ${{ secrets.GITHUB_TOKEN }}
 | 
					          token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          branch: compile-backend-translation
 | 
					          branch: compile-backend-translation
 | 
				
			||||||
          commit-message: "core: compile backend translations"
 | 
					          commit-message: "core: compile backend translations"
 | 
				
			||||||
          title: "core: compile backend translations"
 | 
					          title: "core: compile backend translations"
 | 
				
			||||||
 | 
					          body: "core: compile backend translations"
 | 
				
			||||||
          delete-branch: true
 | 
					          delete-branch: true
 | 
				
			||||||
          signoff: true
 | 
					          signoff: true
 | 
				
			||||||
 | 
					      - name: Enable Pull Request Automerge
 | 
				
			||||||
 | 
					        if: steps.cpr.outputs.pull-request-operation == 'created'
 | 
				
			||||||
 | 
					        uses: peter-evans/enable-pull-request-automerge@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					          pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
 | 
				
			||||||
 | 
					          merge-method: squash
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										9
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -30,10 +30,19 @@ jobs:
 | 
				
			|||||||
          npm i @goauthentik/api@$VERSION
 | 
					          npm i @goauthentik/api@$VERSION
 | 
				
			||||||
      - name: Create Pull Request
 | 
					      - name: Create Pull Request
 | 
				
			||||||
        uses: peter-evans/create-pull-request@v3
 | 
					        uses: peter-evans/create-pull-request@v3
 | 
				
			||||||
 | 
					        id: cpr
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          token: ${{ secrets.GITHUB_TOKEN }}
 | 
					          token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          branch: update-web-api-client
 | 
					          branch: update-web-api-client
 | 
				
			||||||
          commit-message: "web: Update Web API Client version"
 | 
					          commit-message: "web: Update Web API Client version"
 | 
				
			||||||
          title: "web: Update Web API Client version"
 | 
					          title: "web: Update Web API Client version"
 | 
				
			||||||
 | 
					          body: "web: Update Web API Client version"
 | 
				
			||||||
          delete-branch: true
 | 
					          delete-branch: true
 | 
				
			||||||
          signoff: true
 | 
					          signoff: true
 | 
				
			||||||
 | 
					      - name: Enable Pull Request Automerge
 | 
				
			||||||
 | 
					        if: steps.cpr.outputs.pull-request-operation == 'created'
 | 
				
			||||||
 | 
					        uses: peter-evans/enable-pull-request-automerge@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					          pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
 | 
				
			||||||
 | 
					          merge-method: squash
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -66,7 +66,9 @@ coverage.xml
 | 
				
			|||||||
unittest.xml
 | 
					unittest.xml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Translations
 | 
					# Translations
 | 
				
			||||||
*.mo
 | 
					# Have to include binary mo files as they are annoying to compile at build time
 | 
				
			||||||
 | 
					# since a full postgres and redis instance are required
 | 
				
			||||||
 | 
					# *.mo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Django stuff:
 | 
					# Django stuff:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							@ -10,7 +10,9 @@
 | 
				
			|||||||
        "plex",
 | 
					        "plex",
 | 
				
			||||||
        "saml",
 | 
					        "saml",
 | 
				
			||||||
        "totp",
 | 
					        "totp",
 | 
				
			||||||
        "webauthn"
 | 
					        "webauthn",
 | 
				
			||||||
 | 
					        "traefik",
 | 
				
			||||||
 | 
					        "passwordless"
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    "python.linting.pylintEnabled": true,
 | 
					    "python.linting.pylintEnabled": true,
 | 
				
			||||||
    "todo-tree.tree.showCountsInTree": true,
 | 
					    "todo-tree.tree.showCountsInTree": true,
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										76
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										76
									
								
								Dockerfile
									
									
									
									
									
								
							@ -1,75 +1,68 @@
 | 
				
			|||||||
# Stage 1: Lock python dependencies
 | 
					# Stage 1: Build website
 | 
				
			||||||
FROM docker.io/python:3.9-bullseye as locker
 | 
					FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./Pipfile /app/
 | 
					COPY ./website /work/website/
 | 
				
			||||||
COPY ./Pipfile.lock /app/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
WORKDIR /app/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
RUN pip install pipenv==2021.5.29 && \
 | 
					 | 
				
			||||||
    pipenv lock -r > requirements.txt && \
 | 
					 | 
				
			||||||
    pipenv lock -r --dev-only > requirements-dev.txt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Stage 2: Build website
 | 
					 | 
				
			||||||
FROM docker.io/node:16 as website-builder
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
COPY ./website /static/
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
ENV NODE_ENV=production
 | 
					ENV NODE_ENV=production
 | 
				
			||||||
RUN cd /static && npm i && npm run build-docs-only
 | 
					RUN cd /work/website && npm i && npm run build-docs-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Stage 3: Build webui
 | 
					# Stage 2: Build webui
 | 
				
			||||||
FROM docker.io/node:16 as web-builder
 | 
					FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./web /static/
 | 
					COPY ./web /work/web/
 | 
				
			||||||
 | 
					COPY ./website /work/website/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ENV NODE_ENV=production
 | 
					ENV NODE_ENV=production
 | 
				
			||||||
RUN cd /static && npm i && npm run build
 | 
					RUN cd /work/web && npm i && npm run build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Stage 4: Build go proxy
 | 
					# Stage 3: Build go proxy
 | 
				
			||||||
FROM docker.io/golang:1.17.3-bullseye AS builder
 | 
					FROM docker.io/golang:1.17.6-bullseye AS builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
WORKDIR /work
 | 
					WORKDIR /work
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY --from=web-builder /static/robots.txt /work/web/robots.txt
 | 
					COPY --from=web-builder /work/web/robots.txt /work/web/robots.txt
 | 
				
			||||||
COPY --from=web-builder /static/security.txt /work/web/security.txt
 | 
					COPY --from=web-builder /work/web/security.txt /work/web/security.txt
 | 
				
			||||||
COPY --from=web-builder /static/dist/ /work/web/dist/
 | 
					 | 
				
			||||||
COPY --from=web-builder /static/authentik/ /work/web/authentik/
 | 
					 | 
				
			||||||
COPY --from=website-builder /static/help/ /work/website/help/
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./cmd /work/cmd
 | 
					COPY ./cmd /work/cmd
 | 
				
			||||||
COPY ./web/static.go /work/web/static.go
 | 
					COPY ./web/static.go /work/web/static.go
 | 
				
			||||||
COPY ./website/static.go /work/website/static.go
 | 
					 | 
				
			||||||
COPY ./internal /work/internal
 | 
					COPY ./internal /work/internal
 | 
				
			||||||
COPY ./go.mod /work/go.mod
 | 
					COPY ./go.mod /work/go.mod
 | 
				
			||||||
COPY ./go.sum /work/go.sum
 | 
					COPY ./go.sum /work/go.sum
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN go build -o /work/authentik ./cmd/server/main.go
 | 
					RUN go build -o /work/authentik ./cmd/server/main.go
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Stage 5: Run
 | 
					# Stage 4: Run
 | 
				
			||||||
FROM docker.io/python:3.9-bullseye
 | 
					FROM docker.io/python:3.10.2-slim-bullseye
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LABEL org.opencontainers.image.url https://goauthentik.io
 | 
				
			||||||
 | 
					LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
 | 
				
			||||||
 | 
					LABEL org.opencontainers.image.source https://github.com/goauthentik/authentik
 | 
				
			||||||
 | 
					
 | 
				
			||||||
WORKDIR /
 | 
					WORKDIR /
 | 
				
			||||||
COPY --from=locker /app/requirements.txt /
 | 
					 | 
				
			||||||
COPY --from=locker /app/requirements-dev.txt /
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
ARG GIT_BUILD_HASH
 | 
					ARG GIT_BUILD_HASH
 | 
				
			||||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
 | 
					ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY ./pyproject.toml /
 | 
				
			||||||
 | 
					COPY ./poetry.lock /
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN apt-get update && \
 | 
					RUN apt-get update && \
 | 
				
			||||||
    apt-get install -y --no-install-recommends curl ca-certificates gnupg git runit && \
 | 
					    apt-get install -y --no-install-recommends \
 | 
				
			||||||
    curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
 | 
					        curl ca-certificates gnupg git runit libpq-dev \
 | 
				
			||||||
    echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
 | 
					        postgresql-client build-essential libxmlsec1-dev \
 | 
				
			||||||
    apt-get update && \
 | 
					        pkg-config libmaxminddb0 && \
 | 
				
			||||||
    apt-get install -y --no-install-recommends libpq-dev postgresql-client build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
 | 
					    pip install poetry && \
 | 
				
			||||||
    pip install -r /requirements.txt --no-cache-dir && \
 | 
					    poetry config virtualenvs.create false && \
 | 
				
			||||||
 | 
					    poetry install --no-dev && \
 | 
				
			||||||
 | 
					    rm -rf ~/.cache/pypoetry && \
 | 
				
			||||||
    apt-get remove --purge -y build-essential git && \
 | 
					    apt-get remove --purge -y build-essential git && \
 | 
				
			||||||
    apt-get autoremove --purge -y && \
 | 
					    apt-get autoremove --purge -y && \
 | 
				
			||||||
    apt-get clean && \
 | 
					    apt-get clean && \
 | 
				
			||||||
    rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
 | 
					    rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
 | 
				
			||||||
    adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
 | 
					    adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
 | 
				
			||||||
    mkdir /backups && \
 | 
					    mkdir -p /backups /certs /media && \
 | 
				
			||||||
    chown authentik:authentik /backups
 | 
					    mkdir -p /authentik/.ssh && \
 | 
				
			||||||
 | 
					    chown authentik:authentik /backups /certs /media /authentik/.ssh
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./authentik/ /authentik
 | 
					COPY ./authentik/ /authentik
 | 
				
			||||||
COPY ./pyproject.toml /
 | 
					COPY ./pyproject.toml /
 | 
				
			||||||
@ -78,6 +71,9 @@ COPY ./tests /tests
 | 
				
			|||||||
COPY ./manage.py /
 | 
					COPY ./manage.py /
 | 
				
			||||||
COPY ./lifecycle/ /lifecycle
 | 
					COPY ./lifecycle/ /lifecycle
 | 
				
			||||||
COPY --from=builder /work/authentik /authentik-proxy
 | 
					COPY --from=builder /work/authentik /authentik-proxy
 | 
				
			||||||
 | 
					COPY --from=web-builder /work/web/dist/ /web/dist/
 | 
				
			||||||
 | 
					COPY --from=web-builder /work/web/authentik/ /web/authentik/
 | 
				
			||||||
 | 
					COPY --from=website-builder /work/website/help/ /website/help/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
USER authentik
 | 
					USER authentik
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										64
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								Makefile
									
									
									
									
									
								
							@ -4,16 +4,19 @@ UID = $(shell id -u)
 | 
				
			|||||||
GID = $(shell id -g)
 | 
					GID = $(shell id -g)
 | 
				
			||||||
NPM_VERSION = $(shell python -m scripts.npm_version)
 | 
					NPM_VERSION = $(shell python -m scripts.npm_version)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
all: lint-fix lint test gen
 | 
					all: lint-fix lint test gen web
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test-integration:
 | 
					test-integration:
 | 
				
			||||||
	coverage run manage.py test -v 3 tests/integration
 | 
						coverage run manage.py test tests/integration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test-e2e:
 | 
					test-e2e-provider:
 | 
				
			||||||
	coverage run manage.py test --failfast -v 3 tests/e2e
 | 
						coverage run manage.py test tests/e2e/test_provider*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test-e2e-rest:
 | 
				
			||||||
 | 
						coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test:
 | 
					test:
 | 
				
			||||||
	coverage run manage.py test -v 3 authentik
 | 
						coverage run manage.py test authentik
 | 
				
			||||||
	coverage html
 | 
						coverage html
 | 
				
			||||||
	coverage report
 | 
						coverage report
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -32,10 +35,12 @@ lint-fix:
 | 
				
			|||||||
lint:
 | 
					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
 | 
				
			||||||
 | 
						golangci-lint run -v
 | 
				
			||||||
 | 
					
 | 
				
			||||||
i18n-extract:
 | 
					i18n-extract: i18n-extract-core web-extract
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					i18n-extract-core:
 | 
				
			||||||
	./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
 | 
						./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
 | 
				
			||||||
	cd web && npm run extract
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
gen-build:
 | 
					gen-build:
 | 
				
			||||||
	./manage.py spectacular --file schema.yml
 | 
						./manage.py spectacular --file schema.yml
 | 
				
			||||||
@ -48,7 +53,7 @@ gen-web:
 | 
				
			|||||||
	docker run \
 | 
						docker run \
 | 
				
			||||||
		--rm -v ${PWD}:/local \
 | 
							--rm -v ${PWD}:/local \
 | 
				
			||||||
		--user ${UID}:${GID} \
 | 
							--user ${UID}:${GID} \
 | 
				
			||||||
		ghcr.io/beryju/openapi-generator generate \
 | 
							openapitools/openapi-generator-cli generate \
 | 
				
			||||||
		-i /local/schema.yml \
 | 
							-i /local/schema.yml \
 | 
				
			||||||
		-g typescript-fetch \
 | 
							-g typescript-fetch \
 | 
				
			||||||
		-o /local/web-api \
 | 
							-o /local/web-api \
 | 
				
			||||||
@ -67,12 +72,13 @@ gen-outpost:
 | 
				
			|||||||
	docker run \
 | 
						docker run \
 | 
				
			||||||
		--rm -v ${PWD}:/local \
 | 
							--rm -v ${PWD}:/local \
 | 
				
			||||||
		--user ${UID}:${GID} \
 | 
							--user ${UID}:${GID} \
 | 
				
			||||||
		openapitools/openapi-generator-cli generate \
 | 
							openapitools/openapi-generator-cli:v5.2.1 generate \
 | 
				
			||||||
		-i /local/schema.yml \
 | 
							-i /local/schema.yml \
 | 
				
			||||||
		-g go \
 | 
							-g go \
 | 
				
			||||||
		-o /local/api \
 | 
							-o /local/api \
 | 
				
			||||||
		-c /local/config.yaml
 | 
							-c /local/config.yaml
 | 
				
			||||||
	go mod edit -replace goauthentik.io/api=./api
 | 
						go mod edit -replace goauthentik.io/api=./api
 | 
				
			||||||
 | 
						rm -rf config.yaml ./templates/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gen: gen-build gen-clean gen-web
 | 
					gen: gen-build gen-clean gen-web
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -81,3 +87,43 @@ migrate:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
run:
 | 
					run:
 | 
				
			||||||
	go run -v cmd/server/main.go
 | 
						go run -v cmd/server/main.go
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					web-watch:
 | 
				
			||||||
 | 
						cd web && npm run watch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					web: web-lint-fix web-lint web-extract
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					web-lint-fix:
 | 
				
			||||||
 | 
						cd web && npm run prettier
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					web-lint:
 | 
				
			||||||
 | 
						cd web && npm run lint
 | 
				
			||||||
 | 
						cd web && npm run lit-analyse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					web-extract:
 | 
				
			||||||
 | 
						cd web && npm run extract
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# These targets are use by GitHub actions to allow usage of matrix
 | 
				
			||||||
 | 
					# which makes the YAML File a lot smaller
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ci--meta-debug:
 | 
				
			||||||
 | 
						python -V
 | 
				
			||||||
 | 
						node --version
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ci-pylint: ci--meta-debug
 | 
				
			||||||
 | 
						pylint authentik tests lifecycle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ci-black: ci--meta-debug
 | 
				
			||||||
 | 
						black --check authentik tests lifecycle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ci-isort: ci--meta-debug
 | 
				
			||||||
 | 
						isort --check authentik tests lifecycle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ci-bandit: ci--meta-debug
 | 
				
			||||||
 | 
						bandit -r authentik tests lifecycle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ci-pyright: ci--meta-debug
 | 
				
			||||||
 | 
						pyright e2e lifecycle
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ci-pending-migrations: ci--meta-debug
 | 
				
			||||||
 | 
						./manage.py makemigrations --check
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										64
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								Pipfile
									
									
									
									
									
								
							@ -1,64 +0,0 @@
 | 
				
			|||||||
[[source]]
 | 
					 | 
				
			||||||
name = "pypi"
 | 
					 | 
				
			||||||
url = "https://pypi.org/simple"
 | 
					 | 
				
			||||||
verify_ssl = true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[packages]
 | 
					 | 
				
			||||||
boto3 = "*"
 | 
					 | 
				
			||||||
celery = "*"
 | 
					 | 
				
			||||||
channels = "*"
 | 
					 | 
				
			||||||
channels-redis = "*"
 | 
					 | 
				
			||||||
dacite = "*"
 | 
					 | 
				
			||||||
defusedxml = "*"
 | 
					 | 
				
			||||||
django = "*"
 | 
					 | 
				
			||||||
django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' }
 | 
					 | 
				
			||||||
django-filter = "*"
 | 
					 | 
				
			||||||
django-guardian = "*"
 | 
					 | 
				
			||||||
django-model-utils = "*"
 | 
					 | 
				
			||||||
django-otp = "*"
 | 
					 | 
				
			||||||
django-prometheus = "*"
 | 
					 | 
				
			||||||
django-redis = "*"
 | 
					 | 
				
			||||||
django-storages = "*"
 | 
					 | 
				
			||||||
djangorestframework = "*"
 | 
					 | 
				
			||||||
djangorestframework-guardian = "*"
 | 
					 | 
				
			||||||
docker = "*"
 | 
					 | 
				
			||||||
drf-spectacular = "*"
 | 
					 | 
				
			||||||
facebook-sdk = "*"
 | 
					 | 
				
			||||||
geoip2 = "*"
 | 
					 | 
				
			||||||
gunicorn = "*"
 | 
					 | 
				
			||||||
kubernetes = "==v19.15.0"
 | 
					 | 
				
			||||||
ldap3 = "*"
 | 
					 | 
				
			||||||
lxml = "*"
 | 
					 | 
				
			||||||
packaging = "*"
 | 
					 | 
				
			||||||
psycopg2-binary = "*"
 | 
					 | 
				
			||||||
pycryptodome = "*"
 | 
					 | 
				
			||||||
pyjwt = "*"
 | 
					 | 
				
			||||||
pyyaml = "*"
 | 
					 | 
				
			||||||
requests-oauthlib = "*"
 | 
					 | 
				
			||||||
sentry-sdk = "*"
 | 
					 | 
				
			||||||
service_identity = "*"
 | 
					 | 
				
			||||||
structlog = "*"
 | 
					 | 
				
			||||||
swagger-spec-validator = "*"
 | 
					 | 
				
			||||||
twisted = "==21.7.0"
 | 
					 | 
				
			||||||
urllib3 = {extras = ["secure"],version = "*"}
 | 
					 | 
				
			||||||
uvicorn = {extras = ["standard"],version = "*"}
 | 
					 | 
				
			||||||
webauthn = "*"
 | 
					 | 
				
			||||||
xmlsec = "*"
 | 
					 | 
				
			||||||
duo-client = "*"
 | 
					 | 
				
			||||||
ua-parser = "*"
 | 
					 | 
				
			||||||
deepmerge = "*"
 | 
					 | 
				
			||||||
colorama = "*"
 | 
					 | 
				
			||||||
codespell = "*"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[dev-packages]
 | 
					 | 
				
			||||||
bandit = "*"
 | 
					 | 
				
			||||||
black = "==21.9b0"
 | 
					 | 
				
			||||||
bump2version = "*"
 | 
					 | 
				
			||||||
colorama = "*"
 | 
					 | 
				
			||||||
coverage = {extras = ["toml"],version = "*"}
 | 
					 | 
				
			||||||
pylint = "*"
 | 
					 | 
				
			||||||
pylint-django = "*"
 | 
					 | 
				
			||||||
pytest = "*"
 | 
					 | 
				
			||||||
pytest-django = "*"
 | 
					 | 
				
			||||||
selenium = "*"
 | 
					 | 
				
			||||||
requests-mock = "*"
 | 
					 | 
				
			||||||
							
								
								
									
										2351
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2351
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										20
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								README.md
									
									
									
									
									
								
							@ -38,3 +38,23 @@ See [Development Documentation](https://goauthentik.io/developer-docs/?utm_sourc
 | 
				
			|||||||
## Security
 | 
					## Security
 | 
				
			||||||
 | 
					
 | 
				
			||||||
See [SECURITY.md](SECURITY.md)
 | 
					See [SECURITY.md](SECURITY.md)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Sponsors
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					This project is proudly sponsored by:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<p>
 | 
				
			||||||
 | 
					    <a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=goauthentik.io">
 | 
				
			||||||
 | 
					        <img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DigitalOcean provides development and testing resources for authentik.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<p>
 | 
				
			||||||
 | 
					    <a href="https://www.netlify.com">
 | 
				
			||||||
 | 
					        <img src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg" alt="Deploys by Netlify" />
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Netlify hosts the [goauthentik.io](goauthentik.io) site.
 | 
				
			||||||
 | 
				
			|||||||
@ -6,8 +6,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
| Version    | Supported          |
 | 
					| Version    | Supported          |
 | 
				
			||||||
| ---------- | ------------------ |
 | 
					| ---------- | ------------------ |
 | 
				
			||||||
| 2021.8.x   | :white_check_mark: |
 | 
					| 2021.10.x  | :white_check_mark: |
 | 
				
			||||||
| 2021.9.x   | :white_check_mark: |
 | 
					| 2021.12.x  | :white_check_mark: |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Reporting a Vulnerability
 | 
					## Reporting a Vulnerability
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,19 @@
 | 
				
			|||||||
"""authentik"""
 | 
					"""authentik"""
 | 
				
			||||||
__version__ = "2021.10.3"
 | 
					from os import environ
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__version__ = "2022.1.1"
 | 
				
			||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
					ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_build_hash(fallback: Optional[str] = None) -> str:
 | 
				
			||||||
 | 
					    """Get build hash"""
 | 
				
			||||||
 | 
					    return environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_full_version() -> str:
 | 
				
			||||||
 | 
					    """Get full version, with build hash appended"""
 | 
				
			||||||
 | 
					    version = __version__
 | 
				
			||||||
 | 
					    if (build_hash := get_build_hash()) != "":
 | 
				
			||||||
 | 
					        version += "." + build_hash
 | 
				
			||||||
 | 
					    return version
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,6 @@
 | 
				
			|||||||
"""authentik administration metrics"""
 | 
					"""authentik administration metrics"""
 | 
				
			||||||
import time
 | 
					 | 
				
			||||||
from collections import Counter
 | 
					 | 
				
			||||||
from datetime import timedelta
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.db.models import Count, ExpressionWrapper, F
 | 
					 | 
				
			||||||
from django.db.models.fields import DurationField
 | 
					 | 
				
			||||||
from django.db.models.functions import ExtractHour
 | 
					 | 
				
			||||||
from django.utils.timezone import now
 | 
					 | 
				
			||||||
from drf_spectacular.utils import extend_schema, extend_schema_field
 | 
					from drf_spectacular.utils import extend_schema, extend_schema_field
 | 
				
			||||||
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
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
 | 
				
			||||||
@ -15,31 +8,7 @@ from rest_framework.response import Response
 | 
				
			|||||||
from rest_framework.views import APIView
 | 
					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 EventAction
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_events_per_1h(**filter_kwargs) -> list[dict[str, int]]:
 | 
					 | 
				
			||||||
    """Get event count by hour in the last day, fill with zeros"""
 | 
					 | 
				
			||||||
    date_from = now() - timedelta(days=1)
 | 
					 | 
				
			||||||
    result = (
 | 
					 | 
				
			||||||
        Event.objects.filter(created__gte=date_from, **filter_kwargs)
 | 
					 | 
				
			||||||
        .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
 | 
					 | 
				
			||||||
        .annotate(age_hours=ExtractHour("age"))
 | 
					 | 
				
			||||||
        .values("age_hours")
 | 
					 | 
				
			||||||
        .annotate(count=Count("pk"))
 | 
					 | 
				
			||||||
        .order_by("age_hours")
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    data = Counter({int(d["age_hours"]): d["count"] for d in result})
 | 
					 | 
				
			||||||
    results = []
 | 
					 | 
				
			||||||
    _now = now()
 | 
					 | 
				
			||||||
    for hour in range(0, -24, -1):
 | 
					 | 
				
			||||||
        results.append(
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
 | 
					 | 
				
			||||||
                "y_cord": data[hour * -1],
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    return results
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CoordinateSerializer(PassiveSerializer):
 | 
					class CoordinateSerializer(PassiveSerializer):
 | 
				
			||||||
@ -58,12 +27,22 @@ class LoginMetricsSerializer(PassiveSerializer):
 | 
				
			|||||||
    @extend_schema_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)
 | 
					        user = self.context["user"]
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            get_objects_for_user(user, "authentik_events.view_event")
 | 
				
			||||||
 | 
					            .filter(action=EventAction.LOGIN)
 | 
				
			||||||
 | 
					            .get_events_per_hour()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @extend_schema_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)
 | 
					        user = self.context["user"]
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            get_objects_for_user(user, "authentik_events.view_event")
 | 
				
			||||||
 | 
					            .filter(action=EventAction.LOGIN_FAILED)
 | 
				
			||||||
 | 
					            .get_events_per_hour()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AdministrationMetricsViewSet(APIView):
 | 
					class AdministrationMetricsViewSet(APIView):
 | 
				
			||||||
@ -75,4 +54,5 @@ class AdministrationMetricsViewSet(APIView):
 | 
				
			|||||||
    def get(self, request: Request) -> Response:
 | 
					    def get(self, request: Request) -> Response:
 | 
				
			||||||
        """Login Metrics per 1h"""
 | 
					        """Login Metrics per 1h"""
 | 
				
			||||||
        serializer = LoginMetricsSerializer(True)
 | 
					        serializer = LoginMetricsSerializer(True)
 | 
				
			||||||
 | 
					        serializer.context["user"] = request.user
 | 
				
			||||||
        return Response(serializer.data)
 | 
					        return Response(serializer.data)
 | 
				
			||||||
 | 
				
			|||||||
@ -86,7 +86,7 @@ class SystemSerializer(PassiveSerializer):
 | 
				
			|||||||
    def get_embedded_outpost_host(self, request: Request) -> str:
 | 
					    def get_embedded_outpost_host(self, request: Request) -> str:
 | 
				
			||||||
        """Get the FQDN configured on the embedded outpost"""
 | 
					        """Get the FQDN configured on the embedded outpost"""
 | 
				
			||||||
        outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
 | 
					        outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
 | 
				
			||||||
        if not outposts.exists():
 | 
					        if not outposts.exists():  # pragma: no cover
 | 
				
			||||||
            return ""
 | 
					            return ""
 | 
				
			||||||
        return outposts.first().config.authentik_host
 | 
					        return outposts.first().config.authentik_host
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -36,7 +36,7 @@ class TaskSerializer(PassiveSerializer):
 | 
				
			|||||||
        are pickled in cache. In that case, just delete the info"""
 | 
					        are pickled in cache. In that case, just delete the info"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            return super().to_representation(instance)
 | 
					            return super().to_representation(instance)
 | 
				
			||||||
        except AttributeError:
 | 
					        except AttributeError:  # pragma: no cover
 | 
				
			||||||
            if isinstance(self.instance, list):
 | 
					            if isinstance(self.instance, list):
 | 
				
			||||||
                for inst in self.instance:
 | 
					                for inst in self.instance:
 | 
				
			||||||
                    inst.delete()
 | 
					                    inst.delete()
 | 
				
			||||||
@ -95,7 +95,7 @@ class TaskViewSet(ViewSet):
 | 
				
			|||||||
                _("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}),
 | 
					                _("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            return Response(status=204)
 | 
					            return Response(status=204)
 | 
				
			||||||
        except ImportError:  # pragma: no cover
 | 
					        except (ImportError, AttributeError):  # pragma: no cover
 | 
				
			||||||
            # if we get an import error, the module path has probably changed
 | 
					            # if we get an import error, the module path has probably changed
 | 
				
			||||||
            task.delete()
 | 
					            task.delete()
 | 
				
			||||||
            return Response(status=500)
 | 
					            return Response(status=500)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,4 @@
 | 
				
			|||||||
"""authentik administration overview"""
 | 
					"""authentik administration overview"""
 | 
				
			||||||
from os import environ
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from drf_spectacular.utils import extend_schema
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from packaging.version import parse
 | 
					from packaging.version import parse
 | 
				
			||||||
@ -10,7 +8,7 @@ from rest_framework.request import Request
 | 
				
			|||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.views import APIView
 | 
					from rest_framework.views import APIView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik import ENV_GIT_HASH_KEY, __version__
 | 
					from authentik import __version__, get_build_hash
 | 
				
			||||||
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
 | 
					from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -25,7 +23,7 @@ class VersionSerializer(PassiveSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def get_build_hash(self, _) -> str:
 | 
					    def get_build_hash(self, _) -> str:
 | 
				
			||||||
        """Get build hash, if version is not latest or released"""
 | 
					        """Get build hash, if version is not latest or released"""
 | 
				
			||||||
        return environ.get(ENV_GIT_HASH_KEY, "")
 | 
					        return get_build_hash()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_version_current(self, _) -> str:
 | 
					    def get_version_current(self, _) -> str:
 | 
				
			||||||
        """Get current version"""
 | 
					        """Get current version"""
 | 
				
			||||||
 | 
				
			|||||||
@ -23,6 +23,6 @@ class WorkerView(APIView):
 | 
				
			|||||||
        """Get currently connected worker count."""
 | 
					        """Get currently connected worker count."""
 | 
				
			||||||
        count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
					        count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
				
			||||||
        # In debug we run with `CELERY_TASK_ALWAYS_EAGER`, so tasks are ran on the main process
 | 
					        # In debug we run with `CELERY_TASK_ALWAYS_EAGER`, so tasks are ran on the main process
 | 
				
			||||||
        if settings.DEBUG:
 | 
					        if settings.DEBUG:  # pragma: no cover
 | 
				
			||||||
            count += 1
 | 
					            count += 1
 | 
				
			||||||
        return Response({"count": count})
 | 
					        return Response({"count": count})
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,6 @@
 | 
				
			|||||||
"""authentik admin app config"""
 | 
					"""authentik admin app config"""
 | 
				
			||||||
 | 
					from importlib import import_module
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.apps import AppConfig
 | 
					from django.apps import AppConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -13,3 +15,4 @@ class AuthentikAdminConfig(AppConfig):
 | 
				
			|||||||
        from authentik.admin.tasks import clear_update_notifications
 | 
					        from authentik.admin.tasks import clear_update_notifications
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        clear_update_notifications.delay()
 | 
					        clear_update_notifications.delay()
 | 
				
			||||||
 | 
					        import_module("authentik.admin.signals")
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										23
									
								
								authentik/admin/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/admin/signals.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
				
			|||||||
 | 
					"""admin signals"""
 | 
				
			||||||
 | 
					from django.dispatch import receiver
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.admin.api.tasks import TaskInfo
 | 
				
			||||||
 | 
					from authentik.admin.api.workers import GAUGE_WORKERS
 | 
				
			||||||
 | 
					from authentik.root.celery import CELERY_APP
 | 
				
			||||||
 | 
					from authentik.root.monitoring import monitoring_set
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(monitoring_set)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def monitoring_set_workers(sender, **kwargs):
 | 
				
			||||||
 | 
					    """Set worker gauge"""
 | 
				
			||||||
 | 
					    count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
				
			||||||
 | 
					    GAUGE_WORKERS.set(count)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(monitoring_set)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def monitoring_set_tasks(sender, **kwargs):
 | 
				
			||||||
 | 
					    """Set task gauges"""
 | 
				
			||||||
 | 
					    for task in TaskInfo.all().values():
 | 
				
			||||||
 | 
					        task.set_prom_metrics()
 | 
				
			||||||
@ -1,6 +1,5 @@
 | 
				
			|||||||
"""authentik admin tasks"""
 | 
					"""authentik admin tasks"""
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
from os import environ
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.core.validators import URLValidator
 | 
					from django.core.validators import URLValidator
 | 
				
			||||||
@ -9,7 +8,7 @@ from prometheus_client import Info
 | 
				
			|||||||
from requests import RequestException
 | 
					from requests import RequestException
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik import ENV_GIT_HASH_KEY, __version__
 | 
					from authentik import __version__, get_build_hash
 | 
				
			||||||
from authentik.events.models import Event, EventAction, Notification
 | 
					from authentik.events.models import Event, EventAction, Notification
 | 
				
			||||||
from authentik.events.monitored_tasks import (
 | 
					from authentik.events.monitored_tasks import (
 | 
				
			||||||
    MonitoredTask,
 | 
					    MonitoredTask,
 | 
				
			||||||
@ -36,7 +35,7 @@ def _set_prom_info():
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
            "version": __version__,
 | 
					            "version": __version__,
 | 
				
			||||||
            "latest": cache.get(VERSION_CACHE_KEY, ""),
 | 
					            "latest": cache.get(VERSION_CACHE_KEY, ""),
 | 
				
			||||||
            "build_hash": environ.get(ENV_GIT_HASH_KEY, ""),
 | 
					            "build_hash": get_build_hash(),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -54,7 +53,7 @@ def clear_update_notifications():
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
					@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
				
			||||||
@prefill_task()
 | 
					@prefill_task
 | 
				
			||||||
def update_latest_version(self: MonitoredTask):
 | 
					def update_latest_version(self: MonitoredTask):
 | 
				
			||||||
    """Update latest version info"""
 | 
					    """Update latest version info"""
 | 
				
			||||||
    if CONFIG.y_bool("disable_update_check"):
 | 
					    if CONFIG.y_bool("disable_update_check"):
 | 
				
			||||||
 | 
				
			|||||||
@ -8,6 +8,7 @@ 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
 | 
					from authentik.events.monitored_tasks import TaskResultStatus
 | 
				
			||||||
 | 
					from authentik.managed.tasks import managed_reconcile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestAdminAPI(TestCase):
 | 
					class TestAdminAPI(TestCase):
 | 
				
			||||||
@ -94,5 +95,7 @@ class TestAdminAPI(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_system(self):
 | 
					    def test_system(self):
 | 
				
			||||||
        """Test system API"""
 | 
					        """Test system API"""
 | 
				
			||||||
 | 
					        # pyright: reportGeneralTypeIssues=false
 | 
				
			||||||
 | 
					        managed_reconcile()  # pylint: disable=no-value-for-parameter
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:admin_system"))
 | 
					        response = self.client.get(reverse("authentik_api:admin_system"))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
				
			|||||||
@ -3,8 +3,13 @@ from django.core.cache import cache
 | 
				
			|||||||
from django.test import TestCase
 | 
					from django.test import TestCase
 | 
				
			||||||
from requests_mock import Mocker
 | 
					from requests_mock import Mocker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
 | 
					from authentik.admin.tasks import (
 | 
				
			||||||
 | 
					    VERSION_CACHE_KEY,
 | 
				
			||||||
 | 
					    clear_update_notifications,
 | 
				
			||||||
 | 
					    update_latest_version,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RESPONSE_VALID = {
 | 
					RESPONSE_VALID = {
 | 
				
			||||||
    "$schema": "https://version.goauthentik.io/schema.json",
 | 
					    "$schema": "https://version.goauthentik.io/schema.json",
 | 
				
			||||||
@ -56,3 +61,23 @@ class TestAdminTasks(TestCase):
 | 
				
			|||||||
                    action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
 | 
					                    action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
 | 
				
			||||||
                ).exists()
 | 
					                ).exists()
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_version_disabled(self):
 | 
				
			||||||
 | 
					        """Test Update checker while its disabled"""
 | 
				
			||||||
 | 
					        with CONFIG.patch("disable_update_check", True):
 | 
				
			||||||
 | 
					            update_latest_version.delay().get()
 | 
				
			||||||
 | 
					            self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_clear_update_notifications(self):
 | 
				
			||||||
 | 
					        """Test clear of previous notification"""
 | 
				
			||||||
 | 
					        Event.objects.create(
 | 
				
			||||||
 | 
					            action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
 | 
				
			||||||
 | 
					        Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
 | 
				
			||||||
 | 
					        clear_update_notifications()
 | 
				
			||||||
 | 
					        self.assertFalse(
 | 
				
			||||||
 | 
					            Event.objects.filter(
 | 
				
			||||||
 | 
					                action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"
 | 
				
			||||||
 | 
					            ).exists()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
"""API Authentication"""
 | 
					"""API Authentication"""
 | 
				
			||||||
from base64 import b64decode
 | 
					from base64 import b64decode
 | 
				
			||||||
from binascii import Error
 | 
					from binascii import Error
 | 
				
			||||||
from typing import Any, Optional, Union
 | 
					from typing import Any, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
 | 
					from rest_framework.authentication import BaseAuthentication, get_authorization_header
 | 
				
			||||||
@ -69,7 +69,7 @@ def token_secret_key(value: str) -> Optional[User]:
 | 
				
			|||||||
class TokenAuthentication(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) -> tuple[User, Any] | None:
 | 
				
			||||||
        """Token-based authentication using HTTP Bearer authentication"""
 | 
					        """Token-based authentication using HTTP Bearer authentication"""
 | 
				
			||||||
        auth = get_authorization_header(request)
 | 
					        auth = get_authorization_header(request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -30,7 +30,7 @@ function getCookie(name) {
 | 
				
			|||||||
window.addEventListener('DOMContentLoaded', (event) => {
 | 
					window.addEventListener('DOMContentLoaded', (event) => {
 | 
				
			||||||
    const rapidocEl = document.querySelector('rapi-doc');
 | 
					    const rapidocEl = document.querySelector('rapi-doc');
 | 
				
			||||||
    rapidocEl.addEventListener('before-try', (e) => {
 | 
					    rapidocEl.addEventListener('before-try', (e) => {
 | 
				
			||||||
        e.detail.request.headers.append('X-CSRFToken', getCookie("authentik_csrf"));
 | 
					        e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf"));
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,18 +0,0 @@
 | 
				
			|||||||
"""Throttling classes"""
 | 
					 | 
				
			||||||
from typing import Type
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.views import View
 | 
					 | 
				
			||||||
from rest_framework.request import Request
 | 
					 | 
				
			||||||
from rest_framework.throttling import ScopedRateThrottle
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class SessionThrottle(ScopedRateThrottle):
 | 
					 | 
				
			||||||
    """Throttle based on session key"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def allow_request(self, request: Request, view):
 | 
					 | 
				
			||||||
        if request._request.user.is_superuser:
 | 
					 | 
				
			||||||
            return True
 | 
					 | 
				
			||||||
        return super().allow_request(request, view)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_cache_key(self, request: Request, view: Type[View]) -> str:
 | 
					 | 
				
			||||||
        return f"authentik-throttle-session-{request._request.session.session_key}"
 | 
					 | 
				
			||||||
@ -4,7 +4,5 @@ from django.urls import include, path
 | 
				
			|||||||
from authentik.api.v3.urls import urlpatterns as v3_urls
 | 
					from authentik.api.v3.urls import urlpatterns as v3_urls
 | 
				
			||||||
 | 
					
 | 
				
			||||||
urlpatterns = [
 | 
					urlpatterns = [
 | 
				
			||||||
    # TODO: Remove in 2022.1
 | 
					 | 
				
			||||||
    path("v2beta/", include(v3_urls)),
 | 
					 | 
				
			||||||
    path("v3/", include(v3_urls)),
 | 
					    path("v3/", include(v3_urls)),
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,14 @@ from django.conf import settings
 | 
				
			|||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from drf_spectacular.utils import extend_schema
 | 
					from drf_spectacular.utils import extend_schema
 | 
				
			||||||
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
 | 
					from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
 | 
				
			||||||
from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField, ListField
 | 
					from rest_framework.fields import (
 | 
				
			||||||
 | 
					    BooleanField,
 | 
				
			||||||
 | 
					    CharField,
 | 
				
			||||||
 | 
					    ChoiceField,
 | 
				
			||||||
 | 
					    FloatField,
 | 
				
			||||||
 | 
					    IntegerField,
 | 
				
			||||||
 | 
					    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
 | 
				
			||||||
@ -24,13 +31,19 @@ class Capabilities(models.TextChoices):
 | 
				
			|||||||
    CAN_BACKUP = "can_backup"
 | 
					    CAN_BACKUP = "can_backup"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ErrorReportingConfigSerializer(PassiveSerializer):
 | 
				
			||||||
 | 
					    """Config for error reporting"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    enabled = BooleanField(read_only=True)
 | 
				
			||||||
 | 
					    environment = CharField(read_only=True)
 | 
				
			||||||
 | 
					    send_pii = BooleanField(read_only=True)
 | 
				
			||||||
 | 
					    traces_sample_rate = FloatField(read_only=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ConfigSerializer(PassiveSerializer):
 | 
					class ConfigSerializer(PassiveSerializer):
 | 
				
			||||||
    """Serialize authentik Config into DRF Object"""
 | 
					    """Serialize authentik Config into DRF Object"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    error_reporting_enabled = BooleanField(read_only=True)
 | 
					    error_reporting = ErrorReportingConfigSerializer(required=True)
 | 
				
			||||||
    error_reporting_environment = CharField(read_only=True)
 | 
					 | 
				
			||||||
    error_reporting_send_pii = BooleanField(read_only=True)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
 | 
					    capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    cache_timeout = IntegerField(required=True)
 | 
					    cache_timeout = IntegerField(required=True)
 | 
				
			||||||
@ -66,9 +79,12 @@ class ConfigView(APIView):
 | 
				
			|||||||
        """Retrieve public configuration options"""
 | 
					        """Retrieve public configuration options"""
 | 
				
			||||||
        config = ConfigSerializer(
 | 
					        config = ConfigSerializer(
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
 | 
					                "error_reporting": {
 | 
				
			||||||
                "error_reporting_environment": CONFIG.y("error_reporting.environment"),
 | 
					                    "enabled": CONFIG.y("error_reporting.enabled") and not settings.DEBUG,
 | 
				
			||||||
                "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"),
 | 
					                    "environment": CONFIG.y("error_reporting.environment"),
 | 
				
			||||||
 | 
					                    "send_pii": CONFIG.y("error_reporting.send_pii"),
 | 
				
			||||||
 | 
					                    "traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)),
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
                "capabilities": self.get_capabilities(),
 | 
					                "capabilities": self.get_capabilities(),
 | 
				
			||||||
                "cache_timeout": int(CONFIG.y("redis.cache_timeout")),
 | 
					                "cache_timeout": int(CONFIG.y("redis.cache_timeout")),
 | 
				
			||||||
                "cache_timeout_flows": int(CONFIG.y("redis.cache_timeout_flows")),
 | 
					                "cache_timeout_flows": int(CONFIG.y("redis.cache_timeout_flows")),
 | 
				
			||||||
 | 
				
			|||||||
@ -46,11 +46,7 @@ from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
 | 
				
			|||||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
 | 
					from authentik.policies.expression.api import ExpressionPolicyViewSet
 | 
				
			||||||
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
 | 
					from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
 | 
				
			||||||
from authentik.policies.password.api import PasswordPolicyViewSet
 | 
					from authentik.policies.password.api import PasswordPolicyViewSet
 | 
				
			||||||
from authentik.policies.reputation.api import (
 | 
					from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
 | 
				
			||||||
    IPReputationViewSet,
 | 
					 | 
				
			||||||
    ReputationPolicyViewSet,
 | 
					 | 
				
			||||||
    UserReputationViewSet,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
 | 
					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
 | 
				
			||||||
@ -151,8 +147,7 @@ router.register("policies/event_matcher", EventMatcherPolicyViewSet)
 | 
				
			|||||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
 | 
					router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
 | 
				
			||||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
 | 
					router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
 | 
				
			||||||
router.register("policies/password", PasswordPolicyViewSet)
 | 
					router.register("policies/password", PasswordPolicyViewSet)
 | 
				
			||||||
router.register("policies/reputation/users", UserReputationViewSet)
 | 
					router.register("policies/reputation/scores", ReputationViewSet)
 | 
				
			||||||
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)
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,7 @@ from django.http.response import HttpResponseBadRequest
 | 
				
			|||||||
from django.shortcuts import get_object_or_404
 | 
					from django.shortcuts import get_object_or_404
 | 
				
			||||||
from drf_spectacular.types import OpenApiTypes
 | 
					from drf_spectacular.types import OpenApiTypes
 | 
				
			||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
 | 
					from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
 | 
				
			||||||
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
from rest_framework.decorators import action
 | 
					from rest_framework.decorators import action
 | 
				
			||||||
from rest_framework.fields import ReadOnlyField
 | 
					from rest_framework.fields import ReadOnlyField
 | 
				
			||||||
from rest_framework.parsers import MultiPartParser
 | 
					from rest_framework.parsers import MultiPartParser
 | 
				
			||||||
@ -15,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
 | 
				
			|||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
					from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
 | 
					from authentik.admin.api.metrics import CoordinateSerializer
 | 
				
			||||||
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.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
@ -239,8 +240,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
        """Metrics for application logins"""
 | 
					        """Metrics for application logins"""
 | 
				
			||||||
        app = self.get_object()
 | 
					        app = self.get_object()
 | 
				
			||||||
        return Response(
 | 
					        return Response(
 | 
				
			||||||
            get_events_per_1h(
 | 
					            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
				
			||||||
 | 
					            .filter(
 | 
				
			||||||
                action=EventAction.AUTHORIZE_APPLICATION,
 | 
					                action=EventAction.AUTHORIZE_APPLICATION,
 | 
				
			||||||
                context__authorized_application__pk=app.pk.hex,
 | 
					                context__authorized_application__pk=app.pk.hex,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					            .get_events_per_hour()
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,11 @@
 | 
				
			|||||||
"""Groups API Viewset"""
 | 
					"""Groups API Viewset"""
 | 
				
			||||||
 | 
					from json import loads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.db.models.query import QuerySet
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
from django_filters.filters import ModelMultipleChoiceFilter
 | 
					from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
 | 
				
			||||||
from django_filters.filterset import FilterSet
 | 
					from django_filters.filterset import FilterSet
 | 
				
			||||||
from rest_framework.fields import CharField, JSONField
 | 
					from rest_framework.fields import CharField, JSONField
 | 
				
			||||||
from rest_framework.serializers import ListSerializer, ModelSerializer
 | 
					from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
					from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -62,6 +64,13 @@ class GroupSerializer(ModelSerializer):
 | 
				
			|||||||
class GroupFilter(FilterSet):
 | 
					class GroupFilter(FilterSet):
 | 
				
			||||||
    """Filter for groups"""
 | 
					    """Filter for groups"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    attributes = CharFilter(
 | 
				
			||||||
 | 
					        field_name="attributes",
 | 
				
			||||||
 | 
					        lookup_expr="",
 | 
				
			||||||
 | 
					        label="Attributes",
 | 
				
			||||||
 | 
					        method="filter_attributes",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    members_by_username = ModelMultipleChoiceFilter(
 | 
					    members_by_username = ModelMultipleChoiceFilter(
 | 
				
			||||||
        field_name="users__username",
 | 
					        field_name="users__username",
 | 
				
			||||||
        to_field_name="username",
 | 
					        to_field_name="username",
 | 
				
			||||||
@ -72,10 +81,28 @@ class GroupFilter(FilterSet):
 | 
				
			|||||||
        queryset=User.objects.all(),
 | 
					        queryset=User.objects.all(),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # 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
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            _ = len(queryset.filter(**qs))
 | 
				
			||||||
 | 
					            return queryset.filter(**qs)
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        model = Group
 | 
					        model = Group
 | 
				
			||||||
        fields = ["name", "is_superuser", "members_by_pk", "members_by_username"]
 | 
					        fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class GroupViewSet(UsedByMixin, ModelViewSet):
 | 
					class GroupViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
 | 
				
			|||||||
@ -56,6 +56,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
 | 
				
			|||||||
            "component",
 | 
					            "component",
 | 
				
			||||||
            "verbose_name",
 | 
					            "verbose_name",
 | 
				
			||||||
            "verbose_name_plural",
 | 
					            "verbose_name_plural",
 | 
				
			||||||
 | 
					            "meta_model_name",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -43,6 +43,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
            "assigned_application_name",
 | 
					            "assigned_application_name",
 | 
				
			||||||
            "verbose_name",
 | 
					            "verbose_name",
 | 
				
			||||||
            "verbose_name_plural",
 | 
					            "verbose_name_plural",
 | 
				
			||||||
 | 
					            "meta_model_name",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -48,6 +48,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
            "component",
 | 
					            "component",
 | 
				
			||||||
            "verbose_name",
 | 
					            "verbose_name",
 | 
				
			||||||
            "verbose_name_plural",
 | 
					            "verbose_name_plural",
 | 
				
			||||||
 | 
					            "meta_model_name",
 | 
				
			||||||
            "policy_engine_mode",
 | 
					            "policy_engine_mode",
 | 
				
			||||||
            "user_matching_mode",
 | 
					            "user_matching_mode",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
@ -103,14 +104,14 @@ class SourceViewSet(
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        matching_sources: list[UserSettingSerializer] = []
 | 
					        matching_sources: list[UserSettingSerializer] = []
 | 
				
			||||||
        for source in _all_sources:
 | 
					        for source in _all_sources:
 | 
				
			||||||
            user_settings = source.ui_user_settings
 | 
					            user_settings = source.ui_user_settings()
 | 
				
			||||||
            if not user_settings:
 | 
					            if not user_settings:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            policy_engine = PolicyEngine(source, request.user, request)
 | 
					            policy_engine = PolicyEngine(source, request.user, request)
 | 
				
			||||||
            policy_engine.build()
 | 
					            policy_engine.build()
 | 
				
			||||||
            if not policy_engine.passing:
 | 
					            if not policy_engine.passing:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            source_settings = source.ui_user_settings
 | 
					            source_settings = source.ui_user_settings()
 | 
				
			||||||
            source_settings.initial_data["object_uid"] = source.slug
 | 
					            source_settings.initial_data["object_uid"] = source.slug
 | 
				
			||||||
            if not source_settings.is_valid():
 | 
					            if not source_settings.is_valid():
 | 
				
			||||||
                LOGGER.warning(source_settings.errors)
 | 
					                LOGGER.warning(source_settings.errors)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,6 @@
 | 
				
			|||||||
"""Tokens API Viewset"""
 | 
					"""Tokens API Viewset"""
 | 
				
			||||||
from typing import Any
 | 
					from typing import Any
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.http.response import Http404
 | 
					 | 
				
			||||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
					from django_filters.rest_framework import DjangoFilterBackend
 | 
				
			||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
					from drf_spectacular.utils import OpenApiResponse, extend_schema
 | 
				
			||||||
from guardian.shortcuts import get_anonymous_user
 | 
					from guardian.shortcuts import get_anonymous_user
 | 
				
			||||||
@ -114,7 +113,5 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
    def view_key(self, request: Request, identifier: str) -> Response:
 | 
					    def view_key(self, request: Request, identifier: str) -> Response:
 | 
				
			||||||
        """Return token key and log access"""
 | 
					        """Return token key and log access"""
 | 
				
			||||||
        token: Token = self.get_object()
 | 
					        token: Token = self.get_object()
 | 
				
			||||||
        if token.is_expired:
 | 
					 | 
				
			||||||
            raise Http404
 | 
					 | 
				
			||||||
        Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request)  # noqa # nosec
 | 
					        Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request)  # noqa # nosec
 | 
				
			||||||
        return Response(TokenViewSerializer({"key": token.key}).data)
 | 
					        return Response(TokenViewSerializer({"key": token.key}).data)
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,7 @@ from datetime import timedelta
 | 
				
			|||||||
from json import loads
 | 
					from json import loads
 | 
				
			||||||
from typing import Optional
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.contrib.auth import update_session_auth_hash
 | 
				
			||||||
from django.db.models.query import QuerySet
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
from django.db.transaction import atomic
 | 
					from django.db.transaction import atomic
 | 
				
			||||||
from django.db.utils import IntegrityError
 | 
					from django.db.utils import IntegrityError
 | 
				
			||||||
@ -38,7 +39,7 @@ from rest_framework.viewsets import ModelViewSet
 | 
				
			|||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
					from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
 | 
					from authentik.admin.api.metrics import CoordinateSerializer
 | 
				
			||||||
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.groups import GroupSerializer
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
@ -46,6 +47,7 @@ from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
 | 
				
			|||||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
 | 
					from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
 | 
				
			||||||
from authentik.core.models import (
 | 
					from authentik.core.models import (
 | 
				
			||||||
    USER_ATTRIBUTE_CHANGE_EMAIL,
 | 
					    USER_ATTRIBUTE_CHANGE_EMAIL,
 | 
				
			||||||
 | 
					    USER_ATTRIBUTE_CHANGE_NAME,
 | 
				
			||||||
    USER_ATTRIBUTE_CHANGE_USERNAME,
 | 
					    USER_ATTRIBUTE_CHANGE_USERNAME,
 | 
				
			||||||
    USER_ATTRIBUTE_SA,
 | 
					    USER_ATTRIBUTE_SA,
 | 
				
			||||||
    USER_ATTRIBUTE_TOKEN_EXPIRING,
 | 
					    USER_ATTRIBUTE_TOKEN_EXPIRING,
 | 
				
			||||||
@ -55,6 +57,7 @@ from authentik.core.models import (
 | 
				
			|||||||
    User,
 | 
					    User,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.events.models import EventAction
 | 
					from authentik.events.models import EventAction
 | 
				
			||||||
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
from authentik.stages.email.models import EmailStage
 | 
					from authentik.stages.email.models import EmailStage
 | 
				
			||||||
from authentik.stages.email.tasks import send_mails
 | 
					from authentik.stages.email.tasks import send_mails
 | 
				
			||||||
from authentik.stages.email.utils import TemplateEmailMessage
 | 
					from authentik.stages.email.utils import TemplateEmailMessage
 | 
				
			||||||
@ -125,20 +128,41 @@ class UserSelfSerializer(ModelSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def validate_email(self, email: str):
 | 
					    def validate_email(self, email: str):
 | 
				
			||||||
        """Check if the user is allowed to change their email"""
 | 
					        """Check if the user is allowed to change their email"""
 | 
				
			||||||
        if self.instance.group_attributes().get(USER_ATTRIBUTE_CHANGE_EMAIL, True):
 | 
					        if self.instance.group_attributes().get(
 | 
				
			||||||
 | 
					            USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
            return email
 | 
					            return email
 | 
				
			||||||
        if email != self.instance.email:
 | 
					        if email != self.instance.email:
 | 
				
			||||||
            raise ValidationError("Not allowed to change email.")
 | 
					            raise ValidationError("Not allowed to change email.")
 | 
				
			||||||
        return email
 | 
					        return email
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def validate_name(self, name: str):
 | 
				
			||||||
 | 
					        """Check if the user is allowed to change their name"""
 | 
				
			||||||
 | 
					        if self.instance.group_attributes().get(
 | 
				
			||||||
 | 
					            USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            return name
 | 
				
			||||||
 | 
					        if name != self.instance.name:
 | 
				
			||||||
 | 
					            raise ValidationError("Not allowed to change name.")
 | 
				
			||||||
 | 
					        return name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate_username(self, username: str):
 | 
					    def validate_username(self, username: str):
 | 
				
			||||||
        """Check if the user is allowed to change their username"""
 | 
					        """Check if the user is allowed to change their username"""
 | 
				
			||||||
        if self.instance.group_attributes().get(USER_ATTRIBUTE_CHANGE_USERNAME, True):
 | 
					        if self.instance.group_attributes().get(
 | 
				
			||||||
 | 
					            USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
            return username
 | 
					            return username
 | 
				
			||||||
        if username != self.instance.username:
 | 
					        if username != self.instance.username:
 | 
				
			||||||
            raise ValidationError("Not allowed to change username.")
 | 
					            raise ValidationError("Not allowed to change username.")
 | 
				
			||||||
        return username
 | 
					        return username
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def save(self, **kwargs):
 | 
				
			||||||
 | 
					        if self.instance:
 | 
				
			||||||
 | 
					            attributes: dict = self.instance.attributes
 | 
				
			||||||
 | 
					            attributes.update(self.validated_data.get("attributes", {}))
 | 
				
			||||||
 | 
					            self.validated_data["attributes"] = attributes
 | 
				
			||||||
 | 
					        return super().save(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        model = User
 | 
					        model = User
 | 
				
			||||||
@ -179,19 +203,31 @@ class UserMetricsSerializer(PassiveSerializer):
 | 
				
			|||||||
    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_objects_for_user(user, "authentik_events.view_event")
 | 
				
			||||||
 | 
					            .filter(action=EventAction.LOGIN, user__pk=user.pk)
 | 
				
			||||||
 | 
					            .get_events_per_hour()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @extend_schema_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"]
 | 
				
			||||||
        return get_events_per_1h(action=EventAction.LOGIN_FAILED, context__username=user.username)
 | 
					        return (
 | 
				
			||||||
 | 
					            get_objects_for_user(user, "authentik_events.view_event")
 | 
				
			||||||
 | 
					            .filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
 | 
				
			||||||
 | 
					            .get_events_per_hour()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @extend_schema_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"]
 | 
				
			||||||
        return get_events_per_1h(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
 | 
					        return (
 | 
				
			||||||
 | 
					            get_objects_for_user(user, "authentik_events.view_event")
 | 
				
			||||||
 | 
					            .filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
 | 
				
			||||||
 | 
					            .get_events_per_hour()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UsersFilter(FilterSet):
 | 
					class UsersFilter(FilterSet):
 | 
				
			||||||
@ -228,7 +264,11 @@ class UsersFilter(FilterSet):
 | 
				
			|||||||
        qs = {}
 | 
					        qs = {}
 | 
				
			||||||
        for key, _value in value.items():
 | 
					        for key, _value in value.items():
 | 
				
			||||||
            qs[f"attributes__{key}"] = _value
 | 
					            qs[f"attributes__{key}"] = _value
 | 
				
			||||||
        return queryset.filter(**qs)
 | 
					        try:
 | 
				
			||||||
 | 
					            _ = len(queryset.filter(**qs))
 | 
				
			||||||
 | 
					            return queryset.filter(**qs)
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            return queryset
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
        model = User
 | 
					        model = User
 | 
				
			||||||
@ -309,7 +349,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
                    name=username,
 | 
					                    name=username,
 | 
				
			||||||
                    attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
 | 
					                    attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                if create_group:
 | 
					                if create_group and self.request.user.has_perm("authentik_core.add_group"):
 | 
				
			||||||
                    group = Group.objects.create(
 | 
					                    group = Group.objects.create(
 | 
				
			||||||
                        name=username,
 | 
					                        name=username,
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
@ -338,6 +378,35 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
            ).data
 | 
					            ).data
 | 
				
			||||||
        return Response(serializer.initial_data)
 | 
					        return Response(serializer.initial_data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @permission_required("authentik_core.reset_user_password")
 | 
				
			||||||
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        request=inline_serializer(
 | 
				
			||||||
 | 
					            "UserPasswordSetSerializer",
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                "password": CharField(required=True),
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        responses={
 | 
				
			||||||
 | 
					            204: "",
 | 
				
			||||||
 | 
					            400: "",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @action(detail=True, methods=["POST"])
 | 
				
			||||||
 | 
					    # pylint: disable=invalid-name, unused-argument
 | 
				
			||||||
 | 
					    def set_password(self, request: Request, pk: int) -> Response:
 | 
				
			||||||
 | 
					        """Set password for user"""
 | 
				
			||||||
 | 
					        user: User = self.get_object()
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            user.set_password(request.data.get("password"))
 | 
				
			||||||
 | 
					            user.save()
 | 
				
			||||||
 | 
					        except (ValidationError, IntegrityError) as exc:
 | 
				
			||||||
 | 
					            LOGGER.debug("Failed to set password", exc=exc)
 | 
				
			||||||
 | 
					            return Response(status=400)
 | 
				
			||||||
 | 
					        if user.pk == request.user.pk and SESSION_IMPERSONATE_USER not in self.request.session:
 | 
				
			||||||
 | 
					            LOGGER.debug("Updating session hash after password change")
 | 
				
			||||||
 | 
					            update_session_auth_hash(self.request, user)
 | 
				
			||||||
 | 
					        return Response(status=204)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
 | 
					    @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
 | 
				
			||||||
    @action(
 | 
					    @action(
 | 
				
			||||||
        methods=["PUT"],
 | 
					        methods=["PUT"],
 | 
				
			||||||
 | 
				
			|||||||
@ -41,6 +41,7 @@ class MetaNameSerializer(PassiveSerializer):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    verbose_name = SerializerMethodField()
 | 
					    verbose_name = SerializerMethodField()
 | 
				
			||||||
    verbose_name_plural = SerializerMethodField()
 | 
					    verbose_name_plural = SerializerMethodField()
 | 
				
			||||||
 | 
					    meta_model_name = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_verbose_name(self, obj: Model) -> str:
 | 
					    def get_verbose_name(self, obj: Model) -> str:
 | 
				
			||||||
        """Return object's verbose_name"""
 | 
					        """Return object's verbose_name"""
 | 
				
			||||||
@ -50,6 +51,10 @@ class MetaNameSerializer(PassiveSerializer):
 | 
				
			|||||||
        """Return object's plural verbose_name"""
 | 
					        """Return object's plural verbose_name"""
 | 
				
			||||||
        return obj._meta.verbose_name_plural
 | 
					        return obj._meta.verbose_name_plural
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_meta_model_name(self, obj: Model) -> str:
 | 
				
			||||||
 | 
					        """Return internal model name"""
 | 
				
			||||||
 | 
					        return f"{obj._meta.app_label}.{obj._meta.model_name}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TypeCreateSerializer(PassiveSerializer):
 | 
					class TypeCreateSerializer(PassiveSerializer):
 | 
				
			||||||
    """Types of an object that can be created"""
 | 
					    """Types of an object that can be created"""
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +0,0 @@
 | 
				
			|||||||
"""Output full config"""
 | 
					 | 
				
			||||||
from json import dumps
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.core.management.base import BaseCommand, no_translations
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Command(BaseCommand):  # pragma: no cover
 | 
					 | 
				
			||||||
    """Output full config"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @no_translations
 | 
					 | 
				
			||||||
    def handle(self, *args, **options):
 | 
					 | 
				
			||||||
        """Check permissions for all apps"""
 | 
					 | 
				
			||||||
        print(dumps(CONFIG.raw, indent=4))
 | 
					 | 
				
			||||||
@ -5,6 +5,7 @@ from typing import Callable
 | 
				
			|||||||
from uuid import uuid4
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.http import HttpRequest, HttpResponse
 | 
					from django.http import HttpRequest, HttpResponse
 | 
				
			||||||
 | 
					from sentry_sdk.api import set_tag
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
 | 
					SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
 | 
				
			||||||
SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
 | 
					SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
 | 
				
			||||||
@ -12,7 +13,6 @@ LOCAL = local()
 | 
				
			|||||||
RESPONSE_HEADER_ID = "X-authentik-id"
 | 
					RESPONSE_HEADER_ID = "X-authentik-id"
 | 
				
			||||||
KEY_AUTH_VIA = "auth_via"
 | 
					KEY_AUTH_VIA = "auth_via"
 | 
				
			||||||
KEY_USER = "user"
 | 
					KEY_USER = "user"
 | 
				
			||||||
INTERNAL_HEADER_PREFIX = "X-authentik-internal-"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ImpersonateMiddleware:
 | 
					class ImpersonateMiddleware:
 | 
				
			||||||
@ -51,11 +51,12 @@ class RequestIDMiddleware:
 | 
				
			|||||||
                "request_id": request_id,
 | 
					                "request_id": request_id,
 | 
				
			||||||
                "host": request.get_host(),
 | 
					                "host": request.get_host(),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					            set_tag("authentik.request_id", request_id)
 | 
				
			||||||
        response = self.get_response(request)
 | 
					        response = self.get_response(request)
 | 
				
			||||||
        response[RESPONSE_HEADER_ID] = request.request_id
 | 
					        response[RESPONSE_HEADER_ID] = request.request_id
 | 
				
			||||||
        if auth_via := LOCAL.authentik.get(KEY_AUTH_VIA, None):
 | 
					        setattr(response, "ak_context", {})
 | 
				
			||||||
            response[INTERNAL_HEADER_PREFIX + KEY_AUTH_VIA] = auth_via
 | 
					        response.ak_context.update(LOCAL.authentik)
 | 
				
			||||||
        response[INTERNAL_HEADER_PREFIX + KEY_USER] = request.user.username
 | 
					        response.ak_context[KEY_USER] = request.user.username
 | 
				
			||||||
        for key in list(LOCAL.authentik.keys()):
 | 
					        for key in list(LOCAL.authentik.keys()):
 | 
				
			||||||
            del LOCAL.authentik[key]
 | 
					            del LOCAL.authentik[key]
 | 
				
			||||||
        return response
 | 
					        return response
 | 
				
			||||||
@ -66,4 +67,6 @@ def structlog_add_request_id(logger: Logger, method_name: str, event_dict: 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.update(LOCAL.authentik)
 | 
					        event_dict.update(LOCAL.authentik)
 | 
				
			||||||
 | 
					    if hasattr(LOCAL, "authentik_task"):
 | 
				
			||||||
 | 
					        event_dict.update(LOCAL.authentik_task)
 | 
				
			||||||
    return event_dict
 | 
					    return event_dict
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,6 @@
 | 
				
			|||||||
import uuid
 | 
					import uuid
 | 
				
			||||||
from os import environ
 | 
					from os import environ
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import django.core.validators
 | 
					 | 
				
			||||||
import django.db.models.deletion
 | 
					import django.db.models.deletion
 | 
				
			||||||
from django.apps.registry import Apps
 | 
					from django.apps.registry import Apps
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
@ -12,10 +11,10 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			|||||||
from django.db.models import Count
 | 
					from django.db.models import Count
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import authentik.core.models
 | 
					import authentik.core.models
 | 
				
			||||||
 | 
					import authentik.lib.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
					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.contrib.sessions.backends.cache import KEY_PREFIX
 | 
				
			||||||
    from django.core.cache import cache
 | 
					    from django.core.cache import cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -161,7 +160,7 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
            model_name="application",
 | 
					            model_name="application",
 | 
				
			||||||
            name="meta_launch_url",
 | 
					            name="meta_launch_url",
 | 
				
			||||||
            field=models.TextField(
 | 
					            field=models.TextField(
 | 
				
			||||||
                blank=True, default="", validators=[django.core.validators.URLValidator()]
 | 
					                blank=True, default="", validators=[authentik.lib.models.DomainlessURLValidator()]
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        migrations.RunPython(
 | 
					        migrations.RunPython(
 | 
				
			||||||
 | 
				
			|||||||
@ -12,7 +12,6 @@ import authentik.core.models
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
					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.contrib.sessions.backends.cache import KEY_PREFIX
 | 
				
			||||||
    from django.core.cache import cache
 | 
					    from django.core.cache import cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,9 @@
 | 
				
			|||||||
# Generated by Django 3.2.3 on 2021-06-02 21:51
 | 
					# Generated by Django 3.2.3 on 2021-06-02 21:51
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import django.core.validators
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import authentik.lib.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -17,7 +18,7 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
            field=models.TextField(
 | 
					            field=models.TextField(
 | 
				
			||||||
                blank=True,
 | 
					                blank=True,
 | 
				
			||||||
                default="",
 | 
					                default="",
 | 
				
			||||||
                validators=[django.core.validators.URLValidator()],
 | 
					                validators=[authentik.lib.models.DomainlessURLValidator()],
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,15 @@
 | 
				
			|||||||
"""authentik core models"""
 | 
					"""authentik core models"""
 | 
				
			||||||
from datetime import timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
from hashlib import md5, sha256
 | 
					from hashlib import md5, sha256
 | 
				
			||||||
from typing import Any, Optional, Type
 | 
					from typing import Any, Optional
 | 
				
			||||||
from urllib.parse import urlencode
 | 
					from urllib.parse import urlencode
 | 
				
			||||||
from uuid import uuid4
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from deepmerge import always_merger
 | 
					from deepmerge import always_merger
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.auth.hashers import check_password
 | 
				
			||||||
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, options
 | 
					from django.db.models import Q, QuerySet, options
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
@ -26,10 +26,9 @@ 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, UserSettingSerializer
 | 
					from authentik.core.types import UILoginButton, UserSettingSerializer
 | 
				
			||||||
from authentik.flows.models import Flow
 | 
					 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
 | 
					from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
 | 
				
			||||||
from authentik.lib.utils.http import get_client_ip
 | 
					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
 | 
				
			||||||
@ -40,6 +39,7 @@ USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
 | 
				
			|||||||
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
 | 
					USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
 | 
				
			||||||
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires"  # nosec
 | 
					USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires"  # nosec
 | 
				
			||||||
USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
 | 
					USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
 | 
				
			||||||
 | 
					USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
 | 
				
			||||||
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
 | 
					USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
 | 
				
			||||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
 | 
					USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -162,6 +162,22 @@ class User(GuardianUserMixin, AbstractUser):
 | 
				
			|||||||
        self.password_change_date = now()
 | 
					        self.password_change_date = now()
 | 
				
			||||||
        return super().set_password(password)
 | 
					        return super().set_password(password)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def check_password(self, raw_password: str) -> bool:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Return a boolean of whether the raw_password was correct. Handles
 | 
				
			||||||
 | 
					        hashing formats behind the scenes.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Slightly changed version which doesn't send a signal for such internal hash upgrades
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        def setter(raw_password):
 | 
				
			||||||
 | 
					            self.set_password(raw_password, signal=False)
 | 
				
			||||||
 | 
					            # Password hash upgrades shouldn't be considered password changes.
 | 
				
			||||||
 | 
					            self._password = None
 | 
				
			||||||
 | 
					            self.save(update_fields=["password"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return check_password(raw_password, self.password, setter)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def uid(self) -> str:
 | 
					    def uid(self) -> str:
 | 
				
			||||||
        """Generate a globall unique UID, based on the user ID and the hashed secret key"""
 | 
					        """Generate a globall unique UID, based on the user ID and the hashed secret key"""
 | 
				
			||||||
@ -174,7 +190,7 @@ class User(GuardianUserMixin, AbstractUser):
 | 
				
			|||||||
        if mode == "none":
 | 
					        if mode == "none":
 | 
				
			||||||
            return DEFAULT_AVATAR
 | 
					            return DEFAULT_AVATAR
 | 
				
			||||||
        # gravatar uses md5 for their URLs, so md5 can't be avoided
 | 
					        # gravatar uses md5 for their URLs, so md5 can't be avoided
 | 
				
			||||||
        mail_hash = md5(self.email.encode("utf-8")).hexdigest()  # nosec
 | 
					        mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest()  # nosec
 | 
				
			||||||
        if mode == "gravatar":
 | 
					        if mode == "gravatar":
 | 
				
			||||||
            parameters = [
 | 
					            parameters = [
 | 
				
			||||||
                ("s", "158"),
 | 
					                ("s", "158"),
 | 
				
			||||||
@ -204,7 +220,7 @@ class Provider(SerializerModel):
 | 
				
			|||||||
    name = models.TextField()
 | 
					    name = models.TextField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    authorization_flow = models.ForeignKey(
 | 
					    authorization_flow = models.ForeignKey(
 | 
				
			||||||
        Flow,
 | 
					        "authentik_flows.Flow",
 | 
				
			||||||
        on_delete=models.CASCADE,
 | 
					        on_delete=models.CASCADE,
 | 
				
			||||||
        help_text=_("Flow used when authorizing this provider."),
 | 
					        help_text=_("Flow used when authorizing this provider."),
 | 
				
			||||||
        related_name="provider_authorization",
 | 
					        related_name="provider_authorization",
 | 
				
			||||||
@ -226,7 +242,7 @@ class Provider(SerializerModel):
 | 
				
			|||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def serializer(self) -> Type[Serializer]:
 | 
					    def serializer(self) -> type[Serializer]:
 | 
				
			||||||
        """Get serializer for this model"""
 | 
					        """Get serializer for this model"""
 | 
				
			||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -246,7 +262,7 @@ class Application(PolicyBindingModel):
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    meta_launch_url = models.TextField(
 | 
					    meta_launch_url = models.TextField(
 | 
				
			||||||
        default="", blank=True, validators=[validators.URLValidator()]
 | 
					        default="", blank=True, validators=[DomainlessURLValidator()]
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    # For template applications, this can be set to /static/authentik/applications/*
 | 
					    # For template applications, this can be set to /static/authentik/applications/*
 | 
				
			||||||
    meta_icon = models.FileField(
 | 
					    meta_icon = models.FileField(
 | 
				
			||||||
@ -264,7 +280,7 @@ class Application(PolicyBindingModel):
 | 
				
			|||||||
        it is returned as-is"""
 | 
					        it is returned as-is"""
 | 
				
			||||||
        if not self.meta_icon:
 | 
					        if not self.meta_icon:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        if self.meta_icon.name.startswith("http") or self.meta_icon.name.startswith("/static"):
 | 
					        if "://" in self.meta_icon.name or self.meta_icon.name.startswith("/static"):
 | 
				
			||||||
            return self.meta_icon.name
 | 
					            return self.meta_icon.name
 | 
				
			||||||
        return self.meta_icon.url
 | 
					        return self.meta_icon.url
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -272,15 +288,21 @@ class Application(PolicyBindingModel):
 | 
				
			|||||||
        """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:
 | 
				
			||||||
            return self.meta_launch_url
 | 
					            return self.meta_launch_url
 | 
				
			||||||
        if self.provider:
 | 
					        if provider := self.get_provider():
 | 
				
			||||||
            return self.get_provider().launch_url
 | 
					            return provider.launch_url
 | 
				
			||||||
        return None
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_provider(self) -> Optional[Provider]:
 | 
					    def get_provider(self) -> Optional[Provider]:
 | 
				
			||||||
        """Get casted provider instance"""
 | 
					        """Get casted provider instance"""
 | 
				
			||||||
        if not self.provider:
 | 
					        if not self.provider:
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
        return Provider.objects.get_subclass(pk=self.provider.pk)
 | 
					        # if the Application class has been cache, self.provider is set
 | 
				
			||||||
 | 
					        # but doing a direct query lookup will fail.
 | 
				
			||||||
 | 
					        # In that case, just return None
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return Provider.objects.get_subclass(pk=self.provider.pk)
 | 
				
			||||||
 | 
					        except Provider.DoesNotExist:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return self.name
 | 
					        return self.name
 | 
				
			||||||
@ -325,7 +347,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
    property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
 | 
					    property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    authentication_flow = models.ForeignKey(
 | 
					    authentication_flow = models.ForeignKey(
 | 
				
			||||||
        Flow,
 | 
					        "authentik_flows.Flow",
 | 
				
			||||||
        blank=True,
 | 
					        blank=True,
 | 
				
			||||||
        null=True,
 | 
					        null=True,
 | 
				
			||||||
        default=None,
 | 
					        default=None,
 | 
				
			||||||
@ -334,7 +356,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
        related_name="source_authentication",
 | 
					        related_name="source_authentication",
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    enrollment_flow = models.ForeignKey(
 | 
					    enrollment_flow = models.ForeignKey(
 | 
				
			||||||
        Flow,
 | 
					        "authentik_flows.Flow",
 | 
				
			||||||
        blank=True,
 | 
					        blank=True,
 | 
				
			||||||
        null=True,
 | 
					        null=True,
 | 
				
			||||||
        default=None,
 | 
					        default=None,
 | 
				
			||||||
@ -361,13 +383,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
				
			|||||||
        """Return component used to edit this object"""
 | 
					        """Return component used to edit this object"""
 | 
				
			||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    def ui_login_button(self, request: HttpRequest) -> Optional[UILoginButton]:
 | 
				
			||||||
    def ui_login_button(self) -> Optional[UILoginButton]:
 | 
					 | 
				
			||||||
        """If source uses a http-based flow, return UI Information about the login
 | 
					        """If source uses a http-based flow, return UI Information about the login
 | 
				
			||||||
        button. If source doesn't use http-based flow, return None."""
 | 
					        button. If source doesn't use http-based flow, return None."""
 | 
				
			||||||
        return None
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def ui_user_settings(self) -> Optional[UserSettingSerializer]:
 | 
					    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 UserSettingSerializer."""
 | 
					        user settings are available, or UserSettingSerializer."""
 | 
				
			||||||
@ -454,6 +474,14 @@ class Token(ManagedModel, ExpiringModel):
 | 
				
			|||||||
        """Handler which is called when this object is expired."""
 | 
					        """Handler which is called when this object is expired."""
 | 
				
			||||||
        from authentik.events.models import Event, EventAction
 | 
					        from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if self.intent in [
 | 
				
			||||||
 | 
					            TokenIntents.INTENT_RECOVERY,
 | 
				
			||||||
 | 
					            TokenIntents.INTENT_VERIFICATION,
 | 
				
			||||||
 | 
					            TokenIntents.INTENT_APP_PASSWORD,
 | 
				
			||||||
 | 
					        ]:
 | 
				
			||||||
 | 
					            super().expire_action(*args, **kwargs)
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.key = default_token_key()
 | 
					        self.key = default_token_key()
 | 
				
			||||||
        self.expires = default_token_duration()
 | 
					        self.expires = default_token_duration()
 | 
				
			||||||
        self.save(*args, **kwargs)
 | 
					        self.save(*args, **kwargs)
 | 
				
			||||||
@ -495,7 +523,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
 | 
				
			|||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def serializer(self) -> Type[Serializer]:
 | 
					    def serializer(self) -> type[Serializer]:
 | 
				
			||||||
        """Get serializer for this model"""
 | 
					        """Get serializer for this model"""
 | 
				
			||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
"""authentik core signals"""
 | 
					"""authentik core signals"""
 | 
				
			||||||
from typing import TYPE_CHECKING, Type
 | 
					from typing import TYPE_CHECKING
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.apps import apps
 | 
				
			||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
 | 
					from django.contrib.auth.signals import user_logged_in, user_logged_out
 | 
				
			||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
					from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
@ -11,6 +12,8 @@ from django.dispatch import receiver
 | 
				
			|||||||
from django.http.request import HttpRequest
 | 
					from django.http.request import HttpRequest
 | 
				
			||||||
from prometheus_client import Gauge
 | 
					from prometheus_client import Gauge
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.root.monitoring import monitoring_set
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Arguments: user: User, password: str
 | 
					# Arguments: user: User, password: str
 | 
				
			||||||
password_changed = Signal()
 | 
					password_changed = Signal()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -20,6 +23,17 @@ if TYPE_CHECKING:
 | 
				
			|||||||
    from authentik.core.models import AuthenticatedSession, User
 | 
					    from authentik.core.models import AuthenticatedSession, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(monitoring_set)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def monitoring_set_models(sender, **kwargs):
 | 
				
			||||||
 | 
					    """set models gauges"""
 | 
				
			||||||
 | 
					    for model in apps.get_models():
 | 
				
			||||||
 | 
					        GAUGE_MODELS.labels(
 | 
				
			||||||
 | 
					            model_name=model._meta.model_name,
 | 
				
			||||||
 | 
					            app=model._meta.app_label,
 | 
				
			||||||
 | 
					        ).set(model.objects.count())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(post_save)
 | 
					@receiver(post_save)
 | 
				
			||||||
# pylint: disable=unused-argument
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
 | 
					def post_save_application(sender: type[Model], instance, created: bool, **_):
 | 
				
			||||||
@ -27,11 +41,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
 | 
				
			|||||||
    from authentik.core.api.applications import user_app_cache_key
 | 
					    from authentik.core.api.applications import user_app_cache_key
 | 
				
			||||||
    from authentik.core.models import Application
 | 
					    from authentik.core.models import Application
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    GAUGE_MODELS.labels(
 | 
					 | 
				
			||||||
        model_name=sender._meta.model_name,
 | 
					 | 
				
			||||||
        app=sender._meta.app_label,
 | 
					 | 
				
			||||||
    ).set(sender.objects.count())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if sender != Application:
 | 
					    if sender != Application:
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
    if not created:  # pragma: no cover
 | 
					    if not created:  # pragma: no cover
 | 
				
			||||||
@ -62,7 +71,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(pre_delete)
 | 
					@receiver(pre_delete)
 | 
				
			||||||
def authenticated_session_delete(sender: Type[Model], instance: "AuthenticatedSession", **_):
 | 
					def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
 | 
				
			||||||
    """Delete session when authenticated session is deleted"""
 | 
					    """Delete session when authenticated session is deleted"""
 | 
				
			||||||
    from authentik.core.models import AuthenticatedSession
 | 
					    from authentik.core.models import AuthenticatedSession
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,6 @@
 | 
				
			|||||||
"""Source decision helper"""
 | 
					"""Source decision helper"""
 | 
				
			||||||
from enum import Enum
 | 
					from enum import Enum
 | 
				
			||||||
from typing import Any, Optional, Type
 | 
					from typing import Any, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.contrib import messages
 | 
					from django.contrib import messages
 | 
				
			||||||
from django.db import IntegrityError
 | 
					from django.db import IntegrityError
 | 
				
			||||||
@ -14,6 +14,7 @@ from structlog.stdlib import get_logger
 | 
				
			|||||||
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
 | 
					from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
 | 
				
			||||||
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
 | 
					from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					from authentik.flows.exceptions import FlowNonApplicableException
 | 
				
			||||||
from authentik.flows.models import Flow, Stage, in_memory_stage
 | 
					from authentik.flows.models import Flow, Stage, in_memory_stage
 | 
				
			||||||
from authentik.flows.planner import (
 | 
					from authentik.flows.planner import (
 | 
				
			||||||
    PLAN_CONTEXT_PENDING_USER,
 | 
					    PLAN_CONTEXT_PENDING_USER,
 | 
				
			||||||
@ -24,6 +25,8 @@ from authentik.flows.planner import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
 | 
					from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
 | 
				
			||||||
from authentik.lib.utils.urls import redirect_with_qs
 | 
					from authentik.lib.utils.urls import redirect_with_qs
 | 
				
			||||||
 | 
					from authentik.policies.denied import AccessDeniedResponse
 | 
				
			||||||
 | 
					from authentik.policies.types import PolicyResult
 | 
				
			||||||
from authentik.policies.utils import delete_none_keys
 | 
					from authentik.policies.utils import delete_none_keys
 | 
				
			||||||
from authentik.stages.password import BACKEND_INBUILT
 | 
					from authentik.stages.password import BACKEND_INBUILT
 | 
				
			||||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
 | 
					from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
 | 
				
			||||||
@ -50,7 +53,10 @@ class SourceFlowManager:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    identifier: str
 | 
					    identifier: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    connection_type: Type[UserSourceConnection] = UserSourceConnection
 | 
					    connection_type: type[UserSourceConnection] = UserSourceConnection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    enroll_info: dict[str, Any]
 | 
				
			||||||
 | 
					    policy_context: dict[str, Any]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(
 | 
					    def __init__(
 | 
				
			||||||
        self,
 | 
					        self,
 | 
				
			||||||
@ -64,6 +70,7 @@ class SourceFlowManager:
 | 
				
			|||||||
        self.identifier = identifier
 | 
					        self.identifier = identifier
 | 
				
			||||||
        self.enroll_info = enroll_info
 | 
					        self.enroll_info = enroll_info
 | 
				
			||||||
        self._logger = get_logger().bind(source=source, identifier=identifier)
 | 
					        self._logger = get_logger().bind(source=source, identifier=identifier)
 | 
				
			||||||
 | 
					        self.policy_context = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # pylint: disable=too-many-return-statements
 | 
					    # pylint: disable=too-many-return-statements
 | 
				
			||||||
    def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
 | 
					    def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
 | 
				
			||||||
@ -144,20 +151,23 @@ class SourceFlowManager:
 | 
				
			|||||||
        except IntegrityError as exc:
 | 
					        except IntegrityError as exc:
 | 
				
			||||||
            self._logger.warning("failed to get action", exc=exc)
 | 
					            self._logger.warning("failed to get action", exc=exc)
 | 
				
			||||||
            return redirect("/")
 | 
					            return redirect("/")
 | 
				
			||||||
        self._logger.debug("get_action() says", action=action, connection=connection)
 | 
					        self._logger.debug("get_action", action=action, connection=connection)
 | 
				
			||||||
        if connection:
 | 
					        try:
 | 
				
			||||||
            if action == Action.LINK:
 | 
					            if connection:
 | 
				
			||||||
                self._logger.debug("Linking existing user")
 | 
					                if action == Action.LINK:
 | 
				
			||||||
                return self.handle_existing_user_link(connection)
 | 
					                    self._logger.debug("Linking existing user")
 | 
				
			||||||
            if action == Action.AUTH:
 | 
					                    return self.handle_existing_user_link(connection)
 | 
				
			||||||
                self._logger.debug("Handling auth user")
 | 
					                if action == Action.AUTH:
 | 
				
			||||||
                return self.handle_auth_user(connection)
 | 
					                    self._logger.debug("Handling auth user")
 | 
				
			||||||
            if action == Action.ENROLL:
 | 
					                    return self.handle_auth_user(connection)
 | 
				
			||||||
                self._logger.debug("Handling enrollment of new user")
 | 
					                if action == Action.ENROLL:
 | 
				
			||||||
                return self.handle_enroll(connection)
 | 
					                    self._logger.debug("Handling enrollment of new user")
 | 
				
			||||||
 | 
					                    return self.handle_enroll(connection)
 | 
				
			||||||
 | 
					        except FlowNonApplicableException as exc:
 | 
				
			||||||
 | 
					            self._logger.warning("Flow non applicable", exc=exc)
 | 
				
			||||||
 | 
					            return self.error_handler(exc, exc.policy_result)
 | 
				
			||||||
        # Default case, assume deny
 | 
					        # Default case, assume deny
 | 
				
			||||||
        messages.error(
 | 
					        error = (
 | 
				
			||||||
            self.request,
 | 
					 | 
				
			||||||
            _(
 | 
					            _(
 | 
				
			||||||
                (
 | 
					                (
 | 
				
			||||||
                    "Request to authenticate with %(source)s has been denied. Please authenticate "
 | 
					                    "Request to authenticate with %(source)s has been denied. Please authenticate "
 | 
				
			||||||
@ -166,7 +176,17 @@ class SourceFlowManager:
 | 
				
			|||||||
                % {"source": self.source.name}
 | 
					                % {"source": self.source.name}
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return redirect(reverse("authentik_core:root-redirect"))
 | 
					        return self.error_handler(error)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def error_handler(
 | 
				
			||||||
 | 
					        self, error: Exception, policy_result: Optional[PolicyResult] = None
 | 
				
			||||||
 | 
					    ) -> HttpResponse:
 | 
				
			||||||
 | 
					        """Handle any errors by returning an access denied stage"""
 | 
				
			||||||
 | 
					        response = AccessDeniedResponse(self.request)
 | 
				
			||||||
 | 
					        response.error_message = str(error)
 | 
				
			||||||
 | 
					        if policy_result:
 | 
				
			||||||
 | 
					            response.policy_result = policy_result
 | 
				
			||||||
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # pylint: disable=unused-argument
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
    def get_stages_to_append(self, flow: Flow) -> list[Stage]:
 | 
					    def get_stages_to_append(self, flow: Flow) -> list[Stage]:
 | 
				
			||||||
@ -179,7 +199,9 @@ class SourceFlowManager:
 | 
				
			|||||||
            ]
 | 
					            ]
 | 
				
			||||||
        return []
 | 
					        return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
 | 
					    def _handle_login_flow(
 | 
				
			||||||
 | 
					        self, flow: Flow, connection: UserSourceConnection, **kwargs
 | 
				
			||||||
 | 
					    ) -> HttpResponse:
 | 
				
			||||||
        """Prepare Authentication Plan, redirect user FlowExecutor"""
 | 
					        """Prepare Authentication Plan, redirect user FlowExecutor"""
 | 
				
			||||||
        # Ensure redirect is carried through when user was trying to
 | 
					        # Ensure redirect is carried through when user was trying to
 | 
				
			||||||
        # authorize application
 | 
					        # authorize application
 | 
				
			||||||
@ -193,8 +215,10 @@ class SourceFlowManager:
 | 
				
			|||||||
                PLAN_CONTEXT_SSO: True,
 | 
					                PLAN_CONTEXT_SSO: True,
 | 
				
			||||||
                PLAN_CONTEXT_SOURCE: self.source,
 | 
					                PLAN_CONTEXT_SOURCE: self.source,
 | 
				
			||||||
                PLAN_CONTEXT_REDIRECT: final_redirect,
 | 
					                PLAN_CONTEXT_REDIRECT: final_redirect,
 | 
				
			||||||
 | 
					                PLAN_CONTEXT_SOURCES_CONNECTION: connection,
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        kwargs.update(self.policy_context)
 | 
				
			||||||
        if not flow:
 | 
					        if not flow:
 | 
				
			||||||
            return HttpResponseBadRequest()
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
        # We run the Flow planner here so we can pass the Pending user in the context
 | 
					        # We run the Flow planner here so we can pass the Pending user in the context
 | 
				
			||||||
@ -220,7 +244,7 @@ class SourceFlowManager:
 | 
				
			|||||||
            _("Successfully authenticated with %(source)s!" % {"source": self.source.name}),
 | 
					            _("Successfully authenticated with %(source)s!" % {"source": self.source.name}),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
 | 
					        flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
 | 
				
			||||||
        return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs)
 | 
					        return self._handle_login_flow(self.source.authentication_flow, connection, **flow_kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def handle_existing_user_link(
 | 
					    def handle_existing_user_link(
 | 
				
			||||||
        self,
 | 
					        self,
 | 
				
			||||||
@ -264,8 +288,8 @@ class SourceFlowManager:
 | 
				
			|||||||
            return HttpResponseBadRequest()
 | 
					            return HttpResponseBadRequest()
 | 
				
			||||||
        return self._handle_login_flow(
 | 
					        return self._handle_login_flow(
 | 
				
			||||||
            self.source.enrollment_flow,
 | 
					            self.source.enrollment_flow,
 | 
				
			||||||
 | 
					            connection,
 | 
				
			||||||
            **{
 | 
					            **{
 | 
				
			||||||
                PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
 | 
					                PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
 | 
				
			||||||
                PLAN_CONTEXT_SOURCES_CONNECTION: connection,
 | 
					 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -6,7 +6,6 @@ from os import environ
 | 
				
			|||||||
from boto3.exceptions import Boto3Error
 | 
					from boto3.exceptions import Boto3Error
 | 
				
			||||||
from botocore.exceptions import BotoCoreError, ClientError
 | 
					from botocore.exceptions import BotoCoreError, ClientError
 | 
				
			||||||
from dbbackup.db.exceptions import CommandConnectorError
 | 
					from dbbackup.db.exceptions import CommandConnectorError
 | 
				
			||||||
from django.conf import settings
 | 
					 | 
				
			||||||
from django.contrib.humanize.templatetags.humanize import naturaltime
 | 
					from django.contrib.humanize.templatetags.humanize import naturaltime
 | 
				
			||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
					from django.contrib.sessions.backends.cache import KEY_PREFIX
 | 
				
			||||||
from django.core import management
 | 
					from django.core import management
 | 
				
			||||||
@ -29,7 +28,7 @@ LOGGER = get_logger()
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
					@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
				
			||||||
@prefill_task()
 | 
					@prefill_task
 | 
				
			||||||
def clean_expired_models(self: MonitoredTask):
 | 
					def clean_expired_models(self: MonitoredTask):
 | 
				
			||||||
    """Remove expired objects"""
 | 
					    """Remove expired objects"""
 | 
				
			||||||
    messages = []
 | 
					    messages = []
 | 
				
			||||||
@ -63,13 +62,11 @@ def should_backup() -> bool:
 | 
				
			|||||||
        return False
 | 
					        return False
 | 
				
			||||||
    if not CONFIG.y_bool("postgresql.backup.enabled"):
 | 
					    if not CONFIG.y_bool("postgresql.backup.enabled"):
 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
    if settings.DEBUG:
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
    return True
 | 
					    return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
					@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
				
			||||||
@prefill_task()
 | 
					@prefill_task
 | 
				
			||||||
def backup_database(self: MonitoredTask):  # pragma: no cover
 | 
					def backup_database(self: MonitoredTask):  # pragma: no cover
 | 
				
			||||||
    """Database backup"""
 | 
					    """Database backup"""
 | 
				
			||||||
    self.result_timeout_hours = 25
 | 
					    self.result_timeout_hours = 25
 | 
				
			||||||
 | 
				
			|||||||
@ -19,6 +19,7 @@
 | 
				
			|||||||
        <script src="{% static 'dist/poly.js' %}" type="module"></script>
 | 
					        <script src="{% static 'dist/poly.js' %}" type="module"></script>
 | 
				
			||||||
        {% block head %}
 | 
					        {% block head %}
 | 
				
			||||||
        {% endblock %}
 | 
					        {% endblock %}
 | 
				
			||||||
 | 
					        <meta name="sentry-trace" content="{{ sentry_trace }}" />
 | 
				
			||||||
    </head>
 | 
					    </head>
 | 
				
			||||||
    <body>
 | 
					    <body>
 | 
				
			||||||
        {% block body %}
 | 
					        {% block body %}
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,9 @@
 | 
				
			|||||||
{% load i18n %}
 | 
					{% load i18n %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					{% block head %}
 | 
				
			||||||
<script src="{% static 'dist/AdminInterface.js' %}" type="module"></script>
 | 
					<script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script>
 | 
				
			||||||
 | 
					<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 | 
				
			||||||
 | 
					<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block body %}
 | 
					{% block body %}
 | 
				
			||||||
 | 
				
			|||||||
@ -11,7 +11,7 @@
 | 
				
			|||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					{% block head %}
 | 
				
			||||||
<script src="{% static 'dist/FlowInterface.js' %}" type="module"></script>
 | 
					<script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script>
 | 
				
			||||||
<style>
 | 
					<style>
 | 
				
			||||||
.pf-c-background-image::before {
 | 
					.pf-c-background-image::before {
 | 
				
			||||||
    --ak-flow-background: url("{{ flow.background_url }}");
 | 
					    --ak-flow-background: url("{{ flow.background_url }}");
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,9 @@
 | 
				
			|||||||
{% load i18n %}
 | 
					{% load i18n %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					{% block head %}
 | 
				
			||||||
<script src="{% static 'dist/UserInterface.js' %}" type="module"></script>
 | 
					<script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script>
 | 
				
			||||||
 | 
					<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
 | 
				
			||||||
 | 
					<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block body %}
 | 
					{% block body %}
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,9 @@
 | 
				
			|||||||
"""Test Applications API"""
 | 
					"""Test Applications API"""
 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.utils.encoding import force_str
 | 
					 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application, User
 | 
					from authentik.core.models import Application
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
from authentik.policies.dummy.models import DummyPolicy
 | 
					from authentik.policies.dummy.models import DummyPolicy
 | 
				
			||||||
from authentik.policies.models import PolicyBinding
 | 
					from authentik.policies.models import PolicyBinding
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -12,7 +12,7 @@ class TestApplicationsAPI(APITestCase):
 | 
				
			|||||||
    """Test applications API"""
 | 
					    """Test applications API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        self.user = User.objects.get(username="akadmin")
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
        self.allowed = Application.objects.create(name="allowed", slug="allowed")
 | 
					        self.allowed = Application.objects.create(name="allowed", slug="allowed")
 | 
				
			||||||
        self.denied = Application.objects.create(name="denied", slug="denied")
 | 
					        self.denied = Application.objects.create(name="denied", slug="denied")
 | 
				
			||||||
        PolicyBinding.objects.create(
 | 
					        PolicyBinding.objects.create(
 | 
				
			||||||
@ -31,7 +31,7 @@ class TestApplicationsAPI(APITestCase):
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
        self.assertJSONEqual(force_str(response.content), {"messages": [], "passing": True})
 | 
					        self.assertJSONEqual(response.content.decode(), {"messages": [], "passing": True})
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
                "authentik_api:application-check-access",
 | 
					                "authentik_api:application-check-access",
 | 
				
			||||||
@ -39,14 +39,14 @@ class TestApplicationsAPI(APITestCase):
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
        self.assertJSONEqual(force_str(response.content), {"messages": ["dummy"], "passing": False})
 | 
					        self.assertJSONEqual(response.content.decode(), {"messages": ["dummy"], "passing": False})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_list(self):
 | 
					    def test_list(self):
 | 
				
			||||||
        """Test list operation without superuser_full_list"""
 | 
					        """Test list operation without superuser_full_list"""
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:application-list"))
 | 
					        response = self.client.get(reverse("authentik_api:application-list"))
 | 
				
			||||||
        self.assertJSONEqual(
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
            force_str(response.content),
 | 
					            response.content.decode(),
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "pagination": {
 | 
					                "pagination": {
 | 
				
			||||||
                    "next": 0,
 | 
					                    "next": 0,
 | 
				
			||||||
@ -82,7 +82,7 @@ class TestApplicationsAPI(APITestCase):
 | 
				
			|||||||
            reverse("authentik_api:application-list") + "?superuser_full_list=true"
 | 
					            reverse("authentik_api:application-list") + "?superuser_full_list=true"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertJSONEqual(
 | 
					        self.assertJSONEqual(
 | 
				
			||||||
            force_str(response.content),
 | 
					            response.content.decode(),
 | 
				
			||||||
            {
 | 
					            {
 | 
				
			||||||
                "pagination": {
 | 
					                "pagination": {
 | 
				
			||||||
                    "next": 0,
 | 
					                    "next": 0,
 | 
				
			||||||
 | 
				
			|||||||
@ -2,10 +2,10 @@
 | 
				
			|||||||
from json import loads
 | 
					from json import loads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.urls.base import reverse
 | 
					from django.urls.base import reverse
 | 
				
			||||||
from django.utils.encoding import force_str
 | 
					 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.models import User
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestAuthenticatedSessionsAPI(APITestCase):
 | 
					class TestAuthenticatedSessionsAPI(APITestCase):
 | 
				
			||||||
@ -13,7 +13,7 @@ class TestAuthenticatedSessionsAPI(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        super().setUp()
 | 
					        super().setUp()
 | 
				
			||||||
        self.user = User.objects.get(username="akadmin")
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
        self.other_user = User.objects.create(username="normal-user")
 | 
					        self.other_user = User.objects.create(username="normal-user")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_list(self):
 | 
					    def test_list(self):
 | 
				
			||||||
@ -27,5 +27,5 @@ class TestAuthenticatedSessionsAPI(APITestCase):
 | 
				
			|||||||
        self.client.force_login(self.other_user)
 | 
					        self.client.force_login(self.other_user)
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
 | 
					        response = self.client.get(reverse("authentik_api:authenticatedsession-list"))
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
        body = loads(force_str(response.content))
 | 
					        body = loads(response.content.decode())
 | 
				
			||||||
        self.assertEqual(body["pagination"]["count"], 1)
 | 
					        self.assertEqual(body["pagination"]["count"], 1)
 | 
				
			||||||
 | 
				
			|||||||
@ -5,6 +5,7 @@ from django.test.testcases import TestCase
 | 
				
			|||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.models import User
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestImpersonation(TestCase):
 | 
					class TestImpersonation(TestCase):
 | 
				
			||||||
@ -13,14 +14,14 @@ class TestImpersonation(TestCase):
 | 
				
			|||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        super().setUp()
 | 
					        super().setUp()
 | 
				
			||||||
        self.other_user = User.objects.create(username="to-impersonate")
 | 
					        self.other_user = User.objects.create(username="to-impersonate")
 | 
				
			||||||
        self.akadmin = User.objects.get(username="akadmin")
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    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
 | 
					        # test with an inactive user to ensure that still works
 | 
				
			||||||
        self.other_user.is_active = False
 | 
					        self.other_user.is_active = False
 | 
				
			||||||
        self.other_user.save()
 | 
					        self.other_user.save()
 | 
				
			||||||
        self.client.force_login(self.akadmin)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.client.get(
 | 
					        self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
@ -32,13 +33,13 @@ class TestImpersonation(TestCase):
 | 
				
			|||||||
        response = self.client.get(reverse("authentik_api:user-me"))
 | 
					        response = self.client.get(reverse("authentik_api:user-me"))
 | 
				
			||||||
        response_body = loads(response.content.decode())
 | 
					        response_body = loads(response.content.decode())
 | 
				
			||||||
        self.assertEqual(response_body["user"]["username"], self.other_user.username)
 | 
					        self.assertEqual(response_body["user"]["username"], self.other_user.username)
 | 
				
			||||||
        self.assertEqual(response_body["original"]["username"], self.akadmin.username)
 | 
					        self.assertEqual(response_body["original"]["username"], self.user.username)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.client.get(reverse("authentik_core:impersonate-end"))
 | 
					        self.client.get(reverse("authentik_core:impersonate-end"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:user-me"))
 | 
					        response = self.client.get(reverse("authentik_api:user-me"))
 | 
				
			||||||
        response_body = loads(response.content.decode())
 | 
					        response_body = loads(response.content.decode())
 | 
				
			||||||
        self.assertEqual(response_body["user"]["username"], self.akadmin.username)
 | 
					        self.assertEqual(response_body["user"]["username"], self.user.username)
 | 
				
			||||||
        self.assertNotIn("original", response_body)
 | 
					        self.assertNotIn("original", response_body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_impersonate_denied(self):
 | 
					    def test_impersonate_denied(self):
 | 
				
			||||||
@ -46,7 +47,7 @@ class TestImpersonation(TestCase):
 | 
				
			|||||||
        self.client.force_login(self.other_user)
 | 
					        self.client.force_login(self.other_user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.client.get(
 | 
					        self.client.get(
 | 
				
			||||||
            reverse("authentik_core:impersonate-init", kwargs={"user_id": self.akadmin.pk})
 | 
					            reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk})
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        response = self.client.get(reverse("authentik_api:user-me"))
 | 
					        response = self.client.get(reverse("authentik_api:user-me"))
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,8 @@
 | 
				
			|||||||
"""authentik core models tests"""
 | 
					"""authentik core models tests"""
 | 
				
			||||||
from time import sleep
 | 
					from time import sleep
 | 
				
			||||||
from typing import Callable, Type
 | 
					from typing import Callable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.test import TestCase
 | 
					from django.test import RequestFactory, 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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -27,9 +27,12 @@ class TestModels(TestCase):
 | 
				
			|||||||
        self.assertFalse(token.is_expired)
 | 
					        self.assertFalse(token.is_expired)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def source_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
					def source_tester_factory(test_model: type[Stage]) -> Callable:
 | 
				
			||||||
    """Test source"""
 | 
					    """Test source"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    factory = RequestFactory()
 | 
				
			||||||
 | 
					    request = factory.get("/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def tester(self: TestModels):
 | 
					    def tester(self: TestModels):
 | 
				
			||||||
        model_class = None
 | 
					        model_class = None
 | 
				
			||||||
        if test_model._meta.abstract:
 | 
					        if test_model._meta.abstract:
 | 
				
			||||||
@ -38,18 +41,18 @@ def source_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
				
			|||||||
            model_class = test_model()
 | 
					            model_class = test_model()
 | 
				
			||||||
        model_class.slug = "test"
 | 
					        model_class.slug = "test"
 | 
				
			||||||
        self.assertIsNotNone(model_class.component)
 | 
					        self.assertIsNotNone(model_class.component)
 | 
				
			||||||
        _ = model_class.ui_login_button
 | 
					        _ = model_class.ui_login_button(request)
 | 
				
			||||||
        _ = model_class.ui_user_settings
 | 
					        _ = model_class.ui_user_settings()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return tester
 | 
					    return tester
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def provider_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
					def provider_tester_factory(test_model: type[Stage]) -> Callable:
 | 
				
			||||||
    """Test provider"""
 | 
					    """Test provider"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def tester(self: TestModels):
 | 
					    def tester(self: TestModels):
 | 
				
			||||||
        model_class = None
 | 
					        model_class = None
 | 
				
			||||||
        if test_model._meta.abstract:
 | 
					        if test_model._meta.abstract:  # pragma: no cover
 | 
				
			||||||
            model_class = test_model.__bases__[0]()
 | 
					            model_class = test_model.__bases__[0]()
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            model_class = test_model()
 | 
					            model_class = test_model()
 | 
				
			||||||
@ -59,6 +62,6 @@ def provider_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
for model in all_subclasses(Source):
 | 
					for model in all_subclasses(Source):
 | 
				
			||||||
    setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model))
 | 
					    setattr(TestModels, f"test_source_{model.__name__}", source_tester_factory(model))
 | 
				
			||||||
for model in all_subclasses(Provider):
 | 
					for model in all_subclasses(Provider):
 | 
				
			||||||
    setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model))
 | 
					    setattr(TestModels, f"test_provider_{model.__name__}", provider_tester_factory(model))
 | 
				
			||||||
 | 
				
			|||||||
@ -6,7 +6,8 @@ from rest_framework.serializers import ValidationError
 | 
				
			|||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
 | 
					from authentik.core.api.propertymappings import PropertyMappingSerializer
 | 
				
			||||||
from authentik.core.models import PropertyMapping, User
 | 
					from authentik.core.models import PropertyMapping
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestPropertyMappingAPI(APITestCase):
 | 
					class TestPropertyMappingAPI(APITestCase):
 | 
				
			||||||
@ -17,7 +18,7 @@ class TestPropertyMappingAPI(APITestCase):
 | 
				
			|||||||
        self.mapping = PropertyMapping.objects.create(
 | 
					        self.mapping = PropertyMapping.objects.create(
 | 
				
			||||||
            name="dummy", expression="""return {'foo': 'bar'}"""
 | 
					            name="dummy", expression="""return {'foo': 'bar'}"""
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.user = User.objects.get(username="akadmin")
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_test_call(self):
 | 
					    def test_test_call(self):
 | 
				
			||||||
@ -40,7 +41,7 @@ class TestPropertyMappingAPI(APITestCase):
 | 
				
			|||||||
        expr = "return True"
 | 
					        expr = "return True"
 | 
				
			||||||
        self.assertEqual(PropertyMappingSerializer().validate_expression(expr), expr)
 | 
					        self.assertEqual(PropertyMappingSerializer().validate_expression(expr), expr)
 | 
				
			||||||
        with self.assertRaises(ValidationError):
 | 
					        with self.assertRaises(ValidationError):
 | 
				
			||||||
            print(PropertyMappingSerializer().validate_expression("/"))
 | 
					            PropertyMappingSerializer().validate_expression("/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_types(self):
 | 
					    def test_types(self):
 | 
				
			||||||
        """Test PropertyMappigns's types endpoint"""
 | 
					        """Test PropertyMappigns's types endpoint"""
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,8 @@
 | 
				
			|||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import PropertyMapping, User
 | 
					from authentik.core.models import PropertyMapping
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestProvidersAPI(APITestCase):
 | 
					class TestProvidersAPI(APITestCase):
 | 
				
			||||||
@ -13,7 +14,7 @@ class TestProvidersAPI(APITestCase):
 | 
				
			|||||||
        self.mapping = PropertyMapping.objects.create(
 | 
					        self.mapping = PropertyMapping.objects.create(
 | 
				
			||||||
            name="dummy", expression="""return {'foo': 'bar'}"""
 | 
					            name="dummy", expression="""return {'foo': 'bar'}"""
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.user = User.objects.get(username="akadmin")
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_types(self):
 | 
					    def test_types(self):
 | 
				
			||||||
 | 
				
			|||||||
@ -6,8 +6,12 @@ from guardian.utils import get_anonymous_user
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import SourceUserMatchingModes, User
 | 
					from authentik.core.models import SourceUserMatchingModes, User
 | 
				
			||||||
from authentik.core.sources.flow_manager import Action
 | 
					from authentik.core.sources.flow_manager import Action
 | 
				
			||||||
 | 
					from authentik.flows.models import Flow, FlowDesignation
 | 
				
			||||||
from authentik.lib.generators import generate_id
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
from authentik.lib.tests.utils import get_request
 | 
					from authentik.lib.tests.utils import get_request
 | 
				
			||||||
 | 
					from authentik.policies.denied import AccessDeniedResponse
 | 
				
			||||||
 | 
					from authentik.policies.expression.models import ExpressionPolicy
 | 
				
			||||||
 | 
					from authentik.policies.models import PolicyBinding
 | 
				
			||||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
 | 
					from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
 | 
				
			||||||
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
 | 
					from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -17,7 +21,7 @@ class TestSourceFlowManager(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        super().setUp()
 | 
					        super().setUp()
 | 
				
			||||||
        self.source = OAuthSource.objects.create(name="test")
 | 
					        self.source: OAuthSource = OAuthSource.objects.create(name="test")
 | 
				
			||||||
        self.factory = RequestFactory()
 | 
					        self.factory = RequestFactory()
 | 
				
			||||||
        self.identifier = generate_id()
 | 
					        self.identifier = generate_id()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -143,3 +147,34 @@ class TestSourceFlowManager(TestCase):
 | 
				
			|||||||
        action, _ = flow_manager.get_action()
 | 
					        action, _ = flow_manager.get_action()
 | 
				
			||||||
        self.assertEqual(action, Action.ENROLL)
 | 
					        self.assertEqual(action, Action.ENROLL)
 | 
				
			||||||
        flow_manager.get_flow()
 | 
					        flow_manager.get_flow()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_error_non_applicable_flow(self):
 | 
				
			||||||
 | 
					        """Test error handling when a source selected flow is non-applicable due to a policy"""
 | 
				
			||||||
 | 
					        self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_LINK
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        flow = Flow.objects.create(
 | 
				
			||||||
 | 
					            name="test", slug="test", title="test", designation=FlowDesignation.ENROLLMENT
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        policy = ExpressionPolicy.objects.create(
 | 
				
			||||||
 | 
					            name="false", expression="""ak_message("foo");return False"""
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        PolicyBinding.objects.create(
 | 
				
			||||||
 | 
					            policy=policy,
 | 
				
			||||||
 | 
					            target=flow,
 | 
				
			||||||
 | 
					            order=0,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.source.enrollment_flow = flow
 | 
				
			||||||
 | 
					        self.source.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        flow_manager = OAuthSourceFlowManager(
 | 
				
			||||||
 | 
					            self.source,
 | 
				
			||||||
 | 
					            get_request("/", user=AnonymousUser()),
 | 
				
			||||||
 | 
					            self.identifier,
 | 
				
			||||||
 | 
					            {"username": "foo"},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        action, _ = flow_manager.get_action()
 | 
				
			||||||
 | 
					        self.assertEqual(action, Action.ENROLL)
 | 
				
			||||||
 | 
					        response = flow_manager.get_flow()
 | 
				
			||||||
 | 
					        self.assertIsInstance(response, AccessDeniedResponse)
 | 
				
			||||||
 | 
					        # pylint: disable=no-member
 | 
				
			||||||
 | 
					        self.assertEqual(response.error_message, "foo")
 | 
				
			||||||
 | 
				
			|||||||
@ -8,6 +8,7 @@ from rest_framework.test import APITestCase
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
 | 
					from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
 | 
				
			||||||
from authentik.core.tasks import clean_expired_models
 | 
					from authentik.core.tasks import clean_expired_models
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestTokenAPI(APITestCase):
 | 
					class TestTokenAPI(APITestCase):
 | 
				
			||||||
@ -16,7 +17,7 @@ class TestTokenAPI(APITestCase):
 | 
				
			|||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        super().setUp()
 | 
					        super().setUp()
 | 
				
			||||||
        self.user = User.objects.create(username="testuser")
 | 
					        self.user = User.objects.create(username="testuser")
 | 
				
			||||||
        self.admin = User.objects.get(username="akadmin")
 | 
					        self.admin = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_token_create(self):
 | 
					    def test_token_create(self):
 | 
				
			||||||
@ -53,7 +54,9 @@ class TestTokenAPI(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_token_expire(self):
 | 
					    def test_token_expire(self):
 | 
				
			||||||
        """Test Token expire task"""
 | 
					        """Test Token expire task"""
 | 
				
			||||||
        token: Token = Token.objects.create(expires=now(), user=get_anonymous_user())
 | 
					        token: Token = Token.objects.create(
 | 
				
			||||||
 | 
					            expires=now(), user=get_anonymous_user(), intent=TokenIntents.INTENT_API
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        key = token.key
 | 
					        key = token.key
 | 
				
			||||||
        clean_expired_models.delay().get()
 | 
					        clean_expired_models.delay().get()
 | 
				
			||||||
        token.refresh_from_db()
 | 
					        token.refresh_from_db()
 | 
				
			||||||
 | 
				
			|||||||
@ -2,8 +2,15 @@
 | 
				
			|||||||
from django.urls.base import reverse
 | 
					from django.urls.base import reverse
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import USER_ATTRIBUTE_CHANGE_EMAIL, USER_ATTRIBUTE_CHANGE_USERNAME, User
 | 
					from authentik.core.models import (
 | 
				
			||||||
from authentik.flows.models import Flow, FlowDesignation
 | 
					    USER_ATTRIBUTE_CHANGE_EMAIL,
 | 
				
			||||||
 | 
					    USER_ATTRIBUTE_CHANGE_NAME,
 | 
				
			||||||
 | 
					    USER_ATTRIBUTE_CHANGE_USERNAME,
 | 
				
			||||||
 | 
					    User,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
 | 
				
			||||||
 | 
					from authentik.flows.models import FlowDesignation
 | 
				
			||||||
 | 
					from authentik.lib.generators import generate_key
 | 
				
			||||||
from authentik.stages.email.models import EmailStage
 | 
					from authentik.stages.email.models import EmailStage
 | 
				
			||||||
from authentik.tenants.models import Tenant
 | 
					from authentik.tenants.models import Tenant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -12,16 +19,33 @@ class TestUsersAPI(APITestCase):
 | 
				
			|||||||
    """Test Users API"""
 | 
					    """Test Users API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        self.admin = User.objects.get(username="akadmin")
 | 
					        self.admin = create_test_admin_user()
 | 
				
			||||||
        self.user = User.objects.create(username="test-user")
 | 
					        self.user = User.objects.create(username="test-user")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_update_self(self):
 | 
					    def test_update_self(self):
 | 
				
			||||||
        """Test update_self"""
 | 
					        """Test update_self"""
 | 
				
			||||||
 | 
					        self.admin.attributes["foo"] = "bar"
 | 
				
			||||||
 | 
					        self.admin.save()
 | 
				
			||||||
 | 
					        self.admin.refresh_from_db()
 | 
				
			||||||
        self.client.force_login(self.admin)
 | 
					        self.client.force_login(self.admin)
 | 
				
			||||||
        response = self.client.put(
 | 
					        response = self.client.put(
 | 
				
			||||||
            reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
 | 
					            reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					        self.admin.refresh_from_db()
 | 
				
			||||||
        self.assertEqual(response.status_code, 200)
 | 
					        self.assertEqual(response.status_code, 200)
 | 
				
			||||||
 | 
					        self.assertEqual(self.admin.attributes["foo"], "bar")
 | 
				
			||||||
 | 
					        self.assertEqual(self.admin.username, "foo")
 | 
				
			||||||
 | 
					        self.assertEqual(self.admin.name, "foo")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_update_self_name_denied(self):
 | 
				
			||||||
 | 
					        """Test update_self"""
 | 
				
			||||||
 | 
					        self.admin.attributes[USER_ATTRIBUTE_CHANGE_NAME] = False
 | 
				
			||||||
 | 
					        self.admin.save()
 | 
				
			||||||
 | 
					        self.client.force_login(self.admin)
 | 
				
			||||||
 | 
					        response = self.client.put(
 | 
				
			||||||
 | 
					            reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_update_self_username_denied(self):
 | 
					    def test_update_self_username_denied(self):
 | 
				
			||||||
        """Test update_self"""
 | 
					        """Test update_self"""
 | 
				
			||||||
@ -67,12 +91,22 @@ class TestUsersAPI(APITestCase):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        self.assertEqual(response.status_code, 404)
 | 
					        self.assertEqual(response.status_code, 404)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_set_password(self):
 | 
				
			||||||
 | 
					        """Test Direct password set"""
 | 
				
			||||||
 | 
					        self.client.force_login(self.admin)
 | 
				
			||||||
 | 
					        new_pw = generate_key()
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
 | 
				
			||||||
 | 
					            data={"password": new_pw},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 204)
 | 
				
			||||||
 | 
					        self.admin.refresh_from_db()
 | 
				
			||||||
 | 
					        self.assertTrue(self.admin.check_password(new_pw))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_recovery(self):
 | 
					    def test_recovery(self):
 | 
				
			||||||
        """Test user recovery link (no recovery flow set)"""
 | 
					        """Test user recovery link (no recovery flow set)"""
 | 
				
			||||||
        flow = Flow.objects.create(
 | 
					        flow = create_test_flow(FlowDesignation.RECOVERY)
 | 
				
			||||||
            name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
 | 
					        tenant: Tenant = create_test_tenant()
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        tenant: Tenant = Tenant.objects.first()
 | 
					 | 
				
			||||||
        tenant.flow_recovery = flow
 | 
					        tenant.flow_recovery = flow
 | 
				
			||||||
        tenant.save()
 | 
					        tenant.save()
 | 
				
			||||||
        self.client.force_login(self.admin)
 | 
					        self.client.force_login(self.admin)
 | 
				
			||||||
@ -99,10 +133,8 @@ class TestUsersAPI(APITestCase):
 | 
				
			|||||||
        """Test user recovery link (no email stage)"""
 | 
					        """Test user recovery link (no email stage)"""
 | 
				
			||||||
        self.user.email = "foo@bar.baz"
 | 
					        self.user.email = "foo@bar.baz"
 | 
				
			||||||
        self.user.save()
 | 
					        self.user.save()
 | 
				
			||||||
        flow = Flow.objects.create(
 | 
					        flow = create_test_flow(designation=FlowDesignation.RECOVERY)
 | 
				
			||||||
            name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
 | 
					        tenant: Tenant = create_test_tenant()
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        tenant: Tenant = Tenant.objects.first()
 | 
					 | 
				
			||||||
        tenant.flow_recovery = flow
 | 
					        tenant.flow_recovery = flow
 | 
				
			||||||
        tenant.save()
 | 
					        tenant.save()
 | 
				
			||||||
        self.client.force_login(self.admin)
 | 
					        self.client.force_login(self.admin)
 | 
				
			||||||
@ -115,10 +147,8 @@ class TestUsersAPI(APITestCase):
 | 
				
			|||||||
        """Test user recovery link"""
 | 
					        """Test user recovery link"""
 | 
				
			||||||
        self.user.email = "foo@bar.baz"
 | 
					        self.user.email = "foo@bar.baz"
 | 
				
			||||||
        self.user.save()
 | 
					        self.user.save()
 | 
				
			||||||
        flow = Flow.objects.create(
 | 
					        flow = create_test_flow(FlowDesignation.RECOVERY)
 | 
				
			||||||
            name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
 | 
					        tenant: Tenant = create_test_tenant()
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        tenant: Tenant = Tenant.objects.first()
 | 
					 | 
				
			||||||
        tenant.flow_recovery = flow
 | 
					        tenant.flow_recovery = flow
 | 
				
			||||||
        tenant.save()
 | 
					        tenant.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										57
									
								
								authentik/core/tests/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								authentik/core/tests/utils.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,57 @@
 | 
				
			|||||||
 | 
					"""Test Utils"""
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.utils.text import slugify
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import Group, User
 | 
				
			||||||
 | 
					from authentik.crypto.builder import CertificateBuilder
 | 
				
			||||||
 | 
					from authentik.crypto.models import CertificateKeyPair
 | 
				
			||||||
 | 
					from authentik.flows.models import Flow, FlowDesignation
 | 
				
			||||||
 | 
					from authentik.lib.generators import generate_id
 | 
				
			||||||
 | 
					from authentik.tenants.models import Tenant
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_test_flow(designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION) -> Flow:
 | 
				
			||||||
 | 
					    """Generate a flow that can be used for testing"""
 | 
				
			||||||
 | 
					    uid = generate_id(10)
 | 
				
			||||||
 | 
					    return Flow.objects.create(
 | 
				
			||||||
 | 
					        name=uid,
 | 
				
			||||||
 | 
					        title=uid,
 | 
				
			||||||
 | 
					        slug=slugify(uid),
 | 
				
			||||||
 | 
					        designation=designation,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_test_admin_user(name: Optional[str] = None) -> User:
 | 
				
			||||||
 | 
					    """Generate a test-admin user"""
 | 
				
			||||||
 | 
					    uid = generate_id(20) if not name else name
 | 
				
			||||||
 | 
					    group = Group.objects.create(name=uid, is_superuser=True)
 | 
				
			||||||
 | 
					    user: User = User.objects.create(
 | 
				
			||||||
 | 
					        username=uid,
 | 
				
			||||||
 | 
					        name=uid,
 | 
				
			||||||
 | 
					        email=f"{uid}@goauthentik.io",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    user.set_password(uid)
 | 
				
			||||||
 | 
					    user.save()
 | 
				
			||||||
 | 
					    group.users.add(user)
 | 
				
			||||||
 | 
					    return user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_test_tenant() -> Tenant:
 | 
				
			||||||
 | 
					    """Generate a test tenant, removing all other tenants to make sure this one
 | 
				
			||||||
 | 
					    matches."""
 | 
				
			||||||
 | 
					    uid = generate_id(20)
 | 
				
			||||||
 | 
					    Tenant.objects.all().delete()
 | 
				
			||||||
 | 
					    return Tenant.objects.create(domain=uid, default=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def create_test_cert() -> CertificateKeyPair:
 | 
				
			||||||
 | 
					    """Generate a certificate for testing"""
 | 
				
			||||||
 | 
					    CertificateKeyPair.objects.filter(name="goauthentik.io").delete()
 | 
				
			||||||
 | 
					    builder = CertificateBuilder()
 | 
				
			||||||
 | 
					    builder.common_name = "goauthentik.io"
 | 
				
			||||||
 | 
					    builder.build(
 | 
				
			||||||
 | 
					        subject_alt_names=["goauthentik.io"],
 | 
				
			||||||
 | 
					        validity_days=360,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    return builder.save()
 | 
				
			||||||
@ -29,3 +29,4 @@ class UserSettingSerializer(PassiveSerializer):
 | 
				
			|||||||
    component = CharField()
 | 
					    component = CharField()
 | 
				
			||||||
    title = CharField()
 | 
					    title = CharField()
 | 
				
			||||||
    configure_url = CharField(required=False)
 | 
					    configure_url = CharField(required=False)
 | 
				
			||||||
 | 
					    icon_url = CharField()
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,6 @@
 | 
				
			|||||||
"""Crypto API Views"""
 | 
					"""Crypto API Views"""
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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
 | 
				
			||||||
@ -15,14 +17,18 @@ from rest_framework.request import Request
 | 
				
			|||||||
from rest_framework.response import Response
 | 
					from rest_framework.response import Response
 | 
				
			||||||
from rest_framework.serializers import ModelSerializer, ValidationError
 | 
					from rest_framework.serializers import ModelSerializer, ValidationError
 | 
				
			||||||
from rest_framework.viewsets import ModelViewSet
 | 
					from rest_framework.viewsets import ModelViewSet
 | 
				
			||||||
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.api.decorators import permission_required
 | 
					from authentik.api.decorators import permission_required
 | 
				
			||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
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.managed import MANAGED_KEY
 | 
				
			||||||
from authentik.crypto.models import CertificateKeyPair
 | 
					from authentik.crypto.models import CertificateKeyPair
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CertificateKeyPairSerializer(ModelSerializer):
 | 
					class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			||||||
    """CertificateKeyPair Serializer"""
 | 
					    """CertificateKeyPair Serializer"""
 | 
				
			||||||
@ -30,6 +36,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
    cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True)
 | 
					    cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True)
 | 
				
			||||||
    cert_subject = SerializerMethodField()
 | 
					    cert_subject = SerializerMethodField()
 | 
				
			||||||
    private_key_available = SerializerMethodField()
 | 
					    private_key_available = SerializerMethodField()
 | 
				
			||||||
 | 
					    private_key_type = SerializerMethodField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    certificate_download_url = SerializerMethodField()
 | 
					    certificate_download_url = SerializerMethodField()
 | 
				
			||||||
    private_key_download_url = SerializerMethodField()
 | 
					    private_key_download_url = SerializerMethodField()
 | 
				
			||||||
@ -42,6 +49,13 @@ 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 get_private_key_type(self, instance: CertificateKeyPair) -> Optional[str]:
 | 
				
			||||||
 | 
					        """Get the private key's type, if set"""
 | 
				
			||||||
 | 
					        key = instance.private_key
 | 
				
			||||||
 | 
					        if key:
 | 
				
			||||||
 | 
					            return key.__class__.__name__.replace("_", "").lower().replace("privatekey", "")
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
 | 
					    def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
 | 
				
			||||||
        """Get URL to download certificate"""
 | 
					        """Get URL to download certificate"""
 | 
				
			||||||
        return (
 | 
					        return (
 | 
				
			||||||
@ -65,22 +79,30 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
    def validate_certificate_data(self, value: str) -> str:
 | 
					    def validate_certificate_data(self, value: str) -> str:
 | 
				
			||||||
        """Verify that input is a valid PEM x509 Certificate"""
 | 
					        """Verify that input is a valid PEM x509 Certificate"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            load_pem_x509_certificate(value.encode("utf-8"), default_backend())
 | 
					            # Cast to string to fully load and parse certificate
 | 
				
			||||||
        except ValueError:
 | 
					            # Prevents issues like https://github.com/goauthentik/authentik/issues/2082
 | 
				
			||||||
 | 
					            str(load_pem_x509_certificate(value.encode("utf-8"), default_backend()))
 | 
				
			||||||
 | 
					        except ValueError as exc:
 | 
				
			||||||
 | 
					            LOGGER.warning("Failed to load certificate", exc=exc)
 | 
				
			||||||
            raise ValidationError("Unable to load certificate.")
 | 
					            raise ValidationError("Unable to load certificate.")
 | 
				
			||||||
        return value
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def validate_key_data(self, value: str) -> str:
 | 
					    def validate_key_data(self, value: str) -> str:
 | 
				
			||||||
        """Verify that input is a valid PEM RSA Key"""
 | 
					        """Verify that input is a valid PEM Key"""
 | 
				
			||||||
        # Since this field is optional, data can be empty.
 | 
					        # Since this field is optional, data can be empty.
 | 
				
			||||||
        if value != "":
 | 
					        if value != "":
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                load_pem_private_key(
 | 
					                # Cast to string to fully load and parse certificate
 | 
				
			||||||
                    str.encode("\n".join([x.strip() for x in value.split("\n")])),
 | 
					                # Prevents issues like https://github.com/goauthentik/authentik/issues/2082
 | 
				
			||||||
                    password=None,
 | 
					                str(
 | 
				
			||||||
                    backend=default_backend(),
 | 
					                    load_pem_private_key(
 | 
				
			||||||
 | 
					                        str.encode("\n".join([x.strip() for x in value.split("\n")])),
 | 
				
			||||||
 | 
					                        password=None,
 | 
				
			||||||
 | 
					                        backend=default_backend(),
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            except (ValueError, TypeError):
 | 
					            except (ValueError, TypeError) as exc:
 | 
				
			||||||
 | 
					                LOGGER.warning("Failed to load private key", exc=exc)
 | 
				
			||||||
                raise ValidationError("Unable to load private key (possibly encrypted?).")
 | 
					                raise ValidationError("Unable to load private key (possibly encrypted?).")
 | 
				
			||||||
        return value
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -97,6 +119,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
				
			|||||||
            "cert_expiry",
 | 
					            "cert_expiry",
 | 
				
			||||||
            "cert_subject",
 | 
					            "cert_subject",
 | 
				
			||||||
            "private_key_available",
 | 
					            "private_key_available",
 | 
				
			||||||
 | 
					            "private_key_type",
 | 
				
			||||||
            "certificate_download_url",
 | 
					            "certificate_download_url",
 | 
				
			||||||
            "private_key_download_url",
 | 
					            "private_key_download_url",
 | 
				
			||||||
            "managed",
 | 
					            "managed",
 | 
				
			||||||
@ -141,9 +164,11 @@ class CertificateKeyPairFilter(FilterSet):
 | 
				
			|||||||
class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
					class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
				
			||||||
    """CertificateKeyPair Viewset"""
 | 
					    """CertificateKeyPair Viewset"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    queryset = CertificateKeyPair.objects.exclude(managed__isnull=False)
 | 
					    queryset = CertificateKeyPair.objects.exclude(managed=MANAGED_KEY)
 | 
				
			||||||
    serializer_class = CertificateKeyPairSerializer
 | 
					    serializer_class = CertificateKeyPairSerializer
 | 
				
			||||||
    filterset_class = CertificateKeyPairFilter
 | 
					    filterset_class = CertificateKeyPairFilter
 | 
				
			||||||
 | 
					    ordering = ["name"]
 | 
				
			||||||
 | 
					    search_fields = ["name"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @permission_required(None, ["authentik_crypto.add_certificatekeypair"])
 | 
					    @permission_required(None, ["authentik_crypto.add_certificatekeypair"])
 | 
				
			||||||
    @extend_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
@ -189,7 +214,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
            secret=certificate,
 | 
					            secret=certificate,
 | 
				
			||||||
            type="certificate",
 | 
					            type="certificate",
 | 
				
			||||||
        ).from_http(request)
 | 
					        ).from_http(request)
 | 
				
			||||||
        if "download" in request._request.GET:
 | 
					        if "download" in request.query_params:
 | 
				
			||||||
            # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
 | 
					            # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
 | 
				
			||||||
            response = HttpResponse(
 | 
					            response = HttpResponse(
 | 
				
			||||||
                certificate.certificate_data, content_type="application/x-pem-file"
 | 
					                certificate.certificate_data, content_type="application/x-pem-file"
 | 
				
			||||||
@ -220,7 +245,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
            secret=certificate,
 | 
					            secret=certificate,
 | 
				
			||||||
            type="private_key",
 | 
					            type="private_key",
 | 
				
			||||||
        ).from_http(request)
 | 
					        ).from_http(request)
 | 
				
			||||||
        if "download" in request._request.GET:
 | 
					        if "download" in request.query_params:
 | 
				
			||||||
            # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
 | 
					            # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
 | 
				
			||||||
            response = HttpResponse(certificate.key_data, content_type="application/x-pem-file")
 | 
					            response = HttpResponse(certificate.key_data, content_type="application/x-pem-file")
 | 
				
			||||||
            response[
 | 
					            response[
 | 
				
			||||||
 | 
				
			|||||||
@ -13,3 +13,4 @@ class AuthentikCryptoConfig(AppConfig):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def ready(self):
 | 
					    def ready(self):
 | 
				
			||||||
        import_module("authentik.crypto.managed")
 | 
					        import_module("authentik.crypto.managed")
 | 
				
			||||||
 | 
					        import_module("authentik.crypto.tasks")
 | 
				
			||||||
 | 
				
			|||||||
@ -44,7 +44,7 @@ class CertificateBuilder:
 | 
				
			|||||||
        """Build self-signed certificate"""
 | 
					        """Build self-signed certificate"""
 | 
				
			||||||
        one_day = datetime.timedelta(1, 0, 0)
 | 
					        one_day = datetime.timedelta(1, 0, 0)
 | 
				
			||||||
        self.__private_key = rsa.generate_private_key(
 | 
					        self.__private_key = rsa.generate_private_key(
 | 
				
			||||||
            public_exponent=65537, key_size=2048, backend=default_backend()
 | 
					            public_exponent=65537, key_size=4096, backend=default_backend()
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        self.__public_key = self.__private_key.public_key()
 | 
					        self.__public_key = self.__private_key.public_key()
 | 
				
			||||||
        alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
 | 
					        alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
 | 
				
			||||||
 | 
				
			|||||||
@ -6,15 +6,23 @@ from uuid import uuid4
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from cryptography.hazmat.backends import default_backend
 | 
					from cryptography.hazmat.backends import default_backend
 | 
				
			||||||
from cryptography.hazmat.primitives import hashes
 | 
					from cryptography.hazmat.primitives import hashes
 | 
				
			||||||
 | 
					from cryptography.hazmat.primitives.asymmetric.ec import (
 | 
				
			||||||
 | 
					    EllipticCurvePrivateKey,
 | 
				
			||||||
 | 
					    EllipticCurvePublicKey,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
 | 
				
			||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
 | 
					from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
 | 
				
			||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
 | 
					from cryptography.hazmat.primitives.serialization import load_pem_private_key
 | 
				
			||||||
from cryptography.x509 import Certificate, load_pem_x509_certificate
 | 
					from cryptography.x509 import Certificate, load_pem_x509_certificate
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.lib.models import CreatedUpdatedModel
 | 
					from authentik.lib.models import CreatedUpdatedModel
 | 
				
			||||||
from authentik.managed.models import ManagedModel
 | 
					from authentik.managed.models import ManagedModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
 | 
					class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
 | 
				
			||||||
    """CertificateKeyPair that can be used for signing or encrypting if `key_data`
 | 
					    """CertificateKeyPair that can be used for signing or encrypting if `key_data`
 | 
				
			||||||
@ -33,8 +41,8 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _cert: Optional[Certificate] = None
 | 
					    _cert: Optional[Certificate] = None
 | 
				
			||||||
    _private_key: Optional[RSAPrivateKey] = None
 | 
					    _private_key: Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey] = None
 | 
				
			||||||
    _public_key: Optional[RSAPublicKey] = None
 | 
					    _public_key: Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def certificate(self) -> Certificate:
 | 
					    def certificate(self) -> Certificate:
 | 
				
			||||||
@ -46,23 +54,26 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
 | 
				
			|||||||
        return self._cert
 | 
					        return self._cert
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def public_key(self) -> Optional[RSAPublicKey]:
 | 
					    def public_key(self) -> Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey]:
 | 
				
			||||||
        """Get public key of the private key"""
 | 
					        """Get public key of the private key"""
 | 
				
			||||||
        if not self._public_key:
 | 
					        if not self._public_key:
 | 
				
			||||||
            self._public_key = self.private_key.public_key()
 | 
					            self._public_key = self.private_key.public_key()
 | 
				
			||||||
        return self._public_key
 | 
					        return self._public_key
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def private_key(self) -> Optional[RSAPrivateKey]:
 | 
					    def private_key(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					    ) -> Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey]:
 | 
				
			||||||
        """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.key_data != "":
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                self._private_key = load_pem_private_key(
 | 
					                self._private_key = load_pem_private_key(
 | 
				
			||||||
                    str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
 | 
					                    str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
 | 
				
			||||||
                    password=None,
 | 
					                    password=None,
 | 
				
			||||||
                    backend=default_backend(),
 | 
					                    backend=default_backend(),
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            except ValueError:
 | 
					            except ValueError as exc:
 | 
				
			||||||
 | 
					                LOGGER.warning(exc)
 | 
				
			||||||
                return None
 | 
					                return None
 | 
				
			||||||
        return self._private_key
 | 
					        return self._private_key
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										10
									
								
								authentik/crypto/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/crypto/settings.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					"""Crypto task Settings"""
 | 
				
			||||||
 | 
					from celery.schedules import crontab
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					CELERY_BEAT_SCHEDULE = {
 | 
				
			||||||
 | 
					    "crypto_certificate_discovery": {
 | 
				
			||||||
 | 
					        "task": "authentik.crypto.tasks.certificate_discovery",
 | 
				
			||||||
 | 
					        "schedule": crontab(minute="*/5"),
 | 
				
			||||||
 | 
					        "options": {"queue": "authentik_scheduled"},
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										95
									
								
								authentik/crypto/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								authentik/crypto/tasks.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,95 @@
 | 
				
			|||||||
 | 
					"""Crypto tasks"""
 | 
				
			||||||
 | 
					from glob import glob
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from cryptography.hazmat.backends import default_backend
 | 
				
			||||||
 | 
					from cryptography.hazmat.primitives.serialization import load_pem_private_key
 | 
				
			||||||
 | 
					from cryptography.x509.base import load_pem_x509_certificate
 | 
				
			||||||
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.crypto.models import CertificateKeyPair
 | 
				
			||||||
 | 
					from authentik.events.monitored_tasks import (
 | 
				
			||||||
 | 
					    MonitoredTask,
 | 
				
			||||||
 | 
					    TaskResult,
 | 
				
			||||||
 | 
					    TaskResultStatus,
 | 
				
			||||||
 | 
					    prefill_task,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
 | 
					from authentik.root.celery import CELERY_APP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def ensure_private_key_valid(body: str):
 | 
				
			||||||
 | 
					    """Attempt loading of a PEM Private key without password"""
 | 
				
			||||||
 | 
					    load_pem_private_key(
 | 
				
			||||||
 | 
					        str.encode("\n".join([x.strip() for x in body.split("\n")])),
 | 
				
			||||||
 | 
					        password=None,
 | 
				
			||||||
 | 
					        backend=default_backend(),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    return body
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def ensure_certificate_valid(body: str):
 | 
				
			||||||
 | 
					    """Attempt loading of a PEM-encoded certificate"""
 | 
				
			||||||
 | 
					    load_pem_x509_certificate(body.encode("utf-8"), default_backend())
 | 
				
			||||||
 | 
					    return body
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
				
			||||||
 | 
					@prefill_task
 | 
				
			||||||
 | 
					def certificate_discovery(self: MonitoredTask):
 | 
				
			||||||
 | 
					    """Discover, import and update certificates from the filesystem"""
 | 
				
			||||||
 | 
					    certs = {}
 | 
				
			||||||
 | 
					    private_keys = {}
 | 
				
			||||||
 | 
					    discovered = 0
 | 
				
			||||||
 | 
					    for file in glob(CONFIG.y("cert_discovery_dir") + "/**", recursive=True):
 | 
				
			||||||
 | 
					        path = Path(file)
 | 
				
			||||||
 | 
					        if not path.exists():
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        if path.is_dir():
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        # For certbot setups, we want to ignore archive.
 | 
				
			||||||
 | 
					        if "archive" in file:
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        # Support certbot's directory structure
 | 
				
			||||||
 | 
					        if path.name in ["fullchain.pem", "privkey.pem"]:
 | 
				
			||||||
 | 
					            cert_name = path.parent.name
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            cert_name = path.name.replace(path.suffix, "")
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            with open(path, "r+", encoding="utf-8") as _file:
 | 
				
			||||||
 | 
					                body = _file.read()
 | 
				
			||||||
 | 
					                if "PRIVATE KEY" in body:
 | 
				
			||||||
 | 
					                    private_keys[cert_name] = ensure_private_key_valid(body)
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    certs[cert_name] = ensure_certificate_valid(body)
 | 
				
			||||||
 | 
					        except (OSError, ValueError) as exc:
 | 
				
			||||||
 | 
					            LOGGER.warning("Failed to open file or invalid format", exc=exc, file=path)
 | 
				
			||||||
 | 
					        discovered += 1
 | 
				
			||||||
 | 
					    for name, cert_data in certs.items():
 | 
				
			||||||
 | 
					        cert = CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % name).first()
 | 
				
			||||||
 | 
					        if not cert:
 | 
				
			||||||
 | 
					            cert = CertificateKeyPair(
 | 
				
			||||||
 | 
					                name=name,
 | 
				
			||||||
 | 
					                managed=MANAGED_DISCOVERED % name,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        dirty = False
 | 
				
			||||||
 | 
					        if cert.certificate_data != cert_data:
 | 
				
			||||||
 | 
					            cert.certificate_data = cert_data
 | 
				
			||||||
 | 
					            dirty = True
 | 
				
			||||||
 | 
					        if name in private_keys:
 | 
				
			||||||
 | 
					            if cert.key_data != private_keys[name]:
 | 
				
			||||||
 | 
					                cert.key_data = private_keys[name]
 | 
				
			||||||
 | 
					                dirty = True
 | 
				
			||||||
 | 
					        if dirty:
 | 
				
			||||||
 | 
					            cert.save()
 | 
				
			||||||
 | 
					    self.set_status(
 | 
				
			||||||
 | 
					        TaskResult(
 | 
				
			||||||
 | 
					            TaskResultStatus.SUCCESSFUL,
 | 
				
			||||||
 | 
					            messages=[_("Successfully imported %(count)d files." % {"count": discovered})],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
@ -1,25 +1,37 @@
 | 
				
			|||||||
"""Crypto tests"""
 | 
					"""Crypto tests"""
 | 
				
			||||||
import datetime
 | 
					import datetime
 | 
				
			||||||
 | 
					from os import makedirs
 | 
				
			||||||
 | 
					from tempfile import TemporaryDirectory
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.test import TestCase
 | 
					 | 
				
			||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.api.used_by import DeleteAction
 | 
					from authentik.core.api.used_by import DeleteAction
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
 | 
				
			||||||
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.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
 | 
				
			||||||
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
from authentik.lib.generators import generate_key
 | 
					from authentik.lib.generators import generate_key
 | 
				
			||||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
					from authentik.providers.oauth2.models import OAuth2Provider
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestCrypto(TestCase):
 | 
					class TestCrypto(APITestCase):
 | 
				
			||||||
    """Test Crypto validation"""
 | 
					    """Test Crypto validation"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_model_private(self):
 | 
				
			||||||
 | 
					        """Test model private key"""
 | 
				
			||||||
 | 
					        cert = CertificateKeyPair.objects.create(
 | 
				
			||||||
 | 
					            name="test",
 | 
				
			||||||
 | 
					            certificate_data="foo",
 | 
				
			||||||
 | 
					            key_data="foo",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertIsNone(cert.private_key)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_serializer(self):
 | 
					    def test_serializer(self):
 | 
				
			||||||
        """Test API Validation"""
 | 
					        """Test API Validation"""
 | 
				
			||||||
        keypair = CertificateKeyPair.objects.first()
 | 
					        keypair = create_test_cert()
 | 
				
			||||||
        self.assertTrue(
 | 
					        self.assertTrue(
 | 
				
			||||||
            CertificateKeyPairSerializer(
 | 
					            CertificateKeyPairSerializer(
 | 
				
			||||||
                data={
 | 
					                data={
 | 
				
			||||||
@ -54,10 +66,38 @@ class TestCrypto(TestCase):
 | 
				
			|||||||
        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_builder_api(self):
 | 
				
			||||||
 | 
					        """Test Builder (via API)"""
 | 
				
			||||||
 | 
					        self.client.force_login(create_test_admin_user())
 | 
				
			||||||
 | 
					        self.client.post(
 | 
				
			||||||
 | 
					            reverse("authentik_api:certificatekeypair-generate"),
 | 
				
			||||||
 | 
					            data={"common_name": "foo", "subject_alt_name": "bar,baz", "validity_days": 3},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertTrue(CertificateKeyPair.objects.filter(name="foo").exists())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_builder_api_invalid(self):
 | 
				
			||||||
 | 
					        """Test Builder (via API) (invalid)"""
 | 
				
			||||||
 | 
					        self.client.force_login(create_test_admin_user())
 | 
				
			||||||
 | 
					        response = self.client.post(
 | 
				
			||||||
 | 
					            reverse("authentik_api:certificatekeypair-generate"),
 | 
				
			||||||
 | 
					            data={},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(response.status_code, 400)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_list(self):
 | 
				
			||||||
 | 
					        """Test API List"""
 | 
				
			||||||
 | 
					        self.client.force_login(create_test_admin_user())
 | 
				
			||||||
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
					            reverse(
 | 
				
			||||||
 | 
					                "authentik_api:certificatekeypair-list",
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertEqual(200, response.status_code)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_certificate_download(self):
 | 
					    def test_certificate_download(self):
 | 
				
			||||||
        """Test certificate export (download)"""
 | 
					        """Test certificate export (download)"""
 | 
				
			||||||
        self.client.force_login(User.objects.get(username="akadmin"))
 | 
					        self.client.force_login(create_test_admin_user())
 | 
				
			||||||
        keypair = CertificateKeyPair.objects.first()
 | 
					        keypair = create_test_cert()
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
                "authentik_api:certificatekeypair-view-certificate",
 | 
					                "authentik_api:certificatekeypair-view-certificate",
 | 
				
			||||||
@ -77,8 +117,8 @@ class TestCrypto(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_private_key_download(self):
 | 
					    def test_private_key_download(self):
 | 
				
			||||||
        """Test private_key export (download)"""
 | 
					        """Test private_key export (download)"""
 | 
				
			||||||
        self.client.force_login(User.objects.get(username="akadmin"))
 | 
					        self.client.force_login(create_test_admin_user())
 | 
				
			||||||
        keypair = CertificateKeyPair.objects.first()
 | 
					        keypair = create_test_cert()
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
                "authentik_api:certificatekeypair-view-private-key",
 | 
					                "authentik_api:certificatekeypair-view-private-key",
 | 
				
			||||||
@ -98,15 +138,15 @@ class TestCrypto(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_used_by(self):
 | 
					    def test_used_by(self):
 | 
				
			||||||
        """Test used_by endpoint"""
 | 
					        """Test used_by endpoint"""
 | 
				
			||||||
        self.client.force_login(User.objects.get(username="akadmin"))
 | 
					        self.client.force_login(create_test_admin_user())
 | 
				
			||||||
        keypair = CertificateKeyPair.objects.first()
 | 
					        keypair = create_test_cert()
 | 
				
			||||||
        provider = OAuth2Provider.objects.create(
 | 
					        provider = OAuth2Provider.objects.create(
 | 
				
			||||||
            name="test",
 | 
					            name="test",
 | 
				
			||||||
            client_id="test",
 | 
					            client_id="test",
 | 
				
			||||||
            client_secret=generate_key(),
 | 
					            client_secret=generate_key(),
 | 
				
			||||||
            authorization_flow=Flow.objects.first(),
 | 
					            authorization_flow=create_test_flow(),
 | 
				
			||||||
            redirect_uris="http://localhost",
 | 
					            redirect_uris="http://localhost",
 | 
				
			||||||
            rsa_key=CertificateKeyPair.objects.first(),
 | 
					            signing_key=keypair,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
            reverse(
 | 
					            reverse(
 | 
				
			||||||
@ -127,3 +167,36 @@ class TestCrypto(TestCase):
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_discovery(self):
 | 
				
			||||||
 | 
					        """Test certificate discovery"""
 | 
				
			||||||
 | 
					        builder = CertificateBuilder()
 | 
				
			||||||
 | 
					        builder.common_name = "test-cert"
 | 
				
			||||||
 | 
					        with self.assertRaises(ValueError):
 | 
				
			||||||
 | 
					            builder.save()
 | 
				
			||||||
 | 
					        builder.build(
 | 
				
			||||||
 | 
					            subject_alt_names=[],
 | 
				
			||||||
 | 
					            validity_days=3,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        with TemporaryDirectory() as temp_dir:
 | 
				
			||||||
 | 
					            with open(f"{temp_dir}/foo.pem", "w+", encoding="utf-8") as _cert:
 | 
				
			||||||
 | 
					                _cert.write(builder.certificate)
 | 
				
			||||||
 | 
					            with open(f"{temp_dir}/foo.key", "w+", encoding="utf-8") as _key:
 | 
				
			||||||
 | 
					                _key.write(builder.private_key)
 | 
				
			||||||
 | 
					            makedirs(f"{temp_dir}/foo.bar", exist_ok=True)
 | 
				
			||||||
 | 
					            with open(f"{temp_dir}/foo.bar/fullchain.pem", "w+", encoding="utf-8") as _cert:
 | 
				
			||||||
 | 
					                _cert.write(builder.certificate)
 | 
				
			||||||
 | 
					            with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
 | 
				
			||||||
 | 
					                _key.write(builder.private_key)
 | 
				
			||||||
 | 
					            with CONFIG.patch("cert_discovery_dir", temp_dir):
 | 
				
			||||||
 | 
					                # pyright: reportGeneralTypeIssues=false
 | 
				
			||||||
 | 
					                certificate_discovery()  # pylint: disable=no-value-for-parameter
 | 
				
			||||||
 | 
					        keypair: CertificateKeyPair = CertificateKeyPair.objects.filter(
 | 
				
			||||||
 | 
					            managed=MANAGED_DISCOVERED % "foo"
 | 
				
			||||||
 | 
					        ).first()
 | 
				
			||||||
 | 
					        self.assertIsNotNone(keypair)
 | 
				
			||||||
 | 
					        self.assertIsNotNone(keypair.certificate)
 | 
				
			||||||
 | 
					        self.assertIsNotNone(keypair.private_key)
 | 
				
			||||||
 | 
					        self.assertTrue(
 | 
				
			||||||
 | 
					            CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo.bar").exists()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,6 @@
 | 
				
			|||||||
"""Events API Views"""
 | 
					"""Events API Views"""
 | 
				
			||||||
 | 
					from json import loads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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
 | 
				
			||||||
@ -12,6 +14,7 @@ from rest_framework.response import Response
 | 
				
			|||||||
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.admin.api.metrics import CoordinateSerializer
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -110,13 +113,20 @@ class EventViewSet(ModelViewSet):
 | 
				
			|||||||
    @extend_schema(
 | 
					    @extend_schema(
 | 
				
			||||||
        methods=["GET"],
 | 
					        methods=["GET"],
 | 
				
			||||||
        responses={200: EventTopPerUserSerializer(many=True)},
 | 
					        responses={200: EventTopPerUserSerializer(many=True)},
 | 
				
			||||||
 | 
					        filters=[],
 | 
				
			||||||
        parameters=[
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                "action",
 | 
				
			||||||
 | 
					                type=OpenApiTypes.STR,
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                required=False,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
            OpenApiParameter(
 | 
					            OpenApiParameter(
 | 
				
			||||||
                "top_n",
 | 
					                "top_n",
 | 
				
			||||||
                type=OpenApiTypes.INT,
 | 
					                type=OpenApiTypes.INT,
 | 
				
			||||||
                location=OpenApiParameter.QUERY,
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
                required=False,
 | 
					                required=False,
 | 
				
			||||||
            )
 | 
					            ),
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    @action(detail=False, methods=["GET"], pagination_class=None)
 | 
					    @action(detail=False, methods=["GET"], pagination_class=None)
 | 
				
			||||||
@ -137,6 +147,40 @@ class EventViewSet(ModelViewSet):
 | 
				
			|||||||
            .order_by("-counted_events")[:top_n]
 | 
					            .order_by("-counted_events")[:top_n]
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @extend_schema(
 | 
				
			||||||
 | 
					        methods=["GET"],
 | 
				
			||||||
 | 
					        responses={200: CoordinateSerializer(many=True)},
 | 
				
			||||||
 | 
					        filters=[],
 | 
				
			||||||
 | 
					        parameters=[
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                "action",
 | 
				
			||||||
 | 
					                type=OpenApiTypes.STR,
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                required=False,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					            OpenApiParameter(
 | 
				
			||||||
 | 
					                "query",
 | 
				
			||||||
 | 
					                type=OpenApiTypes.STR,
 | 
				
			||||||
 | 
					                location=OpenApiParameter.QUERY,
 | 
				
			||||||
 | 
					                required=False,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    @action(detail=False, methods=["GET"], pagination_class=None)
 | 
				
			||||||
 | 
					    def per_month(self, request: Request):
 | 
				
			||||||
 | 
					        """Get the count of events per month"""
 | 
				
			||||||
 | 
					        filtered_action = request.query_params.get("action", EventAction.LOGIN)
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            query = loads(request.query_params.get("query", "{}"))
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            return Response(status=400)
 | 
				
			||||||
 | 
					        return Response(
 | 
				
			||||||
 | 
					            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
				
			||||||
 | 
					            .filter(action=filtered_action)
 | 
				
			||||||
 | 
					            .filter(**query)
 | 
				
			||||||
 | 
					            .get_events_per_day()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @extend_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:
 | 
				
			||||||
 | 
				
			|||||||
@ -15,12 +15,14 @@ from authentik.api.decorators import permission_required
 | 
				
			|||||||
from authentik.core.api.used_by import UsedByMixin
 | 
					from authentik.core.api.used_by import UsedByMixin
 | 
				
			||||||
from authentik.core.api.utils import PassiveSerializer
 | 
					from authentik.core.api.utils import PassiveSerializer
 | 
				
			||||||
from authentik.events.models import (
 | 
					from authentik.events.models import (
 | 
				
			||||||
 | 
					    Event,
 | 
				
			||||||
    Notification,
 | 
					    Notification,
 | 
				
			||||||
    NotificationSeverity,
 | 
					    NotificationSeverity,
 | 
				
			||||||
    NotificationTransport,
 | 
					    NotificationTransport,
 | 
				
			||||||
    NotificationTransportError,
 | 
					    NotificationTransportError,
 | 
				
			||||||
    TransportMode,
 | 
					    TransportMode,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from authentik.events.utils import get_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class NotificationTransportSerializer(ModelSerializer):
 | 
					class NotificationTransportSerializer(ModelSerializer):
 | 
				
			||||||
@ -86,6 +88,12 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
 | 
				
			|||||||
            severity=NotificationSeverity.NOTICE,
 | 
					            severity=NotificationSeverity.NOTICE,
 | 
				
			||||||
            body=f"Test Notification from transport {transport.name}",
 | 
					            body=f"Test Notification from transport {transport.name}",
 | 
				
			||||||
            user=request.user,
 | 
					            user=request.user,
 | 
				
			||||||
 | 
					            event=Event(
 | 
				
			||||||
 | 
					                action="Test",
 | 
				
			||||||
 | 
					                user=get_user(request.user),
 | 
				
			||||||
 | 
					                app=self.__class__.__module__,
 | 
				
			||||||
 | 
					                context={"foo": "bar"},
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            response = NotificationTransportTestSerializer(
 | 
					            response = NotificationTransportTestSerializer(
 | 
				
			||||||
 | 
				
			|||||||
@ -7,6 +7,7 @@ from typing import Optional, TypedDict
 | 
				
			|||||||
from geoip2.database import Reader
 | 
					from geoip2.database import Reader
 | 
				
			||||||
from geoip2.errors import GeoIP2Error
 | 
					from geoip2.errors import GeoIP2Error
 | 
				
			||||||
from geoip2.models import City
 | 
					from geoip2.models import City
 | 
				
			||||||
 | 
					from sentry_sdk.hub import Hub
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
@ -34,12 +35,11 @@ class GeoIPReader:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def __open(self):
 | 
					    def __open(self):
 | 
				
			||||||
        """Get GeoIP Reader, if configured, otherwise none"""
 | 
					        """Get GeoIP Reader, if configured, otherwise none"""
 | 
				
			||||||
        path = CONFIG.y("authentik.geoip")
 | 
					        path = CONFIG.y("geoip")
 | 
				
			||||||
        if path == "" or not path:
 | 
					        if path == "" or not path:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            reader = Reader(path)
 | 
					            self.__reader = Reader(path)
 | 
				
			||||||
            self.__reader = reader
 | 
					 | 
				
			||||||
            self.__last_mtime = stat(path).st_mtime
 | 
					            self.__last_mtime = stat(path).st_mtime
 | 
				
			||||||
            LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime)
 | 
					            LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime)
 | 
				
			||||||
        except OSError as exc:
 | 
					        except OSError as exc:
 | 
				
			||||||
@ -62,13 +62,17 @@ class GeoIPReader:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def city(self, ip_address: str) -> Optional[City]:
 | 
					    def city(self, ip_address: str) -> Optional[City]:
 | 
				
			||||||
        """Wrapper for Reader.city"""
 | 
					        """Wrapper for Reader.city"""
 | 
				
			||||||
        if not self.enabled:
 | 
					        with Hub.current.start_span(
 | 
				
			||||||
            return None
 | 
					            op="authentik.events.geo.city",
 | 
				
			||||||
        self.__check_expired()
 | 
					            description=ip_address,
 | 
				
			||||||
        try:
 | 
					        ):
 | 
				
			||||||
            return self.__reader.city(ip_address)
 | 
					            if not self.enabled:
 | 
				
			||||||
        except (GeoIP2Error, ValueError):
 | 
					                return None
 | 
				
			||||||
            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]:
 | 
					    def city_dict(self, ip_address: str) -> Optional[GeoIPDict]:
 | 
				
			||||||
        """Wrapper for self.city that returns a dict"""
 | 
					        """Wrapper for self.city that returns a dict"""
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,6 @@ import uuid
 | 
				
			|||||||
from datetime import timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
from typing import Iterable
 | 
					from typing import Iterable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import django.core.validators
 | 
					 | 
				
			||||||
import django.db.models.deletion
 | 
					import django.db.models.deletion
 | 
				
			||||||
from django.apps.registry import Apps
 | 
					from django.apps.registry import Apps
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
@ -12,6 +11,7 @@ from django.db import migrations, models
 | 
				
			|||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
					from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import authentik.events.models
 | 
					import authentik.events.models
 | 
				
			||||||
 | 
					import authentik.lib.models
 | 
				
			||||||
from authentik.events.models import EventAction, NotificationSeverity, TransportMode
 | 
					from authentik.events.models import EventAction, NotificationSeverity, TransportMode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -19,7 +19,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			|||||||
    Event = apps.get_model("authentik_events", "Event")
 | 
					    Event = apps.get_model("authentik_events", "Event")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    db_alias = schema_editor.connection.alias
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
    for event in Event.objects.all():
 | 
					    for event in Event.objects.using(db_alias).all():
 | 
				
			||||||
        event.delete()
 | 
					        event.delete()
 | 
				
			||||||
        # Because event objects cannot be updated, we have to re-create them
 | 
					        # Because event objects cannot be updated, we have to re-create them
 | 
				
			||||||
        event.pk = None
 | 
					        event.pk = None
 | 
				
			||||||
@ -314,169 +314,10 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
            old_name="user_json",
 | 
					            old_name="user_json",
 | 
				
			||||||
            new_name="user",
 | 
					            new_name="user",
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        migrations.AlterField(
 | 
					 | 
				
			||||||
            model_name="event",
 | 
					 | 
				
			||||||
            name="action",
 | 
					 | 
				
			||||||
            field=models.TextField(
 | 
					 | 
				
			||||||
                choices=[
 | 
					 | 
				
			||||||
                    ("login", "Login"),
 | 
					 | 
				
			||||||
                    ("login_failed", "Login Failed"),
 | 
					 | 
				
			||||||
                    ("logout", "Logout"),
 | 
					 | 
				
			||||||
                    ("sign_up", "Sign Up"),
 | 
					 | 
				
			||||||
                    ("authorize_application", "Authorize Application"),
 | 
					 | 
				
			||||||
                    ("suspicious_request", "Suspicious Request"),
 | 
					 | 
				
			||||||
                    ("password_set", "Password Set"),
 | 
					 | 
				
			||||||
                    ("invitation_created", "Invite Created"),
 | 
					 | 
				
			||||||
                    ("invitation_used", "Invite Used"),
 | 
					 | 
				
			||||||
                    ("source_linked", "Source Linked"),
 | 
					 | 
				
			||||||
                    ("impersonation_started", "Impersonation Started"),
 | 
					 | 
				
			||||||
                    ("impersonation_ended", "Impersonation Ended"),
 | 
					 | 
				
			||||||
                    ("model_created", "Model Created"),
 | 
					 | 
				
			||||||
                    ("model_updated", "Model Updated"),
 | 
					 | 
				
			||||||
                    ("model_deleted", "Model Deleted"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                    ("invitation_created", "Invite Created"),
 | 
					 | 
				
			||||||
                    ("invitation_used", "Invite Used"),
 | 
					 | 
				
			||||||
                    ("authorize_application", "Authorize Application"),
 | 
					 | 
				
			||||||
                    ("source_linked", "Source Linked"),
 | 
					 | 
				
			||||||
                    ("impersonation_started", "Impersonation Started"),
 | 
					 | 
				
			||||||
                    ("impersonation_ended", "Impersonation Ended"),
 | 
					 | 
				
			||||||
                    ("model_created", "Model Created"),
 | 
					 | 
				
			||||||
                    ("model_updated", "Model Updated"),
 | 
					 | 
				
			||||||
                    ("model_deleted", "Model Deleted"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.RemoveField(
 | 
					        migrations.RemoveField(
 | 
				
			||||||
            model_name="event",
 | 
					            model_name="event",
 | 
				
			||||||
            name="date",
 | 
					            name="date",
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                    ("token_view", "Token View"),
 | 
					 | 
				
			||||||
                    ("invitation_created", "Invite Created"),
 | 
					 | 
				
			||||||
                    ("invitation_used", "Invite Used"),
 | 
					 | 
				
			||||||
                    ("authorize_application", "Authorize Application"),
 | 
					 | 
				
			||||||
                    ("source_linked", "Source Linked"),
 | 
					 | 
				
			||||||
                    ("impersonation_started", "Impersonation Started"),
 | 
					 | 
				
			||||||
                    ("impersonation_ended", "Impersonation Ended"),
 | 
					 | 
				
			||||||
                    ("model_created", "Model Created"),
 | 
					 | 
				
			||||||
                    ("model_updated", "Model Updated"),
 | 
					 | 
				
			||||||
                    ("model_deleted", "Model Deleted"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                    ("token_view", "Token View"),
 | 
					 | 
				
			||||||
                    ("invitation_created", "Invite Created"),
 | 
					 | 
				
			||||||
                    ("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"),
 | 
					 | 
				
			||||||
                    ("model_created", "Model Created"),
 | 
					 | 
				
			||||||
                    ("model_updated", "Model Updated"),
 | 
					 | 
				
			||||||
                    ("model_deleted", "Model Deleted"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                    ("token_view", "Token View"),
 | 
					 | 
				
			||||||
                    ("invitation_created", "Invite Created"),
 | 
					 | 
				
			||||||
                    ("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"),
 | 
					 | 
				
			||||||
                    ("model_created", "Model Created"),
 | 
					 | 
				
			||||||
                    ("model_updated", "Model Updated"),
 | 
					 | 
				
			||||||
                    ("model_deleted", "Model Deleted"),
 | 
					 | 
				
			||||||
                    ("update_available", "Update Available"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                    ("token_view", "Token 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"),
 | 
					 | 
				
			||||||
                    ("configuration_error", "Configuration Error"),
 | 
					 | 
				
			||||||
                    ("model_created", "Model Created"),
 | 
					 | 
				
			||||||
                    ("model_updated", "Model Updated"),
 | 
					 | 
				
			||||||
                    ("model_deleted", "Model Deleted"),
 | 
					 | 
				
			||||||
                    ("update_available", "Update Available"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.CreateModel(
 | 
					        migrations.CreateModel(
 | 
				
			||||||
            name="NotificationTransport",
 | 
					            name="NotificationTransport",
 | 
				
			||||||
            fields=[
 | 
					            fields=[
 | 
				
			||||||
@ -610,68 +451,6 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
                help_text="Only send notification once, for example when sending a webhook into a chat channel.",
 | 
					                help_text="Only send notification once, for example when sending a webhook into a chat channel.",
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                    ("token_view", "Token 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"),
 | 
					 | 
				
			||||||
                    ("update_available", "Update Available"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                    ("update_available", "Update Available"),
 | 
					 | 
				
			||||||
                    ("custom_", "Custom Prefix"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.RunPython(
 | 
					        migrations.RunPython(
 | 
				
			||||||
            code=token_view_to_secret_view,
 | 
					            code=token_view_to_secret_view,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
@ -688,76 +467,11 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
        migrations.RunPython(
 | 
					        migrations.RunPython(
 | 
				
			||||||
            code=update_expires,
 | 
					            code=update_expires,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
        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"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AddField(
 | 
					        migrations.AddField(
 | 
				
			||||||
            model_name="event",
 | 
					            model_name="event",
 | 
				
			||||||
            name="tenant",
 | 
					            name="tenant",
 | 
				
			||||||
            field=models.JSONField(blank=True, default=authentik.events.models.default_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"),
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
            ),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        migrations.AlterField(
 | 
					        migrations.AlterField(
 | 
				
			||||||
            model_name="event",
 | 
					            model_name="event",
 | 
				
			||||||
            name="action",
 | 
					            name="action",
 | 
				
			||||||
@ -776,6 +490,7 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
                    ("source_linked", "Source Linked"),
 | 
					                    ("source_linked", "Source Linked"),
 | 
				
			||||||
                    ("impersonation_started", "Impersonation Started"),
 | 
					                    ("impersonation_started", "Impersonation Started"),
 | 
				
			||||||
                    ("impersonation_ended", "Impersonation Ended"),
 | 
					                    ("impersonation_ended", "Impersonation Ended"),
 | 
				
			||||||
 | 
					                    ("flow_execution", "Flow Execution"),
 | 
				
			||||||
                    ("policy_execution", "Policy Execution"),
 | 
					                    ("policy_execution", "Policy Execution"),
 | 
				
			||||||
                    ("policy_exception", "Policy Exception"),
 | 
					                    ("policy_exception", "Policy Exception"),
 | 
				
			||||||
                    ("property_mapping_exception", "Property Mapping Exception"),
 | 
					                    ("property_mapping_exception", "Property Mapping Exception"),
 | 
				
			||||||
@ -826,6 +541,8 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
        migrations.AlterField(
 | 
					        migrations.AlterField(
 | 
				
			||||||
            model_name="notificationtransport",
 | 
					            model_name="notificationtransport",
 | 
				
			||||||
            name="webhook_url",
 | 
					            name="webhook_url",
 | 
				
			||||||
            field=models.TextField(blank=True, validators=[django.core.validators.URLValidator()]),
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                blank=True, validators=[authentik.lib.models.DomainlessURLValidator()]
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			|||||||
    Event = apps.get_model("authentik_events", "Event")
 | 
					    Event = apps.get_model("authentik_events", "Event")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    db_alias = schema_editor.connection.alias
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
    for event in Event.objects.all():
 | 
					    for event in Event.objects.using(db_alias).all():
 | 
				
			||||||
        event.delete()
 | 
					        event.delete()
 | 
				
			||||||
        # Because event objects cannot be updated, we have to re-create them
 | 
					        # Because event objects cannot be updated, we have to re-create them
 | 
				
			||||||
        event.pk = None
 | 
					        event.pk = None
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,9 @@
 | 
				
			|||||||
# Generated by Django 3.2.7 on 2021-10-04 15:31
 | 
					# Generated by Django 3.2.7 on 2021-10-04 15:31
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import django.core.validators
 | 
					 | 
				
			||||||
from django.db import migrations, models
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import authentik.lib.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Migration(migrations.Migration):
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -14,6 +15,8 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
        migrations.AlterField(
 | 
					        migrations.AlterField(
 | 
				
			||||||
            model_name="notificationtransport",
 | 
					            model_name="notificationtransport",
 | 
				
			||||||
            name="webhook_url",
 | 
					            name="webhook_url",
 | 
				
			||||||
            field=models.TextField(blank=True, validators=[django.core.validators.URLValidator()]),
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                blank=True, validators=[authentik.lib.models.DomainlessURLValidator()]
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
 | 
				
			|||||||
@ -1,13 +1,20 @@
 | 
				
			|||||||
"""authentik events models"""
 | 
					"""authentik events models"""
 | 
				
			||||||
 | 
					import time
 | 
				
			||||||
 | 
					from collections import Counter
 | 
				
			||||||
from datetime import timedelta
 | 
					from datetime import timedelta
 | 
				
			||||||
from inspect import getmodule, stack
 | 
					from inspect import currentframe
 | 
				
			||||||
from smtplib import SMTPException
 | 
					from smtplib import SMTPException
 | 
				
			||||||
from typing import TYPE_CHECKING, Optional, Type, Union
 | 
					from typing import TYPE_CHECKING, Optional
 | 
				
			||||||
from uuid import uuid4
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
from django.core.validators import URLValidator
 | 
					 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
 | 
					from django.db.models import Count, ExpressionWrapper, F
 | 
				
			||||||
 | 
					from django.db.models.fields import DurationField
 | 
				
			||||||
 | 
					from django.db.models.functions import ExtractHour
 | 
				
			||||||
 | 
					from django.db.models.functions.datetime import ExtractDay
 | 
				
			||||||
 | 
					from django.db.models.manager import Manager
 | 
				
			||||||
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from django.http.request import QueryDict
 | 
					from django.http.request import QueryDict
 | 
				
			||||||
from django.utils.timezone import now
 | 
					from django.utils.timezone import now
 | 
				
			||||||
@ -20,6 +27,7 @@ from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION
 | 
				
			|||||||
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
 | 
					from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
 | 
				
			||||||
from authentik.events.geo import GEOIP_READER
 | 
					from authentik.events.geo import GEOIP_READER
 | 
				
			||||||
from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
 | 
					from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
 | 
				
			||||||
 | 
					from authentik.lib.models import DomainlessURLValidator
 | 
				
			||||||
from authentik.lib.sentry import SentryIgnoredException
 | 
					from authentik.lib.sentry import SentryIgnoredException
 | 
				
			||||||
from authentik.lib.utils.http import get_client_ip, get_http_session
 | 
					from authentik.lib.utils.http import get_client_ip, get_http_session
 | 
				
			||||||
from authentik.lib.utils.time import timedelta_from_string
 | 
					from authentik.lib.utils.time import timedelta_from_string
 | 
				
			||||||
@ -70,6 +78,7 @@ class EventAction(models.TextChoices):
 | 
				
			|||||||
    IMPERSONATION_STARTED = "impersonation_started"
 | 
					    IMPERSONATION_STARTED = "impersonation_started"
 | 
				
			||||||
    IMPERSONATION_ENDED = "impersonation_ended"
 | 
					    IMPERSONATION_ENDED = "impersonation_ended"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    FLOW_EXECUTION = "flow_execution"
 | 
				
			||||||
    POLICY_EXECUTION = "policy_execution"
 | 
					    POLICY_EXECUTION = "policy_execution"
 | 
				
			||||||
    POLICY_EXCEPTION = "policy_exception"
 | 
					    POLICY_EXCEPTION = "policy_exception"
 | 
				
			||||||
    PROPERTY_MAPPING_EXCEPTION = "property_mapping_exception"
 | 
					    PROPERTY_MAPPING_EXCEPTION = "property_mapping_exception"
 | 
				
			||||||
@ -90,6 +99,72 @@ class EventAction(models.TextChoices):
 | 
				
			|||||||
    CUSTOM_PREFIX = "custom_"
 | 
					    CUSTOM_PREFIX = "custom_"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EventQuerySet(QuerySet):
 | 
				
			||||||
 | 
					    """Custom events query set with helper functions"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_events_per_hour(self) -> list[dict[str, int]]:
 | 
				
			||||||
 | 
					        """Get event count by hour in the last day, fill with zeros"""
 | 
				
			||||||
 | 
					        date_from = now() - timedelta(days=1)
 | 
				
			||||||
 | 
					        result = (
 | 
				
			||||||
 | 
					            self.filter(created__gte=date_from)
 | 
				
			||||||
 | 
					            .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
 | 
				
			||||||
 | 
					            .annotate(age_hours=ExtractHour("age"))
 | 
				
			||||||
 | 
					            .values("age_hours")
 | 
				
			||||||
 | 
					            .annotate(count=Count("pk"))
 | 
				
			||||||
 | 
					            .order_by("age_hours")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        data = Counter({int(d["age_hours"]): d["count"] for d in result})
 | 
				
			||||||
 | 
					        results = []
 | 
				
			||||||
 | 
					        _now = now()
 | 
				
			||||||
 | 
					        for hour in range(0, -24, -1):
 | 
				
			||||||
 | 
					            results.append(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
 | 
				
			||||||
 | 
					                    "y_cord": data[hour * -1],
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        return results
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_events_per_day(self) -> list[dict[str, int]]:
 | 
				
			||||||
 | 
					        """Get event count by hour in the last day, fill with zeros"""
 | 
				
			||||||
 | 
					        date_from = now() - timedelta(weeks=4)
 | 
				
			||||||
 | 
					        result = (
 | 
				
			||||||
 | 
					            self.filter(created__gte=date_from)
 | 
				
			||||||
 | 
					            .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
 | 
				
			||||||
 | 
					            .annotate(age_days=ExtractDay("age"))
 | 
				
			||||||
 | 
					            .values("age_days")
 | 
				
			||||||
 | 
					            .annotate(count=Count("pk"))
 | 
				
			||||||
 | 
					            .order_by("age_days")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        data = Counter({int(d["age_days"]): d["count"] for d in result})
 | 
				
			||||||
 | 
					        results = []
 | 
				
			||||||
 | 
					        _now = now()
 | 
				
			||||||
 | 
					        for day in range(0, -30, -1):
 | 
				
			||||||
 | 
					            results.append(
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    "x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
 | 
				
			||||||
 | 
					                    "y_cord": data[day * -1],
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        return results
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EventManager(Manager):
 | 
				
			||||||
 | 
					    """Custom helper methods for Events"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_queryset(self) -> QuerySet:
 | 
				
			||||||
 | 
					        """use custom queryset"""
 | 
				
			||||||
 | 
					        return EventQuerySet(self.model, using=self._db)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_events_per_hour(self) -> list[dict[str, int]]:
 | 
				
			||||||
 | 
					        """Wrap method from queryset"""
 | 
				
			||||||
 | 
					        return self.get_queryset().get_events_per_hour()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_events_per_day(self) -> list[dict[str, int]]:
 | 
				
			||||||
 | 
					        """Wrap method from queryset"""
 | 
				
			||||||
 | 
					        return self.get_queryset().get_events_per_day()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Event(ExpiringModel):
 | 
					class Event(ExpiringModel):
 | 
				
			||||||
    """An individual Audit/Metrics/Notification/Error Event"""
 | 
					    """An individual Audit/Metrics/Notification/Error Event"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -105,6 +180,8 @@ class Event(ExpiringModel):
 | 
				
			|||||||
    # 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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    objects = EventManager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def _get_app_from_request(request: HttpRequest) -> str:
 | 
					    def _get_app_from_request(request: HttpRequest) -> str:
 | 
				
			||||||
        if not isinstance(request, HttpRequest):
 | 
					        if not isinstance(request, HttpRequest):
 | 
				
			||||||
@ -113,16 +190,17 @@ class Event(ExpiringModel):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def new(
 | 
					    def new(
 | 
				
			||||||
        action: Union[str, EventAction],
 | 
					        action: str | EventAction,
 | 
				
			||||||
        app: Optional[str] = None,
 | 
					        app: Optional[str] = None,
 | 
				
			||||||
        _inspect_offset: int = 1,
 | 
					 | 
				
			||||||
        **kwargs,
 | 
					        **kwargs,
 | 
				
			||||||
    ) -> "Event":
 | 
					    ) -> "Event":
 | 
				
			||||||
        """Create new Event instance from arguments. Instance is NOT saved."""
 | 
					        """Create new Event instance from arguments. Instance is NOT saved."""
 | 
				
			||||||
        if not isinstance(action, EventAction):
 | 
					        if not isinstance(action, EventAction):
 | 
				
			||||||
            action = EventAction.CUSTOM_PREFIX + action
 | 
					            action = EventAction.CUSTOM_PREFIX + action
 | 
				
			||||||
        if not app:
 | 
					        if not app:
 | 
				
			||||||
            app = getmodule(stack()[_inspect_offset][0]).__name__
 | 
					            current = currentframe()
 | 
				
			||||||
 | 
					            parent = current.f_back
 | 
				
			||||||
 | 
					            app = parent.f_globals["__name__"]
 | 
				
			||||||
        cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
 | 
					        cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
 | 
				
			||||||
        event = Event(action=action, app=app, context=cleaned_kwargs)
 | 
					        event = Event(action=action, app=app, context=cleaned_kwargs)
 | 
				
			||||||
        return event
 | 
					        return event
 | 
				
			||||||
@ -224,7 +302,7 @@ class NotificationTransport(models.Model):
 | 
				
			|||||||
    name = models.TextField(unique=True)
 | 
					    name = models.TextField(unique=True)
 | 
				
			||||||
    mode = models.TextField(choices=TransportMode.choices)
 | 
					    mode = models.TextField(choices=TransportMode.choices)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    webhook_url = models.TextField(blank=True, validators=[URLValidator()])
 | 
					    webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
 | 
				
			||||||
    webhook_mapping = models.ForeignKey(
 | 
					    webhook_mapping = models.ForeignKey(
 | 
				
			||||||
        "NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None
 | 
					        "NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
@ -439,7 +517,7 @@ class NotificationWebhookMapping(PropertyMapping):
 | 
				
			|||||||
        return "ak-property-mapping-notification-form"
 | 
					        return "ak-property-mapping-notification-form"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def serializer(self) -> Type["Serializer"]:
 | 
					    def serializer(self) -> type["Serializer"]:
 | 
				
			||||||
        from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer
 | 
					        from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return NotificationWebhookMappingSerializer
 | 
					        return NotificationWebhookMappingSerializer
 | 
				
			||||||
 | 
				
			|||||||
@ -46,7 +46,7 @@ class TaskResult:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def with_error(self, exc: Exception) -> "TaskResult":
 | 
					    def with_error(self, exc: Exception) -> "TaskResult":
 | 
				
			||||||
        """Since errors might not always be pickle-able, set the traceback"""
 | 
					        """Since errors might not always be pickle-able, set the traceback"""
 | 
				
			||||||
        self.messages.extend(exception_to_string(exc).splitlines())
 | 
					        self.messages.append(str(exc))
 | 
				
			||||||
        return self
 | 
					        return self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -112,30 +112,6 @@ class TaskInfo:
 | 
				
			|||||||
        cache.set(key, self, timeout=timeout_hours * 60 * 60)
 | 
					        cache.set(key, self, timeout=timeout_hours * 60 * 60)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def prefill_task():
 | 
					 | 
				
			||||||
    """Ensure a task's details are always in cache, so it can always be triggered via API"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def inner_wrap(func):
 | 
					 | 
				
			||||||
        status = TaskInfo.by_name(func.__name__)
 | 
					 | 
				
			||||||
        if status:
 | 
					 | 
				
			||||||
            return func
 | 
					 | 
				
			||||||
        TaskInfo(
 | 
					 | 
				
			||||||
            task_name=func.__name__,
 | 
					 | 
				
			||||||
            task_description=func.__doc__,
 | 
					 | 
				
			||||||
            result=TaskResult(TaskResultStatus.UNKNOWN, messages=[_("Task has not been run yet.")]),
 | 
					 | 
				
			||||||
            task_call_module=func.__module__,
 | 
					 | 
				
			||||||
            task_call_func=func.__name__,
 | 
					 | 
				
			||||||
            # We don't have real values for these attributes but they cannot be null
 | 
					 | 
				
			||||||
            start_timestamp=default_timer(),
 | 
					 | 
				
			||||||
            finish_timestamp=default_timer(),
 | 
					 | 
				
			||||||
            finish_time=datetime.now(),
 | 
					 | 
				
			||||||
        ).save(86400)
 | 
					 | 
				
			||||||
        LOGGER.debug("prefilled task", task_name=func.__name__)
 | 
					 | 
				
			||||||
        return func
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return inner_wrap
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class MonitoredTask(Task):
 | 
					class MonitoredTask(Task):
 | 
				
			||||||
    """Task which can save its state to the cache"""
 | 
					    """Task which can save its state to the cache"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -210,5 +186,21 @@ class MonitoredTask(Task):
 | 
				
			|||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
for task in TaskInfo.all().values():
 | 
					def prefill_task(func):
 | 
				
			||||||
    task.set_prom_metrics()
 | 
					    """Ensure a task's details are always in cache, so it can always be triggered via API"""
 | 
				
			||||||
 | 
					    status = TaskInfo.by_name(func.__name__)
 | 
				
			||||||
 | 
					    if status:
 | 
				
			||||||
 | 
					        return func
 | 
				
			||||||
 | 
					    TaskInfo(
 | 
				
			||||||
 | 
					        task_name=func.__name__,
 | 
				
			||||||
 | 
					        task_description=func.__doc__,
 | 
				
			||||||
 | 
					        result=TaskResult(TaskResultStatus.UNKNOWN, messages=[_("Task has not been run yet.")]),
 | 
				
			||||||
 | 
					        task_call_module=func.__module__,
 | 
				
			||||||
 | 
					        task_call_func=func.__name__,
 | 
				
			||||||
 | 
					        # We don't have real values for these attributes but they cannot be null
 | 
				
			||||||
 | 
					        start_timestamp=default_timer(),
 | 
				
			||||||
 | 
					        finish_timestamp=default_timer(),
 | 
				
			||||||
 | 
					        finish_time=datetime.now(),
 | 
				
			||||||
 | 
					    ).save(86400)
 | 
				
			||||||
 | 
					    LOGGER.debug("prefilled task", task_name=func.__name__)
 | 
				
			||||||
 | 
					    return func
 | 
				
			||||||
 | 
				
			|||||||
@ -3,14 +3,14 @@ from threading import Thread
 | 
				
			|||||||
from typing import Any, Optional
 | 
					from typing import Any, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed
 | 
					from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed
 | 
				
			||||||
from django.db.models.signals import post_save
 | 
					from django.db.models.signals import post_save, pre_delete
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.models import User
 | 
				
			||||||
from authentik.core.signals import password_changed
 | 
					from authentik.core.signals import password_changed
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
from authentik.events.tasks import event_notification_handler
 | 
					from authentik.events.tasks import event_notification_handler, gdpr_cleanup
 | 
				
			||||||
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
 | 
					from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
 | 
				
			||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
 | 
					from authentik.flows.views.executor import SESSION_KEY_PLAN
 | 
				
			||||||
from authentik.stages.invitation.models import Invitation
 | 
					from authentik.stages.invitation.models import Invitation
 | 
				
			||||||
@ -108,3 +108,10 @@ def on_password_changed(sender, user: User, password: str, **_):
 | 
				
			|||||||
def event_post_save_notification(sender, instance: Event, **_):
 | 
					def event_post_save_notification(sender, instance: Event, **_):
 | 
				
			||||||
    """Start task to check if any policies trigger an notification on this event"""
 | 
					    """Start task to check if any policies trigger an notification on this event"""
 | 
				
			||||||
    event_notification_handler.delay(instance.event_uuid.hex)
 | 
					    event_notification_handler.delay(instance.event_uuid.hex)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(pre_delete, sender=User)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def event_user_pre_delete_cleanup(sender, instance: User, **_):
 | 
				
			||||||
 | 
					    """If gdpr_compliance is enabled, remove all the user's events"""
 | 
				
			||||||
 | 
					    gdpr_cleanup.delay(instance.pk)
 | 
				
			||||||
 | 
				
			|||||||
@ -106,3 +106,11 @@ def notification_transport(self: MonitoredTask, notification_pk: int, transport_
 | 
				
			|||||||
    except NotificationTransportError as exc:
 | 
					    except NotificationTransportError as exc:
 | 
				
			||||||
        self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
 | 
					        self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
 | 
				
			||||||
        raise exc
 | 
					        raise exc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@CELERY_APP.task()
 | 
				
			||||||
 | 
					def gdpr_cleanup(user_pk: int):
 | 
				
			||||||
 | 
					    """cleanup events from gdpr_compliance"""
 | 
				
			||||||
 | 
					    events = Event.objects.filter(user__pk=user_pk)
 | 
				
			||||||
 | 
					    LOGGER.debug("GDPR cleanup, removing events from user", events=events.count())
 | 
				
			||||||
 | 
					    events.delete()
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@
 | 
				
			|||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
from authentik.events.models import (
 | 
					from authentik.events.models import (
 | 
				
			||||||
    Event,
 | 
					    Event,
 | 
				
			||||||
    EventAction,
 | 
					    EventAction,
 | 
				
			||||||
@ -17,7 +17,7 @@ class TestEventsAPI(APITestCase):
 | 
				
			|||||||
    """Test Event API"""
 | 
					    """Test Event API"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        self.user = User.objects.get(username="akadmin")
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_top_n(self):
 | 
					    def test_top_n(self):
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,8 @@
 | 
				
			|||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import Application, User
 | 
					from authentik.core.models import Application
 | 
				
			||||||
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
from authentik.events.models import Event, EventAction
 | 
					from authentik.events.models import Event, EventAction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -12,7 +13,7 @@ class TestEventsMiddleware(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def setUp(self) -> None:
 | 
					    def setUp(self) -> None:
 | 
				
			||||||
        super().setUp()
 | 
					        super().setUp()
 | 
				
			||||||
        self.user = User.objects.get(username="akadmin")
 | 
					        self.user = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(self.user)
 | 
					        self.client.force_login(self.user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_create(self):
 | 
					    def test_create(self):
 | 
				
			||||||
 | 
				
			|||||||
@ -41,6 +41,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
 | 
				
			|||||||
            "component",
 | 
					            "component",
 | 
				
			||||||
            "verbose_name",
 | 
					            "verbose_name",
 | 
				
			||||||
            "verbose_name_plural",
 | 
					            "verbose_name_plural",
 | 
				
			||||||
 | 
					            "meta_model_name",
 | 
				
			||||||
            "flow_set",
 | 
					            "flow_set",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -89,7 +90,7 @@ class StageViewSet(
 | 
				
			|||||||
            stages += list(configurable_stage.objects.all().order_by("name"))
 | 
					            stages += list(configurable_stage.objects.all().order_by("name"))
 | 
				
			||||||
        matching_stages: list[dict] = []
 | 
					        matching_stages: list[dict] = []
 | 
				
			||||||
        for stage in stages:
 | 
					        for stage in stages:
 | 
				
			||||||
            user_settings = stage.ui_user_settings
 | 
					            user_settings = stage.ui_user_settings()
 | 
				
			||||||
            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)
 | 
				
			||||||
 | 
				
			|||||||
@ -72,7 +72,7 @@ class WithUserInfoChallenge(Challenge):
 | 
				
			|||||||
    pending_user_avatar = CharField()
 | 
					    pending_user_avatar = CharField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AccessDeniedChallenge(Challenge):
 | 
					class AccessDeniedChallenge(WithUserInfoChallenge):
 | 
				
			||||||
    """Challenge when a flow's active stage calls `stage_invalid()`."""
 | 
					    """Challenge when a flow's active stage calls `stage_invalid()`."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    error_message = CharField(required=False)
 | 
					    error_message = CharField(required=False)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,14 @@
 | 
				
			|||||||
"""flow exceptions"""
 | 
					"""flow exceptions"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.lib.sentry import SentryIgnoredException
 | 
					from authentik.lib.sentry import SentryIgnoredException
 | 
				
			||||||
 | 
					from authentik.policies.types import PolicyResult
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FlowNonApplicableException(SentryIgnoredException):
 | 
					class FlowNonApplicableException(SentryIgnoredException):
 | 
				
			||||||
    """Flow does not apply to current user (denied by policy)."""
 | 
					    """Flow does not apply to current user (denied by policy)."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    policy_result: PolicyResult
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EmptyFlowException(SentryIgnoredException):
 | 
					class EmptyFlowException(SentryIgnoredException):
 | 
				
			||||||
    """Flow has no stages."""
 | 
					    """Flow has no stages."""
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@ from django.test import RequestFactory
 | 
				
			|||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik import __version__
 | 
					from authentik import __version__
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
from authentik.flows.models import Flow
 | 
					from authentik.flows.models import Flow
 | 
				
			||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
 | 
					from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -68,7 +68,7 @@ class Command(BaseCommand):  # pragma: no cover
 | 
				
			|||||||
    def benchmark_flows(self, proc_count):
 | 
					    def benchmark_flows(self, proc_count):
 | 
				
			||||||
        """Get full recovery link"""
 | 
					        """Get full recovery link"""
 | 
				
			||||||
        flow = Flow.objects.get(slug="default-authentication-flow")
 | 
					        flow = Flow.objects.get(slug="default-authentication-flow")
 | 
				
			||||||
        user = User.objects.get(username="akadmin")
 | 
					        user = create_test_admin_user()
 | 
				
			||||||
        manager = Manager()
 | 
					        manager = Manager()
 | 
				
			||||||
        return_dict = manager.dict()
 | 
					        return_dict = manager.dict()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -10,8 +10,8 @@ def add_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			|||||||
        "default-invalidation-flow": "Default Invalidation Flow",
 | 
					        "default-invalidation-flow": "Default Invalidation Flow",
 | 
				
			||||||
        "default-source-enrollment": "Welcome to authentik! Please select a username.",
 | 
					        "default-source-enrollment": "Welcome to authentik! Please select a username.",
 | 
				
			||||||
        "default-source-authentication": "Welcome to authentik!",
 | 
					        "default-source-authentication": "Welcome to authentik!",
 | 
				
			||||||
        "default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)",
 | 
					        "default-provider-authorization-implicit-consent": "Redirecting to %(app)s",
 | 
				
			||||||
        "default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)",
 | 
					        "default-provider-authorization-explicit-consent": "Redirecting to %(app)s",
 | 
				
			||||||
        "default-password-change": "Change password",
 | 
					        "default-password-change": "Change password",
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    db_alias = schema_editor.connection.alias
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										46
									
								
								authentik/flows/migrations/0020_flowtoken.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								authentik/flows/migrations/0020_flowtoken.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.2.9 on 2021-12-05 13:50
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import django.db.models.deletion
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
 | 
				
			||||||
 | 
					        (
 | 
				
			||||||
 | 
					            "authentik_flows",
 | 
				
			||||||
 | 
					            "0019_alter_flow_background_squashed_0024_alter_flow_compatibility_mode",
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.CreateModel(
 | 
				
			||||||
 | 
					            name="FlowToken",
 | 
				
			||||||
 | 
					            fields=[
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "token_ptr",
 | 
				
			||||||
 | 
					                    models.OneToOneField(
 | 
				
			||||||
 | 
					                        auto_created=True,
 | 
				
			||||||
 | 
					                        on_delete=django.db.models.deletion.CASCADE,
 | 
				
			||||||
 | 
					                        parent_link=True,
 | 
				
			||||||
 | 
					                        primary_key=True,
 | 
				
			||||||
 | 
					                        serialize=False,
 | 
				
			||||||
 | 
					                        to="authentik_core.token",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                ("_plan", models.TextField()),
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    "flow",
 | 
				
			||||||
 | 
					                    models.ForeignKey(
 | 
				
			||||||
 | 
					                        on_delete=django.db.models.deletion.CASCADE, to="authentik_flows.flow"
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					            options={
 | 
				
			||||||
 | 
					                "verbose_name": "Flow Token",
 | 
				
			||||||
 | 
					                "verbose_name_plural": "Flow Tokens",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            bases=("authentik_core.token",),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										27
									
								
								authentik/flows/migrations/0021_auto_20211227_2103.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								authentik/flows/migrations/0021_auto_20211227_2103.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					# Generated by Django 4.0 on 2021-12-27 21:03
 | 
				
			||||||
 | 
					from django.apps.registry import Apps
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def update_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			||||||
 | 
					    slug_title_map = {
 | 
				
			||||||
 | 
					        "default-provider-authorization-implicit-consent": "Redirecting to %(app)s",
 | 
				
			||||||
 | 
					        "default-provider-authorization-explicit-consent": "Redirecting to %(app)s",
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
 | 
					    Flow = apps.get_model("authentik_flows", "Flow")
 | 
				
			||||||
 | 
					    for flow in Flow.objects.using(db_alias).all():
 | 
				
			||||||
 | 
					        if flow.slug not in slug_title_map:
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        flow.title = slug_title_map[flow.slug]
 | 
				
			||||||
 | 
					        flow.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("authentik_flows", "0020_flowtoken"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [migrations.RunPython(update_title_for_defaults)]
 | 
				
			||||||
@ -1,5 +1,7 @@
 | 
				
			|||||||
"""Flow models"""
 | 
					"""Flow models"""
 | 
				
			||||||
from typing import TYPE_CHECKING, Optional, Type
 | 
					from base64 import b64decode, b64encode
 | 
				
			||||||
 | 
					from pickle import dumps, loads  # nosec
 | 
				
			||||||
 | 
					from typing import TYPE_CHECKING, Optional
 | 
				
			||||||
from uuid import uuid4
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
@ -9,11 +11,13 @@ from model_utils.managers import InheritanceManager
 | 
				
			|||||||
from rest_framework.serializers import BaseSerializer
 | 
					from rest_framework.serializers import BaseSerializer
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import Token
 | 
				
			||||||
from authentik.core.types import UserSettingSerializer
 | 
					from authentik.core.types import UserSettingSerializer
 | 
				
			||||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
 | 
					from authentik.lib.models import InheritanceForeignKey, SerializerModel
 | 
				
			||||||
from authentik.policies.models import PolicyBindingModel
 | 
					from authentik.policies.models import PolicyBindingModel
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if TYPE_CHECKING:
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
 | 
					    from authentik.flows.planner import FlowPlan
 | 
				
			||||||
    from authentik.flows.stage import StageView
 | 
					    from authentik.flows.stage import StageView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
@ -59,7 +63,7 @@ class Stage(SerializerModel):
 | 
				
			|||||||
    objects = InheritanceManager()
 | 
					    objects = InheritanceManager()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def type(self) -> Type["StageView"]:
 | 
					    def type(self) -> type["StageView"]:
 | 
				
			||||||
        """Return StageView class that implements logic for this stage"""
 | 
					        """Return StageView class that implements logic for this stage"""
 | 
				
			||||||
        # This is a bit of a workaround, since we can't set class methods with setattr
 | 
					        # This is a bit of a workaround, since we can't set class methods with setattr
 | 
				
			||||||
        if hasattr(self, "__in_memory_type"):
 | 
					        if hasattr(self, "__in_memory_type"):
 | 
				
			||||||
@ -71,7 +75,6 @@ class Stage(SerializerModel):
 | 
				
			|||||||
        """Return component used to edit this object"""
 | 
					        """Return component used to edit this object"""
 | 
				
			||||||
        raise NotImplementedError
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def ui_user_settings(self) -> Optional[UserSettingSerializer]:
 | 
					    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 a challenge."""
 | 
				
			||||||
@ -83,7 +86,7 @@ class Stage(SerializerModel):
 | 
				
			|||||||
        return f"Stage {self.name}"
 | 
					        return f"Stage {self.name}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def in_memory_stage(view: Type["StageView"]) -> Stage:
 | 
					def in_memory_stage(view: type["StageView"]) -> Stage:
 | 
				
			||||||
    """Creates an in-memory stage instance, based on a `view` as view."""
 | 
					    """Creates an in-memory stage instance, based on a `view` as view."""
 | 
				
			||||||
    stage = Stage()
 | 
					    stage = Stage()
 | 
				
			||||||
    # Because we can't pickle a locally generated function,
 | 
					    # Because we can't pickle a locally generated function,
 | 
				
			||||||
@ -260,3 +263,30 @@ class ConfigurableStage(models.Model):
 | 
				
			|||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        abstract = True
 | 
					        abstract = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FlowToken(Token):
 | 
				
			||||||
 | 
					    """Subclass of a standard Token, stores the currently active flow plan upon creation.
 | 
				
			||||||
 | 
					    Can be used to later resume a flow."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    flow = models.ForeignKey(Flow, on_delete=models.CASCADE)
 | 
				
			||||||
 | 
					    _plan = models.TextField()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def pickle(plan) -> str:
 | 
				
			||||||
 | 
					        """Pickle into string"""
 | 
				
			||||||
 | 
					        data = dumps(plan)
 | 
				
			||||||
 | 
					        return b64encode(data).decode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def plan(self) -> "FlowPlan":
 | 
				
			||||||
 | 
					        """Load Flow plan from pickled version"""
 | 
				
			||||||
 | 
					        return loads(b64decode(self._plan.encode()))  # nosec
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
 | 
					        return f"Flow Token {super().__str__()}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        verbose_name = _("Flow Token")
 | 
				
			||||||
 | 
					        verbose_name_plural = _("Flow Tokens")
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@ from typing import Any, Optional
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from prometheus_client import Histogram
 | 
					from prometheus_client import Gauge, Histogram
 | 
				
			||||||
from sentry_sdk.hub import Hub
 | 
					from sentry_sdk.hub import Hub
 | 
				
			||||||
from sentry_sdk.tracing import Span
 | 
					from sentry_sdk.tracing import Span
 | 
				
			||||||
from structlog.stdlib import BoundLogger, get_logger
 | 
					from structlog.stdlib import BoundLogger, get_logger
 | 
				
			||||||
@ -16,7 +16,6 @@ from authentik.flows.markers import ReevaluateMarker, StageMarker
 | 
				
			|||||||
from authentik.flows.models import Flow, FlowStageBinding, Stage
 | 
					from authentik.flows.models import Flow, FlowStageBinding, Stage
 | 
				
			||||||
from authentik.lib.config import CONFIG
 | 
					from authentik.lib.config import CONFIG
 | 
				
			||||||
from authentik.policies.engine import PolicyEngine
 | 
					from authentik.policies.engine import PolicyEngine
 | 
				
			||||||
from authentik.root.monitoring import UpdatingGauge
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
PLAN_CONTEXT_PENDING_USER = "pending_user"
 | 
					PLAN_CONTEXT_PENDING_USER = "pending_user"
 | 
				
			||||||
@ -24,10 +23,12 @@ PLAN_CONTEXT_SSO = "is_sso"
 | 
				
			|||||||
PLAN_CONTEXT_REDIRECT = "redirect"
 | 
					PLAN_CONTEXT_REDIRECT = "redirect"
 | 
				
			||||||
PLAN_CONTEXT_APPLICATION = "application"
 | 
					PLAN_CONTEXT_APPLICATION = "application"
 | 
				
			||||||
PLAN_CONTEXT_SOURCE = "source"
 | 
					PLAN_CONTEXT_SOURCE = "source"
 | 
				
			||||||
GAUGE_FLOWS_CACHED = UpdatingGauge(
 | 
					# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
 | 
				
			||||||
 | 
					# was restored.
 | 
				
			||||||
 | 
					PLAN_CONTEXT_IS_RESTORED = "is_restored"
 | 
				
			||||||
 | 
					GAUGE_FLOWS_CACHED = Gauge(
 | 
				
			||||||
    "authentik_flows_cached",
 | 
					    "authentik_flows_cached",
 | 
				
			||||||
    "Cached flows",
 | 
					    "Cached flows",
 | 
				
			||||||
    update_func=lambda: len(cache.keys("flow_*") or []),
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
HIST_FLOWS_PLAN_TIME = Histogram(
 | 
					HIST_FLOWS_PLAN_TIME = Histogram(
 | 
				
			||||||
    "authentik_flows_plan_time",
 | 
					    "authentik_flows_plan_time",
 | 
				
			||||||
@ -123,7 +124,9 @@ class FlowPlanner:
 | 
				
			|||||||
    ) -> FlowPlan:
 | 
					    ) -> FlowPlan:
 | 
				
			||||||
        """Check each of the flows' policies, check policies for each stage with PolicyBinding
 | 
					        """Check each of the flows' policies, check policies for each stage with PolicyBinding
 | 
				
			||||||
        and return ordered list"""
 | 
					        and return ordered list"""
 | 
				
			||||||
        with Hub.current.start_span(op="flow.planner.plan") as span:
 | 
					        with Hub.current.start_span(
 | 
				
			||||||
 | 
					            op="authentik.flow.planner.plan", description=self.flow.slug
 | 
				
			||||||
 | 
					        ) as span:
 | 
				
			||||||
            span: Span
 | 
					            span: Span
 | 
				
			||||||
            span.set_data("flow", self.flow)
 | 
					            span.set_data("flow", self.flow)
 | 
				
			||||||
            span.set_data("request", request)
 | 
					            span.set_data("request", request)
 | 
				
			||||||
@ -147,7 +150,9 @@ class FlowPlanner:
 | 
				
			|||||||
            engine.build()
 | 
					            engine.build()
 | 
				
			||||||
            result = engine.result
 | 
					            result = engine.result
 | 
				
			||||||
            if not result.passing:
 | 
					            if not result.passing:
 | 
				
			||||||
                raise FlowNonApplicableException(",".join(result.messages))
 | 
					                exc = FlowNonApplicableException(",".join(result.messages))
 | 
				
			||||||
 | 
					                exc.policy_result = result
 | 
				
			||||||
 | 
					                raise exc
 | 
				
			||||||
            # User is passing so far, check if we have a cached plan
 | 
					            # User is passing so far, check if we have a cached plan
 | 
				
			||||||
            cached_plan_key = cache_key(self.flow, user)
 | 
					            cached_plan_key = cache_key(self.flow, user)
 | 
				
			||||||
            cached_plan = cache.get(cached_plan_key, None)
 | 
					            cached_plan = cache.get(cached_plan_key, None)
 | 
				
			||||||
@ -164,7 +169,6 @@ class FlowPlanner:
 | 
				
			|||||||
            )
 | 
					            )
 | 
				
			||||||
            plan = self._build_plan(user, request, default_context)
 | 
					            plan = self._build_plan(user, request, default_context)
 | 
				
			||||||
            cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
 | 
					            cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
 | 
				
			||||||
            GAUGE_FLOWS_CACHED.update()
 | 
					 | 
				
			||||||
            if not plan.bindings and not self.allow_empty_flows:
 | 
					            if not plan.bindings and not self.allow_empty_flows:
 | 
				
			||||||
                raise EmptyFlowException()
 | 
					                raise EmptyFlowException()
 | 
				
			||||||
            return plan
 | 
					            return plan
 | 
				
			||||||
@ -178,7 +182,8 @@ class FlowPlanner:
 | 
				
			|||||||
        """Build flow plan by checking each stage in their respective
 | 
					        """Build flow plan by checking each stage in their respective
 | 
				
			||||||
        order and checking the applied policies"""
 | 
					        order and checking the applied policies"""
 | 
				
			||||||
        with Hub.current.start_span(
 | 
					        with Hub.current.start_span(
 | 
				
			||||||
            op="flow.planner.build_plan"
 | 
					            op="authentik.flow.planner.build_plan",
 | 
				
			||||||
 | 
					            description=self.flow.slug,
 | 
				
			||||||
        ) as span, HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time():
 | 
					        ) as span, HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time():
 | 
				
			||||||
            span: Span
 | 
					            span: Span
 | 
				
			||||||
            span.set_data("flow", self.flow)
 | 
					            span.set_data("flow", self.flow)
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,9 @@ from django.db.models.signals import post_save, pre_delete
 | 
				
			|||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.flows.planner import GAUGE_FLOWS_CACHED
 | 
				
			||||||
 | 
					from authentik.root.monitoring import monitoring_set
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -14,6 +17,13 @@ def delete_cache_prefix(prefix: str) -> int:
 | 
				
			|||||||
    return len(keys)
 | 
					    return len(keys)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(monitoring_set)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def monitoring_set_flows(sender, **kwargs):
 | 
				
			||||||
 | 
					    """set flow gauges"""
 | 
				
			||||||
 | 
					    GAUGE_FLOWS_CACHED.set(len(cache.keys("flow_*") or []))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(post_save)
 | 
					@receiver(post_save)
 | 
				
			||||||
@receiver(pre_delete)
 | 
					@receiver(pre_delete)
 | 
				
			||||||
# pylint: disable=unused-argument
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,6 @@
 | 
				
			|||||||
"""authentik stage Base view"""
 | 
					"""authentik stage Base view"""
 | 
				
			||||||
 | 
					from typing import TYPE_CHECKING, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.contrib.auth.models import AnonymousUser
 | 
					from django.contrib.auth.models import AnonymousUser
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from django.http.request import QueryDict
 | 
					from django.http.request import QueryDict
 | 
				
			||||||
@ -6,19 +8,24 @@ from django.http.response import HttpResponse
 | 
				
			|||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from django.views.generic.base import View
 | 
					from django.views.generic.base import View
 | 
				
			||||||
from rest_framework.request import Request
 | 
					from rest_framework.request import Request
 | 
				
			||||||
 | 
					from sentry_sdk.hub import Hub
 | 
				
			||||||
from structlog.stdlib import get_logger
 | 
					from structlog.stdlib import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import DEFAULT_AVATAR, User
 | 
					from authentik.core.models import DEFAULT_AVATAR, User
 | 
				
			||||||
from authentik.flows.challenge import (
 | 
					from authentik.flows.challenge import (
 | 
				
			||||||
 | 
					    AccessDeniedChallenge,
 | 
				
			||||||
    Challenge,
 | 
					    Challenge,
 | 
				
			||||||
    ChallengeResponse,
 | 
					    ChallengeResponse,
 | 
				
			||||||
 | 
					    ChallengeTypes,
 | 
				
			||||||
    ContextualFlowInfo,
 | 
					    ContextualFlowInfo,
 | 
				
			||||||
    HttpChallengeResponse,
 | 
					    HttpChallengeResponse,
 | 
				
			||||||
    WithUserInfoChallenge,
 | 
					    WithUserInfoChallenge,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from authentik.flows.models import InvalidResponseAction
 | 
					from authentik.flows.models import InvalidResponseAction
 | 
				
			||||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
 | 
					from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
 | 
				
			||||||
from authentik.flows.views.executor import FlowExecutorView
 | 
					
 | 
				
			||||||
 | 
					if TYPE_CHECKING:
 | 
				
			||||||
 | 
					    from authentik.flows.views.executor import FlowExecutorView
 | 
				
			||||||
 | 
					
 | 
				
			||||||
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
 | 
					PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
@ -27,11 +34,11 @@ LOGGER = get_logger()
 | 
				
			|||||||
class StageView(View):
 | 
					class StageView(View):
 | 
				
			||||||
    """Abstract Stage, inherits TemplateView but can be combined with FormView"""
 | 
					    """Abstract Stage, inherits TemplateView but can be combined with FormView"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    executor: FlowExecutorView
 | 
					    executor: "FlowExecutorView"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    request: HttpRequest = None
 | 
					    request: HttpRequest = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, executor: FlowExecutorView, **kwargs):
 | 
					    def __init__(self, executor: "FlowExecutorView", **kwargs):
 | 
				
			||||||
        self.executor = executor
 | 
					        self.executor = executor
 | 
				
			||||||
        super().__init__(**kwargs)
 | 
					        super().__init__(**kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -42,6 +49,8 @@ class StageView(View):
 | 
				
			|||||||
        other things besides the form display.
 | 
					        other things besides the form display.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        If no user is pending, returns request.user"""
 | 
					        If no user is pending, returns request.user"""
 | 
				
			||||||
 | 
					        if not self.executor.plan:
 | 
				
			||||||
 | 
					            return self.request.user
 | 
				
			||||||
        if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context and for_display:
 | 
					        if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context and for_display:
 | 
				
			||||||
            return User(
 | 
					            return User(
 | 
				
			||||||
                username=self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER_IDENTIFIER),
 | 
					                username=self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER_IDENTIFIER),
 | 
				
			||||||
@ -94,17 +103,34 @@ class ChallengeStageView(StageView):
 | 
				
			|||||||
                    keep_context=keep_context,
 | 
					                    keep_context=keep_context,
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
                return self.executor.restart_flow(keep_context)
 | 
					                return self.executor.restart_flow(keep_context)
 | 
				
			||||||
            return self.challenge_invalid(challenge)
 | 
					            with Hub.current.start_span(
 | 
				
			||||||
        return self.challenge_valid(challenge)
 | 
					                op="authentik.flow.stage.challenge_invalid",
 | 
				
			||||||
 | 
					                description=self.__class__.__name__,
 | 
				
			||||||
 | 
					            ):
 | 
				
			||||||
 | 
					                return self.challenge_invalid(challenge)
 | 
				
			||||||
 | 
					        with Hub.current.start_span(
 | 
				
			||||||
 | 
					            op="authentik.flow.stage.challenge_valid",
 | 
				
			||||||
 | 
					            description=self.__class__.__name__,
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            return self.challenge_valid(challenge)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def format_title(self) -> str:
 | 
					    def format_title(self) -> str:
 | 
				
			||||||
        """Allow usage of placeholder in flow title."""
 | 
					        """Allow usage of placeholder in flow title."""
 | 
				
			||||||
        return self.executor.flow.title % {
 | 
					        if not self.executor.plan:
 | 
				
			||||||
            "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
 | 
					            return self.executor.flow.title
 | 
				
			||||||
        }
 | 
					        try:
 | 
				
			||||||
 | 
					            return self.executor.flow.title % {
 | 
				
			||||||
 | 
					                "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        except ValueError:
 | 
				
			||||||
 | 
					            return self.executor.flow.title
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _get_challenge(self, *args, **kwargs) -> Challenge:
 | 
					    def _get_challenge(self, *args, **kwargs) -> Challenge:
 | 
				
			||||||
        challenge = self.get_challenge(*args, **kwargs)
 | 
					        with Hub.current.start_span(
 | 
				
			||||||
 | 
					            op="authentik.flow.stage.get_challenge",
 | 
				
			||||||
 | 
					            description=self.__class__.__name__,
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            challenge = self.get_challenge(*args, **kwargs)
 | 
				
			||||||
        if "flow_info" not in challenge.initial_data:
 | 
					        if "flow_info" not in challenge.initial_data:
 | 
				
			||||||
            flow_info = ContextualFlowInfo(
 | 
					            flow_info = ContextualFlowInfo(
 | 
				
			||||||
                data={
 | 
					                data={
 | 
				
			||||||
@ -149,10 +175,34 @@ class ChallengeStageView(StageView):
 | 
				
			|||||||
                )
 | 
					                )
 | 
				
			||||||
        challenge_response.initial_data["response_errors"] = full_errors
 | 
					        challenge_response.initial_data["response_errors"] = full_errors
 | 
				
			||||||
        if not challenge_response.is_valid():
 | 
					        if not challenge_response.is_valid():
 | 
				
			||||||
            LOGGER.warning(
 | 
					            LOGGER.error(
 | 
				
			||||||
                "f(ch): invalid challenge response",
 | 
					                "f(ch): invalid challenge response",
 | 
				
			||||||
                binding=self.executor.current_binding,
 | 
					                binding=self.executor.current_binding,
 | 
				
			||||||
                errors=challenge_response.errors,
 | 
					                errors=challenge_response.errors,
 | 
				
			||||||
                stage_view=self,
 | 
					                stage_view=self,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
        return HttpChallengeResponse(challenge_response)
 | 
					        return HttpChallengeResponse(challenge_response)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AccessDeniedChallengeView(ChallengeStageView):
 | 
				
			||||||
 | 
					    """Used internally by FlowExecutor's stage_invalid()"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    error_message: Optional[str]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, executor: "FlowExecutorView", error_message: Optional[str] = None, **kwargs):
 | 
				
			||||||
 | 
					        super().__init__(executor, **kwargs)
 | 
				
			||||||
 | 
					        self.error_message = error_message
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_challenge(self, *args, **kwargs) -> Challenge:
 | 
				
			||||||
 | 
					        return AccessDeniedChallenge(
 | 
				
			||||||
 | 
					            data={
 | 
				
			||||||
 | 
					                "error_message": self.error_message or "Unknown error",
 | 
				
			||||||
 | 
					                "type": ChallengeTypes.NATIVE.value,
 | 
				
			||||||
 | 
					                "component": "ak-stage-access-denied",
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # This can never be reached since this challenge is created on demand and only the
 | 
				
			||||||
 | 
					    # .get() method is called
 | 
				
			||||||
 | 
					    def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:  # pragma: no cover
 | 
				
			||||||
 | 
					        return self.executor.cancel()
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,51 @@
 | 
				
			|||||||
 | 
					"""Test helpers"""
 | 
				
			||||||
 | 
					from json import loads
 | 
				
			||||||
 | 
					from typing import Any, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.http.response import HttpResponse
 | 
				
			||||||
 | 
					from django.urls.base import reverse
 | 
				
			||||||
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from authentik.core.models import User
 | 
				
			||||||
 | 
					from authentik.flows.challenge import ChallengeTypes
 | 
				
			||||||
 | 
					from authentik.flows.models import Flow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class FlowTestCase(APITestCase):
 | 
				
			||||||
 | 
					    """Helpers for testing flows and stages."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # pylint: disable=invalid-name
 | 
				
			||||||
 | 
					    def assertStageResponse(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        response: HttpResponse,
 | 
				
			||||||
 | 
					        flow: Optional[Flow] = None,
 | 
				
			||||||
 | 
					        user: Optional[User] = None,
 | 
				
			||||||
 | 
					        **kwargs,
 | 
				
			||||||
 | 
					    ) -> dict[str, Any]:
 | 
				
			||||||
 | 
					        """Assert various attributes of a stage response"""
 | 
				
			||||||
 | 
					        raw_response = loads(response.content.decode())
 | 
				
			||||||
 | 
					        self.assertIsNotNone(raw_response["component"])
 | 
				
			||||||
 | 
					        self.assertIsNotNone(raw_response["type"])
 | 
				
			||||||
 | 
					        if flow:
 | 
				
			||||||
 | 
					            self.assertIn("flow_info", raw_response)
 | 
				
			||||||
 | 
					            self.assertEqual(raw_response["flow_info"]["background"], flow.background_url)
 | 
				
			||||||
 | 
					            self.assertEqual(
 | 
				
			||||||
 | 
					                raw_response["flow_info"]["cancel_url"], reverse("authentik_flows:cancel")
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            # We don't check the flow title since it will most likely go
 | 
				
			||||||
 | 
					            # through ChallengeStageView.format_title() so might not match 1:1
 | 
				
			||||||
 | 
					            # self.assertEqual(raw_response["flow_info"]["title"], flow.title)
 | 
				
			||||||
 | 
					            self.assertIsNotNone(raw_response["flow_info"]["title"])
 | 
				
			||||||
 | 
					        if user:
 | 
				
			||||||
 | 
					            self.assertEqual(raw_response["pending_user"], user.username)
 | 
				
			||||||
 | 
					            self.assertEqual(raw_response["pending_user_avatar"], user.avatar)
 | 
				
			||||||
 | 
					        for key, expected in kwargs.items():
 | 
				
			||||||
 | 
					            self.assertEqual(raw_response[key], expected)
 | 
				
			||||||
 | 
					        return raw_response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # pylint: disable=invalid-name
 | 
				
			||||||
 | 
					    def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
 | 
				
			||||||
 | 
					        """Wrapper around assertStageResponse that checks for a redirect"""
 | 
				
			||||||
 | 
					        return self.assertStageResponse(
 | 
				
			||||||
 | 
					            response, component="xak-flow-redirect", to=to, type=ChallengeTypes.REDIRECT.value
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,7 @@
 | 
				
			|||||||
from django.urls import reverse
 | 
					from django.urls import reverse
 | 
				
			||||||
from rest_framework.test import APITestCase
 | 
					from rest_framework.test import APITestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from authentik.core.models import User
 | 
					from authentik.core.tests.utils import create_test_admin_user
 | 
				
			||||||
from authentik.flows.api.stages import StageSerializer, StageViewSet
 | 
					from authentik.flows.api.stages import StageSerializer, StageViewSet
 | 
				
			||||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
 | 
					from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
 | 
				
			||||||
from authentik.policies.dummy.models import DummyPolicy
 | 
					from authentik.policies.dummy.models import DummyPolicy
 | 
				
			||||||
@ -32,7 +32,7 @@ class TestFlowsAPI(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_models(self):
 | 
					    def test_models(self):
 | 
				
			||||||
        """Test that ui_user_settings returns none"""
 | 
					        """Test that ui_user_settings returns none"""
 | 
				
			||||||
        self.assertIsNone(Stage().ui_user_settings)
 | 
					        self.assertIsNone(Stage().ui_user_settings())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_api_serializer(self):
 | 
					    def test_api_serializer(self):
 | 
				
			||||||
        """Test that stage serializer returns the correct type"""
 | 
					        """Test that stage serializer returns the correct type"""
 | 
				
			||||||
@ -47,7 +47,7 @@ class TestFlowsAPI(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_api_diagram(self):
 | 
					    def test_api_diagram(self):
 | 
				
			||||||
        """Test flow diagram."""
 | 
					        """Test flow diagram."""
 | 
				
			||||||
        user = User.objects.get(username="akadmin")
 | 
					        user = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(user)
 | 
					        self.client.force_login(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        flow = Flow.objects.create(
 | 
					        flow = Flow.objects.create(
 | 
				
			||||||
@ -77,7 +77,7 @@ class TestFlowsAPI(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_api_diagram_no_stages(self):
 | 
					    def test_api_diagram_no_stages(self):
 | 
				
			||||||
        """Test flow diagram with no stages."""
 | 
					        """Test flow diagram with no stages."""
 | 
				
			||||||
        user = User.objects.get(username="akadmin")
 | 
					        user = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(user)
 | 
					        self.client.force_login(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        flow = Flow.objects.create(
 | 
					        flow = Flow.objects.create(
 | 
				
			||||||
@ -93,7 +93,7 @@ class TestFlowsAPI(APITestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_types(self):
 | 
					    def test_types(self):
 | 
				
			||||||
        """Test Stage's types endpoint"""
 | 
					        """Test Stage's types endpoint"""
 | 
				
			||||||
        user = User.objects.get(username="akadmin")
 | 
					        user = create_test_admin_user()
 | 
				
			||||||
        self.client.force_login(user)
 | 
					        self.client.force_login(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        response = self.client.get(
 | 
					        response = self.client.get(
 | 
				
			||||||
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user