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

|

|
||||||

|

|
||||||

|

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