Compare commits
86 Commits
version-20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
33cdbd7776 | |||
18bc54214d | |||
db7e9f9b95 | |||
a885247d36 | |||
91282c7bd8 | |||
830b8bcd5b | |||
0f5e6d0d8c | |||
6aa6615608 | |||
91d6a3c8c7 | |||
a6ac82c492 | |||
05d777c373 | |||
32cf960053 | |||
83bf639926 | |||
2717742bd2 | |||
ef70e93bbd | |||
478d3430eb | |||
9c1ade59e9 | |||
fadf746234 | |||
397dfc29f1 | |||
b0e3b8b39d | |||
df9ae796d4 | |||
dfdad5388f | |||
c38ea69bdd | |||
dca6f43858 | |||
51cbb7cc8e | |||
1f8130e685 | |||
580d59e921 | |||
e639d8ab56 | |||
9f478bb46a | |||
7a16f97908 | |||
dd8c1eeb52 | |||
005b4d8dda | |||
de2d8b2d85 | |||
7d107991a2 | |||
14dc420747 | |||
89dc4db30b | |||
cc3fccb27e | |||
add20de8de | |||
7e2a471903 | |||
9ca9e67ffa | |||
178417fe67 | |||
53f002a123 | |||
c7c387eb38 | |||
1b3760a4b7 | |||
704a502089 | |||
3b12ef80eb | |||
1101810fea | |||
1ab5289e2e | |||
ac24fc9ce3 | |||
4b24b185f2 | |||
ea0ba5ae30 | |||
44686de74e | |||
b74c08620a | |||
e25d03d8f4 | |||
f8f26d2a23 | |||
1f2e177e3e | |||
cfed41439e | |||
3ac148d01c | |||
3e696d6ac8 | |||
0114bc0d6a | |||
c60934f9b1 | |||
09bdcfaab0 | |||
624206281e | |||
4d7e64c48c | |||
3d112e7688 | |||
3c4ff65a01 | |||
d7f54ce5d5 | |||
bc55c97fa2 | |||
d9a907e39e | |||
8616647045 | |||
4d861e2830 | |||
881730f52e | |||
e78577d470 | |||
d502f4d77d | |||
3c5f7deba9 | |||
b61334c482 | |||
eb762632d0 | |||
6a882249aa | |||
94f6bbd431 | |||
3926ee9eb6 | |||
7fbf915e0a | |||
5af9e8c05d | |||
7c0c453d9f | |||
d8ae56ed19 | |||
a9a65ceca6 | |||
c11fd884b8 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2021.1.4-stable
|
||||
current_version = 2021.2.1-rc1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
|
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@ -18,11 +18,11 @@ jobs:
|
||||
- name: Building Docker Image
|
||||
run: docker build
|
||||
--no-cache
|
||||
-t beryju/authentik:2021.1.4-stable
|
||||
-t beryju/authentik:2021.2.1-rc1
|
||||
-t beryju/authentik:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik:2021.1.4-stable
|
||||
run: docker push beryju/authentik:2021.2.1-rc1
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik:latest
|
||||
build-proxy:
|
||||
@ -48,11 +48,11 @@ jobs:
|
||||
cd outpost/
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/authentik-proxy:2021.1.4-stable \
|
||||
-t beryju/authentik-proxy:2021.2.1-rc1 \
|
||||
-t beryju/authentik-proxy:latest \
|
||||
-f proxy.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik-proxy:2021.1.4-stable
|
||||
run: docker push beryju/authentik-proxy:2021.2.1-rc1
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik-proxy:latest
|
||||
build-static:
|
||||
@ -69,11 +69,11 @@ jobs:
|
||||
cd web/
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/authentik-static:2021.1.4-stable \
|
||||
-t beryju/authentik-static:2021.2.1-rc1 \
|
||||
-t beryju/authentik-static:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/authentik-static:2021.1.4-stable
|
||||
run: docker push beryju/authentik-static:2021.2.1-rc1
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/authentik-static:latest
|
||||
test-release:
|
||||
@ -107,5 +107,5 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
tagName: 2021.1.4-stable
|
||||
tagName: 2021.2.1-rc1
|
||||
environment: beryjuorg-prod
|
||||
|
88
Pipfile.lock
generated
88
Pipfile.lock
generated
@ -74,18 +74,17 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:a280123db79e73478bd23933486f3a0ffa2397d1a6381f32573f2731ff48c59a",
|
||||
"sha256:bb91fecf982e1bbfb68bb6bd2c9a0cce3c84ac6f97dd338d1ef9e47780679091"
|
||||
"sha256:1a282c1cd7d5028cbb3a75d747df32162295253f55d263ac85840e264830963b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.16.62"
|
||||
"version": "==1.17.2"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:1046c152e5865aabbe6b10b2d33e652b3dd072516f3976e96cacc6b7c4460d02",
|
||||
"sha256:29b4b9be5b40f392a033926c08c004c01bd6471384ef6f12eaa49ee3870a010c"
|
||||
"sha256:7442fdbbdc841bfac7f94f92ecb807de070e32ed205743eb72d4ea27c5e8e778",
|
||||
"sha256:bf587b044983a91a0124cc133ff167b8528c19fbbc8f0b956d9a1ac256cad7d7"
|
||||
],
|
||||
"version": "==1.19.62"
|
||||
"version": "==1.20.2"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
@ -127,6 +126,7 @@
|
||||
"sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009",
|
||||
"sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03",
|
||||
"sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b",
|
||||
"sha256:7ef7d4ced6b325e92eb4d3502946c78c5367bc416398d387b39591532536734e",
|
||||
"sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909",
|
||||
"sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53",
|
||||
"sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35",
|
||||
@ -265,11 +265,11 @@
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
|
||||
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
|
||||
"sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f",
|
||||
"sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.5"
|
||||
"version": "==3.1.6"
|
||||
},
|
||||
"django-cors-middleware": {
|
||||
"hashes": [
|
||||
@ -397,10 +397,10 @@
|
||||
},
|
||||
"google-auth": {
|
||||
"hashes": [
|
||||
"sha256:0b0e026b412a0ad096e753907559e4bdb180d9ba9f68dd9036164db4fdc4ad2e",
|
||||
"sha256:ce752cc51c31f479dbf9928435ef4b07514b20261b021c7383bee4bda646acb8"
|
||||
"sha256:008e23ed080674f69f9d2d7d80db4c2591b9bb307d136cea7b3bc129771d211d",
|
||||
"sha256:514e39f4190ca972200ba33876da5a8857c5665f2b4ccc36c8b8ee21228aae80"
|
||||
],
|
||||
"version": "==1.24.0"
|
||||
"version": "==1.25.0"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
@ -522,10 +522,10 @@
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
|
||||
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
|
||||
],
|
||||
"version": "==2.11.2"
|
||||
"version": "==2.11.3"
|
||||
},
|
||||
"jmespath": {
|
||||
"hashes": [
|
||||
@ -614,8 +614,12 @@
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||
"sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f",
|
||||
"sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014",
|
||||
"sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
@ -624,24 +628,39 @@
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85",
|
||||
"sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850",
|
||||
"sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1",
|
||||
"sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
||||
"sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5",
|
||||
"sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c",
|
||||
"sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
||||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
|
||||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
|
||||
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
@ -687,11 +706,11 @@
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
|
||||
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
|
||||
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
|
||||
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.8"
|
||||
"version": "==20.9"
|
||||
},
|
||||
"prometheus-client": {
|
||||
"hashes": [
|
||||
@ -900,10 +919,10 @@
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
||||
],
|
||||
"version": "==2020.5"
|
||||
"version": "==2021.1"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
@ -1084,7 +1103,6 @@
|
||||
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": null,
|
||||
"version": "==1.26.3"
|
||||
},
|
||||
"uvicorn": {
|
||||
@ -1275,10 +1293,11 @@
|
||||
},
|
||||
"autopep8": {
|
||||
"hashes": [
|
||||
"sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094"
|
||||
"sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea",
|
||||
"sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.5.4"
|
||||
"version": "==1.5.5"
|
||||
},
|
||||
"bandit": {
|
||||
"hashes": [
|
||||
@ -1382,11 +1401,11 @@
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7",
|
||||
"sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"
|
||||
"sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f",
|
||||
"sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.5"
|
||||
"version": "==3.1.6"
|
||||
},
|
||||
"django-debug-toolbar": {
|
||||
"hashes": [
|
||||
@ -1487,11 +1506,11 @@
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
|
||||
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
|
||||
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
|
||||
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.8"
|
||||
"version": "==20.9"
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
@ -1616,10 +1635,10 @@
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
||||
],
|
||||
"version": "==2020.5"
|
||||
"version": "==2021.1"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
@ -1808,7 +1827,6 @@
|
||||
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": null,
|
||||
"version": "==1.26.3"
|
||||
},
|
||||
"wrapt": {
|
||||
|
@ -1,2 +1,2 @@
|
||||
"""authentik"""
|
||||
__version__ = "2021.1.4-stable"
|
||||
__version__ = "2021.2.1-rc1"
|
||||
|
@ -1,5 +1,8 @@
|
||||
"""authentik admin tasks"""
|
||||
import re
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import URLValidator
|
||||
from packaging.version import parse
|
||||
from requests import RequestException, get
|
||||
from structlog.stdlib import get_logger
|
||||
@ -11,7 +14,9 @@ from authentik.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
VERSION_CACHE_KEY = "authentik_latest_version"
|
||||
VERSION_CACHE_TIMEOUT = 2 * 60 * 60 # 2 hours
|
||||
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
|
||||
# Chop of the first ^ because we want to search the entire string
|
||||
URL_FINDER = URLValidator.regex.pattern[1:]
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
@ -39,7 +44,10 @@ def update_latest_version(self: MonitoredTask):
|
||||
context__new_version=upstream_version,
|
||||
).exists():
|
||||
return
|
||||
Event.new(EventAction.UPDATE_AVAILABLE, new_version=upstream_version).save()
|
||||
event_dict = {"new_version": upstream_version}
|
||||
if match := re.search(URL_FINDER, data.get("body", "")):
|
||||
event_dict["message"] = f"Changelog: {match.group()}"
|
||||
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
|
||||
except (RequestException, IndexError) as exc:
|
||||
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
|
@ -3,7 +3,42 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block above_form %}
|
||||
<h1>{% blocktrans with policy=policy %}Test policy {{ policy }}{% endblocktrans %}</h1>
|
||||
<h1>{% blocktrans with policy=policy %}Test {{ policy }}{% endblocktrans %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block beneath_form %}
|
||||
{% if result %}
|
||||
<div class="pf-c-form__group ">
|
||||
<div class="pf-c-form__group-label">
|
||||
<label class="pf-c-form__label" for="context-1">
|
||||
<span class="pf-c-form__label-text">{% trans 'Passing' %}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pf-c-form__group-control">
|
||||
<div class="c-form__horizontal-group">
|
||||
<span class="pf-c-form__label-text">{{ result.passing|yesno:"Yes,No" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-form__group ">
|
||||
<div class="pf-c-form__group-label">
|
||||
<label class="pf-c-form__label" for="context-1">
|
||||
<span class="pf-c-form__label-text">{% trans 'Messages' %}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pf-c-form__group-control">
|
||||
<div class="c-form__horizontal-group">
|
||||
<ul>
|
||||
{% for m in result.messages %}
|
||||
<li><span class="pf-c-form__label-text">{{ m }}</span></li>
|
||||
{% empty %}
|
||||
<li><span class="pf-c-form__label-text">-</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block action %}
|
||||
|
@ -0,0 +1,28 @@
|
||||
{% extends 'generic/form.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block above_form %}
|
||||
<h1>{% blocktrans with property_mapping=property_mapping %}Test {{ property_mapping }}{% endblocktrans %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block beneath_form %}
|
||||
{% if result %}
|
||||
<div class="pf-c-form__group ">
|
||||
<div class="pf-c-form__group-label">
|
||||
<label class="pf-c-form__label" for="context-1">
|
||||
<span class="pf-c-form__label-text">{% trans 'Result' %}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pf-c-form__group-control">
|
||||
<div class="c-form__horizontal-group">
|
||||
<ak-codemirror mode="javascript"><textarea class="pf-c-form-control">{{ result }}</textarea></ak-codemirror>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block action %}
|
||||
{% trans 'Test' %}
|
||||
{% endblock %}
|
@ -32,7 +32,8 @@ REQUEST_MOCK_VALID = Mock(
|
||||
return_value=MockResponse(
|
||||
200,
|
||||
"""{
|
||||
"tag_name": "version/99999999.9999999"
|
||||
"tag_name": "version/99999999.9999999",
|
||||
"body": "https://goauthentik.io/test"
|
||||
}""",
|
||||
)
|
||||
)
|
||||
@ -52,6 +53,7 @@ class TestAdminTasks(TestCase):
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE,
|
||||
context__new_version="99999999.9999999",
|
||||
context__message="Changelog: https://goauthentik.io/test",
|
||||
).exists()
|
||||
)
|
||||
# test that a consecutive check doesn't create a duplicate event
|
||||
@ -61,6 +63,7 @@ class TestAdminTasks(TestCase):
|
||||
Event.objects.filter(
|
||||
action=EventAction.UPDATE_AVAILABLE,
|
||||
context__new_version="99999999.9999999",
|
||||
context__message="Changelog: https://goauthentik.io/test",
|
||||
)
|
||||
),
|
||||
1,
|
||||
|
@ -238,11 +238,6 @@ urlpatterns = [
|
||||
name="flow-delete",
|
||||
),
|
||||
# Property Mappings
|
||||
path(
|
||||
"property-mappings/",
|
||||
property_mappings.PropertyMappingListView.as_view(),
|
||||
name="property-mappings",
|
||||
),
|
||||
path(
|
||||
"property-mappings/create/",
|
||||
property_mappings.PropertyMappingCreateView.as_view(),
|
||||
@ -258,6 +253,11 @@ urlpatterns = [
|
||||
property_mappings.PropertyMappingDeleteView.as_view(),
|
||||
name="property-mapping-delete",
|
||||
),
|
||||
path(
|
||||
"property-mappings/<uuid:pk>/test/",
|
||||
property_mappings.PropertyMappingTestView.as_view(),
|
||||
name="property-mapping-test",
|
||||
),
|
||||
# Users
|
||||
path("users/", users.UserListView.as_view(), name="users"),
|
||||
path("users/create/", users.UserCreateView.as_view(), name="user-create"),
|
||||
|
@ -28,7 +28,7 @@ class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView):
|
||||
cache.delete_many(keys)
|
||||
LOGGER.debug("Cleared Policy cache", keys=len(keys))
|
||||
# Also delete user application cache
|
||||
keys = user_app_cache_key("*")
|
||||
keys = cache.keys(user_app_cache_key("*"))
|
||||
cache.delete_many(keys)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
@ -1,13 +1,11 @@
|
||||
"""authentik Policy administration"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
@ -99,7 +97,7 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
|
||||
template_name = "administration/policy/test.html"
|
||||
object = None
|
||||
|
||||
def get_object(self, queryset=None) -> QuerySet:
|
||||
def get_object(self, queryset=None) -> Policy:
|
||||
return (
|
||||
Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
|
||||
)
|
||||
@ -118,12 +116,10 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
|
||||
|
||||
p_request = PolicyRequest(user)
|
||||
p_request.http_request = self.request
|
||||
p_request.context = form.cleaned_data
|
||||
p_request.context = form.cleaned_data.get("context", {})
|
||||
|
||||
proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
|
||||
result = proc.execute()
|
||||
if result.passing:
|
||||
messages.success(self.request, _("User successfully passed policy."))
|
||||
else:
|
||||
messages.error(self.request, _("User didn't pass policy."))
|
||||
return self.render_to_response(self.get_context_data(form=form, result=result))
|
||||
context = self.get_context_data(form=form)
|
||||
context["result"] = result
|
||||
return self.render_to_response(context)
|
||||
|
@ -1,41 +1,29 @@
|
||||
"""authentik PropertyMapping administration"""
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import DetailView
|
||||
from guardian.mixins import PermissionRequiredMixin
|
||||
|
||||
from authentik.admin.forms.policies import PolicyTestForm
|
||||
from authentik.admin.views.utils import (
|
||||
BackSuccessUrlMixin,
|
||||
DeleteMessageView,
|
||||
InheritanceCreateView,
|
||||
InheritanceListView,
|
||||
InheritanceUpdateView,
|
||||
SearchListMixin,
|
||||
UserPaginateListMixin,
|
||||
)
|
||||
from authentik.core.models import PropertyMapping
|
||||
|
||||
|
||||
class PropertyMappingListView(
|
||||
LoginRequiredMixin,
|
||||
PermissionListMixin,
|
||||
UserPaginateListMixin,
|
||||
SearchListMixin,
|
||||
InheritanceListView,
|
||||
):
|
||||
"""Show list of all property_mappings"""
|
||||
|
||||
model = PropertyMapping
|
||||
permission_required = "authentik_core.view_propertymapping"
|
||||
template_name = "administration/property_mapping/list.html"
|
||||
ordering = "name"
|
||||
search_fields = ["name", "expression"]
|
||||
|
||||
|
||||
class PropertyMappingCreateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
@ -81,3 +69,44 @@ class PropertyMappingDeleteView(
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("authentik_admin:property-mappings")
|
||||
success_message = _("Successfully deleted Property Mapping")
|
||||
|
||||
|
||||
class PropertyMappingTestView(
|
||||
LoginRequiredMixin, DetailView, PermissionRequiredMixin, FormView
|
||||
):
|
||||
"""View to test property mappings"""
|
||||
|
||||
model = PropertyMapping
|
||||
form_class = PolicyTestForm
|
||||
permission_required = "authentik_core.view_propertymapping"
|
||||
template_name = "administration/property_mapping/test.html"
|
||||
object = None
|
||||
|
||||
def get_object(self, queryset=None) -> PropertyMapping:
|
||||
return (
|
||||
PropertyMapping.objects.filter(pk=self.kwargs.get("pk"))
|
||||
.select_subclasses()
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
kwargs["property_mapping"] = self.get_object()
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def post(self, *args, **kwargs) -> HttpResponse:
|
||||
self.object = self.get_object()
|
||||
return super().post(*args, **kwargs)
|
||||
|
||||
def form_valid(self, form: PolicyTestForm) -> HttpResponse:
|
||||
mapping = self.get_object()
|
||||
user = form.cleaned_data.get("user")
|
||||
|
||||
context = self.get_context_data(form=form)
|
||||
try:
|
||||
result = mapping.evaluate(
|
||||
user, self.request, **form.cleaned_data.get("context", {})
|
||||
)
|
||||
context["result"] = dumps(result, indent=4)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
context["result"] = str(exc)
|
||||
return self.render_to_response(context)
|
||||
|
@ -1,7 +1,31 @@
|
||||
{% extends "rest_framework/base.html" %}
|
||||
|
||||
{% block title %}{% if name %}{{ name }} – {% endif %}authentik{% endblock %}
|
||||
|
||||
{% block branding %}
|
||||
<span class='navbar-brand'>
|
||||
authentik
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block style %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
body {
|
||||
background-color: #18191a;
|
||||
color: #fafafa;
|
||||
}
|
||||
.prettyprint {
|
||||
background-color: #1c1e21;
|
||||
color: #fafafa;
|
||||
border: 1px solid #2b2e33;
|
||||
}
|
||||
.pln {
|
||||
color: #fafafa;
|
||||
}
|
||||
.well {
|
||||
background-color: #1c1e21;
|
||||
border: 1px solid #2b2e33;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
@ -4,9 +4,6 @@ from django.apps import AppConfig, apps
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.sites import AlreadyRegistered
|
||||
from guardian.admin import GuardedModelAdmin
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def admin_autoregister(app: AppConfig):
|
||||
@ -20,5 +17,4 @@ def admin_autoregister(app: AppConfig):
|
||||
|
||||
for _app in apps.get_app_configs():
|
||||
if _app.label.startswith("authentik_"):
|
||||
LOGGER.debug("Registering application for dj-admin", application=_app.label)
|
||||
admin_autoregister(_app)
|
||||
|
@ -91,7 +91,7 @@ class ApplicationViewSet(ModelViewSet):
|
||||
queryset = self._filter_queryset_for_list(self.get_queryset())
|
||||
self.paginate_queryset(queryset)
|
||||
|
||||
should_cache = "search" not in request.GET
|
||||
should_cache = request.GET.get("search", "") == ""
|
||||
|
||||
allowed_applications = []
|
||||
if not should_cache:
|
||||
|
@ -2,22 +2,36 @@
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.core.models import PropertyMapping
|
||||
|
||||
|
||||
class PropertyMappingSerializer(ModelSerializer):
|
||||
class PropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""PropertyMapping Serializer"""
|
||||
|
||||
__type__ = SerializerMethodField(method_name="get_type")
|
||||
object_type = SerializerMethodField(method_name="get_type")
|
||||
|
||||
def get_type(self, obj):
|
||||
"""Get object type so that we know which API Endpoint to use to get the full object"""
|
||||
return obj._meta.object_name.lower().replace("propertymapping", "")
|
||||
|
||||
def to_representation(self, instance: PropertyMapping):
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
if instance.__class__ == PropertyMapping:
|
||||
return super().to_representation(instance)
|
||||
return instance.serializer(instance=instance).data
|
||||
|
||||
class Meta:
|
||||
|
||||
model = PropertyMapping
|
||||
fields = ["pk", "name", "expression", "__type__"]
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"expression",
|
||||
"object_type",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
class PropertyMappingViewSet(ReadOnlyModelViewSet):
|
||||
@ -25,6 +39,11 @@ class PropertyMappingViewSet(ReadOnlyModelViewSet):
|
||||
|
||||
queryset = PropertyMapping.objects.none()
|
||||
serializer_class = PropertyMappingSerializer
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
filterset_fields = {"managed": ["isnull"]}
|
||||
ordering = ["name"]
|
||||
|
||||
def get_queryset(self):
|
||||
return PropertyMapping.objects.select_subclasses()
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Provider API Views"""
|
||||
from rest_framework.fields import ReadOnlyField
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
@ -9,18 +10,15 @@ from authentik.core.models import Provider
|
||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Provider Serializer"""
|
||||
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug")
|
||||
assigned_application_name = ReadOnlyField(source="application.name")
|
||||
|
||||
object_type = SerializerMethodField()
|
||||
|
||||
def get_object_type(self, obj):
|
||||
"""Get object type so that we know which API Endpoint to use to get the full object"""
|
||||
return obj._meta.object_name.lower().replace("provider", "")
|
||||
|
||||
def to_representation(self, instance: Provider):
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
if instance.__class__ == Provider:
|
||||
return super().to_representation(instance)
|
||||
return instance.serializer(instance=instance).data
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Provider
|
||||
@ -31,6 +29,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"authorization_flow",
|
||||
"property_mappings",
|
||||
"object_type",
|
||||
"assigned_application_slug",
|
||||
"assigned_application_name",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
@ -44,6 +44,10 @@ class ProviderViewSet(ModelViewSet):
|
||||
filterset_fields = {
|
||||
"application": ["isnull"],
|
||||
}
|
||||
search_fields = [
|
||||
"name",
|
||||
"application__name",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
return Provider.objects.select_subclasses()
|
||||
|
@ -1,4 +1,6 @@
|
||||
"""authentik core app config"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
@ -9,3 +11,6 @@ class AuthentikCoreConfig(AppConfig):
|
||||
label = "authentik_core"
|
||||
verbose_name = "authentik Core"
|
||||
mountpoint = ""
|
||||
|
||||
def ready(self):
|
||||
import_module("authentik.core.signals")
|
||||
|
35
authentik/core/migrations/0017_managed.py
Normal file
35
authentik/core/migrations/0017_managed.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 3.1.4 on 2021-01-30 18:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0016_auto_20201202_2234"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="propertymapping",
|
||||
name="managed",
|
||||
field=models.TextField(
|
||||
default=None,
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
verbose_name="Managed by authentik",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="token",
|
||||
name="managed",
|
||||
field=models.TextField(
|
||||
default=None,
|
||||
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
|
||||
null=True,
|
||||
verbose_name="Managed by authentik",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
]
|
@ -22,6 +22,7 @@ from authentik.core.signals import password_changed
|
||||
from authentik.core.types import UILoginButton
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||
from authentik.managed.models import ManagedModel
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -313,7 +314,7 @@ class TokenIntents(models.TextChoices):
|
||||
INTENT_RECOVERY = "recovery"
|
||||
|
||||
|
||||
class Token(ExpiringModel):
|
||||
class Token(ManagedModel, ExpiringModel):
|
||||
"""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)
|
||||
@ -341,7 +342,7 @@ class Token(ExpiringModel):
|
||||
]
|
||||
|
||||
|
||||
class PropertyMapping(models.Model):
|
||||
class PropertyMapping(SerializerModel, ManagedModel):
|
||||
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
|
||||
|
||||
pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@ -355,6 +356,11 @@ class PropertyMapping(models.Model):
|
||||
"""Return Form class used to edit this object"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[Serializer]:
|
||||
"""Get serializer for this model"""
|
||||
raise NotImplementedError
|
||||
|
||||
def evaluate(
|
||||
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
||||
) -> Any:
|
||||
|
@ -1,5 +1,24 @@
|
||||
"""authentik core signals"""
|
||||
from django.core.cache import cache
|
||||
from django.core.signals import Signal
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
|
||||
|
||||
@receiver(post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_application(sender, instance, created: bool, **_):
|
||||
"""Clear user's application cache upon application creation"""
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.api.applications import user_app_cache_key
|
||||
|
||||
if sender != Application:
|
||||
return
|
||||
if not created:
|
||||
return
|
||||
# Also delete user application cache
|
||||
keys = cache.keys(user_app_cache_key("*"))
|
||||
cache.delete_many(keys)
|
||||
|
@ -50,6 +50,7 @@ class EventViewSet(ReadOnlyModelViewSet):
|
||||
serializer_class = EventSerializer
|
||||
ordering = ["-created"]
|
||||
search_fields = [
|
||||
"event_uuid",
|
||||
"user",
|
||||
"action",
|
||||
"app",
|
||||
|
@ -15,6 +15,7 @@ class NotificationTransportForm(forms.ModelForm):
|
||||
"name",
|
||||
"mode",
|
||||
"webhook_url",
|
||||
"send_once",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
|
52
authentik/events/migrations/0012_auto_20210202_1821.py
Normal file
52
authentik/events/migrations/0012_auto_20210202_1821.py
Normal file
@ -0,0 +1,52 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-02 18:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0011_notification_rules_default_v1"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="notificationtransport",
|
||||
name="send_once",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Only send notification once, for example when sending a webhook into a chat channel.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("token_view", "Token View"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("system_task_execution", "System Task Execution"),
|
||||
("system_task_exception", "System Task Exception"),
|
||||
("configuration_error", "Configuration Error"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("update_available", "Update Available"),
|
||||
("custom_", "Custom Prefix"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
@ -184,6 +184,12 @@ class NotificationTransport(models.Model):
|
||||
mode = models.TextField(choices=TransportMode.choices)
|
||||
|
||||
webhook_url = models.TextField(blank=True)
|
||||
send_once = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel."
|
||||
),
|
||||
)
|
||||
|
||||
def send(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to user, called from async task"""
|
||||
@ -254,7 +260,6 @@ class NotificationTransport(models.Model):
|
||||
}
|
||||
if notification.event:
|
||||
body["attachments"][0]["title"] = notification.event.action
|
||||
body["attachments"][0]["text"] = notification.event.action
|
||||
try:
|
||||
response = post(self.webhook_url, json=body)
|
||||
response.raise_for_status()
|
||||
@ -267,17 +272,24 @@ class NotificationTransport(models.Model):
|
||||
|
||||
def send_email(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification via global email configuration"""
|
||||
body_trunc = (
|
||||
(notification.body[:75] + "..")
|
||||
if len(notification.body) > 75
|
||||
else notification.body
|
||||
)
|
||||
subject = "authentik Notification: "
|
||||
key_value = {}
|
||||
if notification.event:
|
||||
subject += notification.event.action
|
||||
for key, value in notification.event.context.items():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
key_value[key] = value
|
||||
else:
|
||||
subject += notification.body[:75]
|
||||
mail = TemplateEmailMessage(
|
||||
subject=f"authentik Notification: {body_trunc}",
|
||||
subject=subject,
|
||||
template_name="email/generic.html",
|
||||
to=[notification.user.email],
|
||||
template_context={
|
||||
"title": subject,
|
||||
"body": notification.body,
|
||||
"key_value": key_value,
|
||||
},
|
||||
)
|
||||
# Email is sent directly here, as the call to send() should have been from a task.
|
||||
|
@ -124,13 +124,6 @@ class MonitoredTask(Task):
|
||||
task_call_args=args,
|
||||
task_call_kwargs=kwargs,
|
||||
).save(self.result_timeout_hours)
|
||||
Event.new(
|
||||
EventAction.SYSTEM_TASK_EXECUTION,
|
||||
message=(
|
||||
f"Task {self.__name__} finished successfully: "
|
||||
"\n".join(self._result.messages)
|
||||
),
|
||||
).save()
|
||||
return super().after_return(status, retval, task_id, args, kwargs, einfo=einfo)
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
|
@ -65,15 +65,17 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
||||
|
||||
LOGGER.debug("e(trigger): event trigger matched", trigger=trigger)
|
||||
# Create the notification objects
|
||||
for user in trigger.group.users.all():
|
||||
notification = Notification.objects.create(
|
||||
severity=trigger.severity, body=event.summary, event=event, user=user
|
||||
)
|
||||
|
||||
for transport in trigger.transports.all():
|
||||
for transport in trigger.transports.all():
|
||||
for user in trigger.group.users.all():
|
||||
LOGGER.debug("created notif")
|
||||
notification = Notification.objects.create(
|
||||
severity=trigger.severity, body=event.summary, event=event, user=user
|
||||
)
|
||||
notification_transport.apply_async(
|
||||
args=[notification.pk, transport.pk], queue="authentik_events"
|
||||
)
|
||||
if transport.send_once:
|
||||
break
|
||||
|
||||
|
||||
@CELERY_APP.task(
|
||||
|
@ -8,6 +8,7 @@ from authentik.core.models import Group, User
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationRule,
|
||||
NotificationTransport,
|
||||
)
|
||||
@ -21,7 +22,7 @@ class TestEventsNotifications(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.group = Group.objects.create(name="test-group")
|
||||
self.user = User.objects.create(name="test-user")
|
||||
self.user = User.objects.create(name="test-user", username="test")
|
||||
self.group.users.add(self.user)
|
||||
self.group.save()
|
||||
|
||||
@ -88,3 +89,26 @@ class TestEventsNotifications(TestCase):
|
||||
):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(passes.call_count, 1)
|
||||
|
||||
def test_transport_once(self):
|
||||
"""Test transport's send_once"""
|
||||
user2 = User.objects.create(name="test2-user", username="test2")
|
||||
self.group.users.add(user2)
|
||||
self.group.save()
|
||||
|
||||
transport = NotificationTransport.objects.create(
|
||||
name="transport", send_once=True
|
||||
)
|
||||
NotificationRule.objects.filter(name__startswith="default").delete()
|
||||
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||
)
|
||||
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
|
||||
|
||||
execute_mock = MagicMock()
|
||||
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(Notification.objects.count(), 1)
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""authentik benchmark command"""
|
||||
from csv import DictWriter
|
||||
from multiprocessing import Manager, Process, cpu_count
|
||||
from multiprocessing import Manager, cpu_count, get_context
|
||||
from sys import stdout
|
||||
from time import time
|
||||
|
||||
@ -15,9 +15,11 @@ from authentik.flows.models import Flow
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
|
||||
LOGGER = get_logger()
|
||||
FORK_CTX = get_context("fork")
|
||||
PROCESS_CLASS = FORK_CTX.Process
|
||||
|
||||
|
||||
class FlowPlanProcess(Process): # pragma: no cover
|
||||
class FlowPlanProcess(PROCESS_CLASS): # pragma: no cover
|
||||
"""Test process which executes flow planner"""
|
||||
|
||||
def __init__(self, index, return_dict, flow, user) -> None:
|
||||
|
@ -6,7 +6,7 @@ from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import get_logger
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import cleanse_dict
|
||||
@ -16,7 +16,6 @@ from authentik.flows.models import Flow, FlowStageBinding, Stage
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
||||
PLAN_CONTEXT_SSO = "is_sso"
|
||||
PLAN_CONTEXT_REDIRECT = "redirect"
|
||||
@ -88,10 +87,13 @@ class FlowPlanner:
|
||||
|
||||
flow: Flow
|
||||
|
||||
_logger: BoundLogger
|
||||
|
||||
def __init__(self, flow: Flow):
|
||||
self.use_cache = True
|
||||
self.allow_empty_flows = False
|
||||
self.flow = flow
|
||||
self._logger = get_logger().bind(flow=flow)
|
||||
|
||||
def plan(
|
||||
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
|
||||
@ -103,7 +105,9 @@ class FlowPlanner:
|
||||
span.set_data("flow", self.flow)
|
||||
span.set_data("request", request)
|
||||
|
||||
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
|
||||
self._logger.debug(
|
||||
"f(plan): starting planning process",
|
||||
)
|
||||
# Bit of a workaround here, if there is a pending user set in the default context
|
||||
# we use that user for our cache key
|
||||
# to make sure they don't get the generic response
|
||||
@ -125,15 +129,16 @@ class FlowPlanner:
|
||||
cached_plan_key = cache_key(self.flow, user)
|
||||
cached_plan = cache.get(cached_plan_key, None)
|
||||
if cached_plan and self.use_cache:
|
||||
LOGGER.debug(
|
||||
"f(plan): Taking plan from cache",
|
||||
flow=self.flow,
|
||||
self._logger.debug(
|
||||
"f(plan): taking plan from cache",
|
||||
key=cached_plan_key,
|
||||
)
|
||||
# Reset the context as this isn't factored into caching
|
||||
cached_plan.context = default_context or {}
|
||||
return cached_plan
|
||||
LOGGER.debug("f(plan): building plan", flow=self.flow)
|
||||
self._logger.debug(
|
||||
"f(plan): building plan",
|
||||
)
|
||||
plan = self._build_plan(user, request, default_context)
|
||||
cache.set(cache_key(self.flow, user), plan)
|
||||
if not plan.stages and not self.allow_empty_flows:
|
||||
@ -165,39 +170,34 @@ class FlowPlanner:
|
||||
stage = binding.stage
|
||||
marker = StageMarker()
|
||||
if binding.evaluate_on_plan:
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(plan): evaluating on plan",
|
||||
stage=binding.stage,
|
||||
flow=self.flow,
|
||||
)
|
||||
engine = PolicyEngine(binding, user, request)
|
||||
engine.request.context = plan.context
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
LOGGER.debug(
|
||||
"f(plan): Stage passing",
|
||||
self._logger.debug(
|
||||
"f(plan): stage passing",
|
||||
stage=binding.stage,
|
||||
flow=self.flow,
|
||||
)
|
||||
else:
|
||||
stage = None
|
||||
else:
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(plan): not evaluating on plan",
|
||||
stage=binding.stage,
|
||||
flow=self.flow,
|
||||
)
|
||||
if binding.re_evaluate_policies and stage:
|
||||
LOGGER.debug(
|
||||
"f(plan): Stage has re-evaluate marker",
|
||||
self._logger.debug(
|
||||
"f(plan): stage has re-evaluate marker",
|
||||
stage=binding.stage,
|
||||
flow=self.flow,
|
||||
)
|
||||
marker = ReevaluateMarker(binding=binding, user=user)
|
||||
if stage:
|
||||
plan.append(stage, marker)
|
||||
LOGGER.debug(
|
||||
"f(plan): Finished building",
|
||||
flow=self.flow,
|
||||
self._logger.debug(
|
||||
"f(plan): finished building",
|
||||
)
|
||||
return plan
|
||||
|
@ -15,7 +15,7 @@ from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.generic import TemplateView, View
|
||||
from structlog.stdlib import get_logger
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
||||
from authentik.events.models import cleanse_dict
|
||||
@ -49,45 +49,48 @@ class FlowExecutorView(View):
|
||||
current_stage: Stage
|
||||
current_stage_view: View
|
||||
|
||||
_logger: BoundLogger
|
||||
|
||||
def setup(self, request: HttpRequest, flow_slug: str):
|
||||
super().setup(request, flow_slug=flow_slug)
|
||||
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
|
||||
self._logger = get_logger().bind(flow_slug=flow_slug)
|
||||
|
||||
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
||||
"""When a flow is non-applicable check if user is on the correct domain"""
|
||||
if NEXT_ARG_NAME in self.request.GET:
|
||||
if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)):
|
||||
LOGGER.debug("f(exec): Redirecting to next on fail")
|
||||
self._logger.debug("f(exec): Redirecting to next on fail")
|
||||
return redirect(self.request.GET.get(NEXT_ARG_NAME))
|
||||
message = exc.__doc__ if exc.__doc__ else str(exc)
|
||||
return self.stage_invalid(error_message=message)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
||||
# Early check if theres an active Plan for the current session
|
||||
if SESSION_KEY_PLAN in self.request.session:
|
||||
self.plan = self.request.session[SESSION_KEY_PLAN]
|
||||
if self.plan.flow_pk != self.flow.pk.hex:
|
||||
LOGGER.warning(
|
||||
self._logger.warning(
|
||||
"f(exec): Found existing plan for other flow, deleteing plan",
|
||||
flow_slug=flow_slug,
|
||||
)
|
||||
# Existing plan is deleted from session and instance
|
||||
self.plan = None
|
||||
self.cancel()
|
||||
LOGGER.debug("f(exec): Continuing existing plan", flow_slug=flow_slug)
|
||||
self._logger.debug("f(exec): Continuing existing plan")
|
||||
|
||||
# Don't check session again as we've either already loaded the plan or we need to plan
|
||||
if not self.plan:
|
||||
LOGGER.debug(
|
||||
"f(exec): No active Plan found, initiating planner", flow_slug=flow_slug
|
||||
)
|
||||
self._logger.debug("f(exec): No active Plan found, initiating planner")
|
||||
try:
|
||||
self.plan = self._initiate_plan()
|
||||
except FlowNonApplicableException as exc:
|
||||
LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc)
|
||||
self._logger.warning(
|
||||
"f(exec): Flow not applicable to current user", exc=exc
|
||||
)
|
||||
return to_stage_response(self.request, self.handle_invalid_flow(exc))
|
||||
except EmptyFlowException as exc:
|
||||
LOGGER.warning("f(exec): Flow is empty", exc=exc)
|
||||
self._logger.warning("f(exec): Flow is empty", exc=exc)
|
||||
# To match behaviour with loading an empty flow plan from cache,
|
||||
# we don't show an error message here, but rather call _flow_done()
|
||||
return self._flow_done()
|
||||
@ -95,10 +98,10 @@ class FlowExecutorView(View):
|
||||
# as it hasn't been successfully passed yet
|
||||
next_stage = self.plan.next(self.request)
|
||||
if not next_stage:
|
||||
LOGGER.debug("f(exec): no more stages, flow is done.")
|
||||
self._logger.debug("f(exec): no more stages, flow is done.")
|
||||
return self._flow_done()
|
||||
self.current_stage = next_stage
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(exec): Current stage",
|
||||
current_stage=self.current_stage,
|
||||
flow_slug=self.flow.slug,
|
||||
@ -112,32 +115,30 @@ class FlowExecutorView(View):
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""pass get request to current stage"""
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(exec): Passing GET",
|
||||
view_class=class_to_path(self.current_stage_view.__class__),
|
||||
stage=self.current_stage,
|
||||
flow_slug=self.flow.slug,
|
||||
)
|
||||
try:
|
||||
stage_response = self.current_stage_view.get(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.exception(exc)
|
||||
self._logger.exception(exc)
|
||||
return to_stage_response(request, FlowErrorResponse(request, exc))
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""pass post request to current stage"""
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(exec): Passing POST",
|
||||
view_class=class_to_path(self.current_stage_view.__class__),
|
||||
stage=self.current_stage,
|
||||
flow_slug=self.flow.slug,
|
||||
)
|
||||
try:
|
||||
stage_response = self.current_stage_view.post(request, *args, **kwargs)
|
||||
return to_stage_response(request, stage_response)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.exception(exc)
|
||||
self._logger.exception(exc)
|
||||
return to_stage_response(request, FlowErrorResponse(request, exc))
|
||||
|
||||
def _initiate_plan(self) -> FlowPlan:
|
||||
@ -163,26 +164,23 @@ class FlowExecutorView(View):
|
||||
def stage_ok(self) -> HttpResponse:
|
||||
"""Callback called by stages upon successful completion.
|
||||
Persists updated plan and context to session."""
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(exec): Stage ok",
|
||||
stage_class=class_to_path(self.current_stage_view.__class__),
|
||||
flow_slug=self.flow.slug,
|
||||
)
|
||||
self.plan.pop()
|
||||
self.request.session[SESSION_KEY_PLAN] = self.plan
|
||||
if self.plan.stages:
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(exec): Continuing with next stage",
|
||||
reamining=len(self.plan.stages),
|
||||
flow_slug=self.flow.slug,
|
||||
)
|
||||
return redirect_with_qs(
|
||||
"authentik_flows:flow-executor", self.request.GET, **self.kwargs
|
||||
)
|
||||
# User passed all stages
|
||||
LOGGER.debug(
|
||||
self._logger.debug(
|
||||
"f(exec): User passed all stages",
|
||||
flow_slug=self.flow.slug,
|
||||
context=cleanse_dict(self.plan.context),
|
||||
)
|
||||
return self._flow_done()
|
||||
@ -193,7 +191,7 @@ class FlowExecutorView(View):
|
||||
|
||||
Optionally, an exception can be passed, which will be shown if the current user
|
||||
is a superuser."""
|
||||
LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
|
||||
self._logger.debug("f(exec): Stage invalid")
|
||||
self.cancel()
|
||||
response = AccessDeniedResponse(
|
||||
self.request, template="flows/denied_shell.html"
|
||||
|
@ -1,7 +1,6 @@
|
||||
"""logging helpers"""
|
||||
from logging import Logger
|
||||
from os import getpid
|
||||
from typing import Callable
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@ -9,15 +8,3 @@ def add_process_id(logger: Logger, method_name: str, event_dict):
|
||||
"""Add the current process ID"""
|
||||
event_dict["pid"] = getpid()
|
||||
return event_dict
|
||||
|
||||
|
||||
def add_common_fields(environment: str) -> Callable:
|
||||
"""Add a common field to easily search for authentik logs"""
|
||||
|
||||
def add_common_field(logger: Logger, method_name: str, event_dict):
|
||||
"""Add a common field to easily search for authentik logs"""
|
||||
event_dict["app"] = "authentik"
|
||||
event_dict["app_environment"] = environment
|
||||
return event_dict
|
||||
|
||||
return add_common_field
|
||||
|
@ -59,6 +59,5 @@ def before_send(event, hint):
|
||||
if "exc_info" in hint:
|
||||
_, exc_value, _ = hint["exc_info"]
|
||||
if isinstance(exc_value, ignored_classes):
|
||||
LOGGER.info("Supressing error %r", exc_value)
|
||||
return None
|
||||
return event
|
||||
|
0
authentik/managed/__init__.py
Normal file
0
authentik/managed/__init__.py
Normal file
16
authentik/managed/apps.py
Normal file
16
authentik/managed/apps.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""authentik Managed app"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthentikManagedConfig(AppConfig):
|
||||
"""authentik Managed app"""
|
||||
|
||||
name = "authentik.managed"
|
||||
label = "authentik_Managed"
|
||||
verbose_name = "authentik Managed"
|
||||
|
||||
def ready(self) -> None:
|
||||
from authentik.managed.tasks import managed_reconcile
|
||||
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
managed_reconcile() # pylint: disable=no-value-for-parameter
|
56
authentik/managed/manager.py
Normal file
56
authentik/managed/manager.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Managed objects manager"""
|
||||
from typing import Type
|
||||
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.managed.models import ManagedModel
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class EnsureOp:
|
||||
"""Ensure operation, executed as part of an ObjectManager run"""
|
||||
|
||||
_obj: Type[ManagedModel]
|
||||
_managed_uid: str
|
||||
_kwargs: dict
|
||||
|
||||
def __init__(self, obj: Type[ManagedModel], managed_uid: str, **kwargs) -> None:
|
||||
self._obj = obj
|
||||
self._managed_uid = managed_uid
|
||||
self._kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
"""Do the actual ensure action"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class EnsureExists(EnsureOp):
|
||||
"""Ensure object exists, with kwargs as given values"""
|
||||
|
||||
def run(self):
|
||||
self._kwargs.setdefault("managed", self._managed_uid)
|
||||
self._obj.objects.update_or_create(
|
||||
**{
|
||||
"managed": self._managed_uid,
|
||||
"defaults": self._kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ObjectManager:
|
||||
"""Base class for Apps Object manager"""
|
||||
|
||||
def run(self):
|
||||
"""Main entrypoint for tasks, iterate through all implementation of this
|
||||
and execute all operations"""
|
||||
for sub in ObjectManager.__subclasses__():
|
||||
sub_inst = sub()
|
||||
ops = sub_inst.reconcile()
|
||||
LOGGER.debug("Reconciling managed objects", manager=sub.__name__)
|
||||
for operation in ops:
|
||||
operation.run()
|
||||
|
||||
def reconcile(self) -> list[EnsureOp]:
|
||||
"""Method which is implemented in subclass that returns a list of Operations"""
|
||||
raise NotImplementedError
|
31
authentik/managed/models.py
Normal file
31
authentik/managed/models.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""Managed Object models"""
|
||||
from django.db import models
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ManagedModel(models.Model):
|
||||
"""Model which can be managed by authentik exclusively"""
|
||||
|
||||
managed = models.TextField(
|
||||
default=None,
|
||||
null=True,
|
||||
verbose_name=_("Managed by authentik"),
|
||||
help_text=_(
|
||||
(
|
||||
"Objects which are managed by authentik. These objects are created and updated "
|
||||
"automatically. This is flag only indicates that an object can be overwritten by "
|
||||
"migrations. You can still modify the objects via the API, but expect changes "
|
||||
"to be overwritten in a later update."
|
||||
)
|
||||
),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
def managed_objects(self) -> QuerySet:
|
||||
"""Get all objects which are managed"""
|
||||
return self.objects.exclude(managed__isnull=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
10
authentik/managed/settings.py
Normal file
10
authentik/managed/settings.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""managed Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"managed_reconcile": {
|
||||
"task": "authentik.managed.tasks.managed_reconcile",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
20
authentik/managed/tasks.py
Normal file
20
authentik/managed/tasks.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""managed tasks"""
|
||||
from django.db import DatabaseError
|
||||
|
||||
from authentik.core.tasks import CELERY_APP
|
||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.managed.manager import ObjectManager
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
def managed_reconcile(self: MonitoredTask):
|
||||
"""Run ObjectManager to ensure objects are up-to-date"""
|
||||
try:
|
||||
ObjectManager().run()
|
||||
self.set_status(
|
||||
TaskResult(
|
||||
TaskResultStatus.SUCCESSFUL, ["Successfully updated managed models."]
|
||||
)
|
||||
)
|
||||
except DatabaseError as exc:
|
||||
self.set_status(TaskResult(TaskResultStatus.WARNING, [str(exc)]))
|
@ -363,6 +363,7 @@ class Outpost(models.Model):
|
||||
intent=TokenIntents.INTENT_API,
|
||||
description=f"Autogenerated by authentik for Outpost {self.name}",
|
||||
expiring=False,
|
||||
managed="goauthentik.io/outpost",
|
||||
)
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model]:
|
||||
|
@ -8,14 +8,13 @@ from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import get_logger
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
|
||||
from authentik.policies.process import PolicyProcess, cache_key
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
CURRENT_PROCESS = current_process()
|
||||
|
||||
|
||||
@ -49,6 +48,7 @@ class PolicyEngine:
|
||||
use_cache: bool
|
||||
request: PolicyRequest
|
||||
|
||||
logger: BoundLogger
|
||||
mode: PolicyEngineMode
|
||||
# Allow objects with no policies attached to pass
|
||||
empty_result: bool
|
||||
@ -62,6 +62,7 @@ class PolicyEngine:
|
||||
def __init__(
|
||||
self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None
|
||||
):
|
||||
self.logger = get_logger().bind()
|
||||
self.mode = PolicyEngineMode.MODE_AND
|
||||
# For backwards compatibility, set empty_result to true
|
||||
# objects with no policies attached will pass.
|
||||
@ -105,18 +106,18 @@ class PolicyEngine:
|
||||
key = cache_key(binding, self.request)
|
||||
cached_policy = cache.get(key, None)
|
||||
if cached_policy and self.use_cache:
|
||||
LOGGER.debug(
|
||||
self.logger.debug(
|
||||
"P_ENG: Taking result from cache",
|
||||
policy=binding.policy,
|
||||
cache_key=key,
|
||||
)
|
||||
self.__cached_policies.append(cached_policy)
|
||||
continue
|
||||
LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
|
||||
self.logger.debug("P_ENG: Evaluating policy", policy=binding.policy)
|
||||
our_end, task_end = Pipe(False)
|
||||
task = PolicyProcess(binding, self.request, task_end)
|
||||
task.daemon = False
|
||||
LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
|
||||
self.logger.debug("P_ENG: Starting Process", policy=binding.policy)
|
||||
if not CURRENT_PROCESS._config.get("daemon"):
|
||||
task.run()
|
||||
else:
|
||||
|
@ -0,0 +1,46 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-02 18:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_policies_event_matcher", "0004_auto_20210112_2158"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="eventmatcherpolicy",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("token_view", "Token View"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("system_task_execution", "System Task Execution"),
|
||||
("system_task_exception", "System Task Exception"),
|
||||
("configuration_error", "Configuration Error"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("update_available", "Update Available"),
|
||||
("custom_", "Custom Prefix"),
|
||||
],
|
||||
help_text="Match created events with this action type. When left empty, all action types will be matched.",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,73 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-03 11:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_policies_event_matcher", "0005_auto_20210202_1821"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="eventmatcherpolicy",
|
||||
name="app",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("authentik.admin", "authentik Admin"),
|
||||
("authentik.api", "authentik API"),
|
||||
("authentik.events", "authentik Events"),
|
||||
("authentik.crypto", "authentik Crypto"),
|
||||
("authentik.flows", "authentik Flows"),
|
||||
("authentik.outposts", "authentik Outpost"),
|
||||
("authentik.lib", "authentik lib"),
|
||||
("authentik.policies", "authentik Policies"),
|
||||
("authentik.policies.dummy", "authentik Policies.Dummy"),
|
||||
(
|
||||
"authentik.policies.event_matcher",
|
||||
"authentik Policies.Event Matcher",
|
||||
),
|
||||
("authentik.policies.expiry", "authentik Policies.Expiry"),
|
||||
("authentik.policies.expression", "authentik Policies.Expression"),
|
||||
(
|
||||
"authentik.policies.group_membership",
|
||||
"authentik Policies.Group Membership",
|
||||
),
|
||||
("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"),
|
||||
("authentik.policies.password", "authentik Policies.Password"),
|
||||
("authentik.policies.reputation", "authentik Policies.Reputation"),
|
||||
("authentik.providers.proxy", "authentik Providers.Proxy"),
|
||||
("authentik.providers.oauth2", "authentik Providers.OAuth2"),
|
||||
("authentik.providers.saml", "authentik Providers.SAML"),
|
||||
("authentik.recovery", "authentik Recovery"),
|
||||
("authentik.sources.ldap", "authentik Sources.LDAP"),
|
||||
("authentik.sources.oauth", "authentik Sources.OAuth"),
|
||||
("authentik.sources.saml", "authentik Sources.SAML"),
|
||||
("authentik.stages.captcha", "authentik Stages.Captcha"),
|
||||
("authentik.stages.consent", "authentik Stages.Consent"),
|
||||
("authentik.stages.dummy", "authentik Stages.Dummy"),
|
||||
("authentik.stages.email", "authentik Stages.Email"),
|
||||
("authentik.stages.prompt", "authentik Stages.Prompt"),
|
||||
(
|
||||
"authentik.stages.identification",
|
||||
"authentik Stages.Identification",
|
||||
),
|
||||
("authentik.stages.invitation", "authentik Stages.User Invitation"),
|
||||
("authentik.stages.user_delete", "authentik Stages.User Delete"),
|
||||
("authentik.stages.user_login", "authentik Stages.User Login"),
|
||||
("authentik.stages.user_logout", "authentik Stages.User Logout"),
|
||||
("authentik.stages.user_write", "authentik Stages.User Write"),
|
||||
("authentik.stages.otp_static", "authentik Stages.OTP.Static"),
|
||||
("authentik.stages.otp_time", "authentik Stages.OTP.Time"),
|
||||
("authentik.stages.otp_validate", "authentik Stages.OTP.Validate"),
|
||||
("authentik.stages.password", "authentik Stages.Password"),
|
||||
("authentik.managed", "authentik Managed"),
|
||||
("authentik.core", "authentik Core"),
|
||||
],
|
||||
default="",
|
||||
help_text="Match events created by selected application. When left empty, all applications are matched.",
|
||||
),
|
||||
),
|
||||
]
|
@ -17,6 +17,9 @@ from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
LOGGER = get_logger()
|
||||
TRACEBACK_HEADER = "Traceback (most recent call last):\n"
|
||||
|
||||
FORK_CTX = get_context("fork")
|
||||
PROCESS_CLASS = FORK_CTX.Process
|
||||
|
||||
|
||||
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
||||
"""Generate Cache key for policy"""
|
||||
@ -28,10 +31,6 @@ def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
||||
return prefix
|
||||
|
||||
|
||||
FORK_CTX = get_context("fork")
|
||||
PROCESS_CLASS = FORK_CTX.Process
|
||||
|
||||
|
||||
class PolicyProcess(PROCESS_CLASS):
|
||||
"""Evaluate a single policy within a seprate process"""
|
||||
|
||||
@ -103,17 +102,16 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
# Invert result if policy.negate is set
|
||||
if self.binding.negate:
|
||||
policy_result.passing = not policy_result.passing
|
||||
key = cache_key(self.binding, self.request)
|
||||
cache.set(key, policy_result)
|
||||
LOGGER.debug(
|
||||
"P_ENG(proc): Finished",
|
||||
"P_ENG(proc): finished and cached ",
|
||||
policy=self.binding.policy,
|
||||
result=policy_result,
|
||||
process="PolicyProcess",
|
||||
passing=policy_result.passing,
|
||||
user=self.request.user,
|
||||
)
|
||||
key = cache_key(self.binding, self.request)
|
||||
cache.set(key, policy_result)
|
||||
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
|
||||
return policy_result
|
||||
|
||||
def run(self): # pragma: no cover
|
||||
|
@ -26,5 +26,5 @@ def invalidate_policy_cache(sender, instance, **_):
|
||||
cache.delete_many(keys)
|
||||
LOGGER.debug("Invalidating policy cache", policy=instance, keys=total)
|
||||
# Also delete user application cache
|
||||
keys = user_app_cache_key("*")
|
||||
keys = cache.keys(user_app_cache_key("*"))
|
||||
cache.delete_many(keys)
|
||||
|
@ -1,20 +1,27 @@
|
||||
"""OAuth2Provider API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from django.shortcuts import reverse
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import ReadOnlyField
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, Serializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.core.models import Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"""OAuth2Provider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OAuth2Provider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"authorization_flow",
|
||||
"client_type",
|
||||
"client_id",
|
||||
@ -27,25 +34,83 @@ class OAuth2ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
class OAuth2ProviderSetupURLs(Serializer):
|
||||
"""OAuth2 Provider Metadata serializer"""
|
||||
|
||||
issuer = ReadOnlyField()
|
||||
authorize = ReadOnlyField()
|
||||
token = ReadOnlyField()
|
||||
user_info = ReadOnlyField()
|
||||
provider_info = ReadOnlyField()
|
||||
|
||||
def create(self, request: Request) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class OAuth2ProviderViewSet(ModelViewSet):
|
||||
"""OAuth2Provider Viewset"""
|
||||
|
||||
queryset = OAuth2Provider.objects.all()
|
||||
serializer_class = OAuth2ProviderSerializer
|
||||
|
||||
@action(methods=["GET"], detail=True)
|
||||
@swagger_auto_schema(responses={200: OAuth2ProviderSetupURLs(many=False)})
|
||||
# pylint: disable=invalid-name
|
||||
def setup_urls(self, request: Request, pk: int) -> str:
|
||||
"""Return metadata as XML string"""
|
||||
provider = get_object_or_404(OAuth2Provider, pk=pk)
|
||||
data = {
|
||||
"issuer": provider.get_issuer(request),
|
||||
"authorize": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:authorize",
|
||||
)
|
||||
),
|
||||
"token": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:token",
|
||||
)
|
||||
),
|
||||
"user_info": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:userinfo",
|
||||
)
|
||||
),
|
||||
"provider_info": None,
|
||||
}
|
||||
try:
|
||||
data["provider_info"] = request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:provider-info",
|
||||
kwargs={"application_slug": provider.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
pass
|
||||
return Response(data)
|
||||
|
||||
class ScopeMappingSerializer(ModelSerializer):
|
||||
|
||||
class ScopeMappingSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""ScopeMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ScopeMapping
|
||||
fields = ["pk", "name", "scope_name", "description", "expression"]
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"scope_name",
|
||||
"description",
|
||||
"expression",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
class ScopeMappingViewSet(ModelViewSet):
|
||||
|
@ -1,4 +1,6 @@
|
||||
"""authentik auth oauth provider app config"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
@ -12,3 +14,6 @@ class AuthentikProviderOAuth2Config(AppConfig):
|
||||
"authentik.providers.oauth2.urls": "application/o/",
|
||||
"authentik.providers.oauth2.urls_github": "",
|
||||
}
|
||||
|
||||
def ready(self) -> None:
|
||||
import_module("authentik.providers.oauth2.managed")
|
||||
|
@ -23,11 +23,12 @@ class OAuth2Error(SentryIgnoredException):
|
||||
def __repr__(self) -> str:
|
||||
return self.error
|
||||
|
||||
def to_event(self, message: Optional[str] = None) -> Event:
|
||||
def to_event(self, message: Optional[str] = None, **kwargs) -> Event:
|
||||
"""Create configuration_error Event and save it."""
|
||||
return Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=message or self.description,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@ -49,10 +50,11 @@ class RedirectUriError(OAuth2Error):
|
||||
self.provided_uri = provided_uri
|
||||
self.allowed_uris = allowed_uris
|
||||
|
||||
def to_event(self) -> Event:
|
||||
def to_event(self, **kwargs) -> Event:
|
||||
return super().to_event(
|
||||
f"Invalid redirect URI was used. Client used '{self.provided_uri}'. "
|
||||
f"Allowed redirect URIs are {','.join(self.allowed_uris)}"
|
||||
f"Allowed redirect URIs are {','.join(self.allowed_uris)}",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@ -68,8 +70,10 @@ class ClientIdError(OAuth2Error):
|
||||
super().__init__()
|
||||
self.client_id = client_id
|
||||
|
||||
def to_event(self) -> Event:
|
||||
return super().to_event(f"Invalid client identifier: {self.client_id}.")
|
||||
def to_event(self, **kwargs) -> Event:
|
||||
return super().to_event(
|
||||
f"Invalid client identifier: {self.client_id}.", **kwargs
|
||||
)
|
||||
|
||||
|
||||
class UserAuthError(OAuth2Error):
|
||||
|
58
authentik/providers/oauth2/managed.py
Normal file
58
authentik/providers/oauth2/managed.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""OAuth2 Provider managed objects"""
|
||||
from authentik.managed.manager import EnsureExists, ObjectManager
|
||||
from authentik.providers.oauth2.models import ScopeMapping
|
||||
|
||||
SCOPE_OPENID_EXPRESSION = """
|
||||
# This scope is required by the OpenID-spec, and must as such exist in authentik.
|
||||
# The scope by itself does not grant any information
|
||||
return {}
|
||||
"""
|
||||
SCOPE_EMAIL_EXPRESSION = """
|
||||
return {
|
||||
"email": user.email,
|
||||
"email_verified": True
|
||||
}
|
||||
"""
|
||||
SCOPE_PROFILE_EXPRESSION = """
|
||||
return {
|
||||
# Because authentik only saves the user's full name, and has no concept of first and last names,
|
||||
# the full name is used as given name.
|
||||
# You can override this behaviour in custom mappings, i.e. `user.name.split(" ")`
|
||||
"name": user.name,
|
||||
"given_name": user.name,
|
||||
"family_name": "",
|
||||
"preferred_username": user.username,
|
||||
"nickname": user.username,
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ScopeMappingManager(ObjectManager):
|
||||
"""OAuth2 Provider managed objects"""
|
||||
|
||||
def reconcile(self):
|
||||
return [
|
||||
EnsureExists(
|
||||
ScopeMapping,
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
name="authentik default OAuth Mapping: OpenID 'openid'",
|
||||
scope_name="openid",
|
||||
expression=SCOPE_OPENID_EXPRESSION,
|
||||
),
|
||||
EnsureExists(
|
||||
ScopeMapping,
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
name="authentik default OAuth Mapping: OpenID 'email'",
|
||||
scope_name="email",
|
||||
description="Email address",
|
||||
expression=SCOPE_EMAIL_EXPRESSION,
|
||||
),
|
||||
EnsureExists(
|
||||
ScopeMapping,
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
name="authentik default OAuth Mapping: OpenID 'profile'",
|
||||
scope_name="profile",
|
||||
description="General Profile Information",
|
||||
expression=SCOPE_PROFILE_EXPRESSION,
|
||||
),
|
||||
]
|
@ -10,54 +10,6 @@ import authentik.core.models
|
||||
import authentik.lib.utils.time
|
||||
import authentik.providers.oauth2.generators
|
||||
|
||||
SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself.
|
||||
return {}
|
||||
"""
|
||||
SCOPE_EMAIL_EXPRESSION = """return {
|
||||
"email": user.email,
|
||||
"email_verified": True
|
||||
}
|
||||
"""
|
||||
SCOPE_PROFILE_EXPRESSION = """return {
|
||||
"name": user.name,
|
||||
"given_name": user.name,
|
||||
"family_name": "",
|
||||
"preferred_username": user.username,
|
||||
"nickname": user.username,
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
|
||||
ScopeMapping.objects.update_or_create(
|
||||
scope_name="openid",
|
||||
defaults={
|
||||
"name": "Autogenerated OAuth2 Mapping: OpenID 'openid'",
|
||||
"scope_name": "openid",
|
||||
"description": "",
|
||||
"expression": SCOPE_OPENID_EXPRESSION,
|
||||
},
|
||||
)
|
||||
ScopeMapping.objects.update_or_create(
|
||||
scope_name="email",
|
||||
defaults={
|
||||
"name": "Autogenerated OAuth2 Mapping: OpenID 'email'",
|
||||
"scope_name": "email",
|
||||
"description": "Email address",
|
||||
"expression": SCOPE_EMAIL_EXPRESSION,
|
||||
},
|
||||
)
|
||||
ScopeMapping.objects.update_or_create(
|
||||
scope_name="profile",
|
||||
defaults={
|
||||
"name": "Autogenerated OAuth2 Mapping: OpenID 'profile'",
|
||||
"scope_name": "profile",
|
||||
"description": "General Profile Information",
|
||||
"expression": SCOPE_PROFILE_EXPRESSION,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@ -235,7 +187,6 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
bases=("authentik_core.propertymapping",),
|
||||
),
|
||||
migrations.RunPython(create_default_scopes),
|
||||
migrations.CreateModel(
|
||||
name="RefreshToken",
|
||||
fields=[
|
||||
|
33
authentik/providers/oauth2/migrations/0011_managed.py
Normal file
33
authentik/providers/oauth2/migrations/0011_managed.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-03 09:24
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
scope_uid_map = {
|
||||
"openid": "goauthentik.io/providers/oauth2/scope-openid",
|
||||
"email": "goauthentik.io/providers/oauth2/scope-email",
|
||||
"profile": "goauthentik.io/providers/oauth2/scope-profile",
|
||||
"ak_proxy": "goauthentik.io/providers/proxy/scope-proxy",
|
||||
}
|
||||
|
||||
|
||||
def set_managed_flag(apps: Apps, schema_editor):
|
||||
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for mapping in ScopeMapping.objects.using(db_alias).filter(
|
||||
name__startswith="Autogenerated "
|
||||
):
|
||||
mapping.managed = scope_uid_map[mapping.scope_name]
|
||||
mapping.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0017_managed"),
|
||||
("authentik_providers_oauth2", "0010_auto_20201227_1804"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_managed_flag),
|
||||
]
|
@ -14,7 +14,6 @@ from django.conf import settings
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils import dateformat, timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key
|
||||
@ -25,7 +24,6 @@ from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.lib.utils.template import render_to_string
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
@ -118,6 +116,12 @@ class ScopeMapping(PropertyMapping):
|
||||
|
||||
return ScopeMappingForm
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[Serializer]:
|
||||
from authentik.providers.oauth2.api import ScopeMappingSerializer
|
||||
|
||||
return ScopeMappingSerializer
|
||||
|
||||
def __str__(self):
|
||||
return f"Scope Mapping {self.name} ({self.scope_name})"
|
||||
|
||||
@ -303,41 +307,6 @@ class OAuth2Provider(Provider):
|
||||
jws = JWS(payload, alg=self.jwt_alg)
|
||||
return jws.sign_compact(keys)
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
return render_to_string(
|
||||
"providers/oauth2/setup_url_modal.html",
|
||||
{
|
||||
"provider": self,
|
||||
"issuer": self.get_issuer(request),
|
||||
"authorize": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:authorize",
|
||||
)
|
||||
),
|
||||
"token": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:token",
|
||||
)
|
||||
),
|
||||
"userinfo": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:userinfo",
|
||||
)
|
||||
),
|
||||
"provider_info": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:provider-info",
|
||||
kwargs={"application_slug": self.application.slug},
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OAuth2/OpenID Provider")
|
||||
|
@ -1,50 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ak-modal-button>
|
||||
<button slot="trigger" class="pf-c-button pf-m-tertiary">
|
||||
{% trans 'View Setup URLs' %}
|
||||
</button>
|
||||
<div slot="modal">
|
||||
<div class="pf-c-modal-box__header">
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration Issuer' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ issuer }}" />
|
||||
</div>
|
||||
<hr>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ authorize }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ token }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo }}" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<a class="pf-c-button pf-m-primary">{% trans 'Close' %}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</ak-modal-button>
|
@ -256,12 +256,12 @@ class OAuthFulfillmentStage(StageView):
|
||||
).from_http(self.request)
|
||||
return redirect(self.create_response_uri())
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
error.to_event().from_http(request)
|
||||
error.to_event(application=application).from_http(request)
|
||||
self.executor.stage_invalid()
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
except AuthorizeError as error:
|
||||
error.to_event().from_http(request)
|
||||
error.to_event(application=application).from_http(request)
|
||||
self.executor.stage_invalid()
|
||||
return redirect(error.create_uri())
|
||||
|
||||
@ -379,7 +379,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
try:
|
||||
self.params = OAuthAuthorizationParams.from_request(self.request)
|
||||
except AuthorizeError as error:
|
||||
error.to_event().from_http(self.request)
|
||||
error.to_event(redirect_uri=error.redirect_uri).from_http(self.request)
|
||||
raise RequestValidationError(redirect(error.create_uri()))
|
||||
except OAuth2Error as error:
|
||||
error.to_event().from_http(self.request)
|
||||
@ -396,7 +396,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
error.to_event().from_http(self.request)
|
||||
error.to_event(redirect_uri=error.redirect_uri).from_http(self.request)
|
||||
raise RequestValidationError(redirect(error.create_uri()))
|
||||
|
||||
def resolve_provider_application(self):
|
||||
|
@ -6,7 +6,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, Serializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
|
||||
@ -34,7 +34,7 @@ class OpenIDConnectConfigurationSerializer(Serializer):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
|
||||
class ProxyProviderSerializer(ProviderSerializer):
|
||||
"""ProxyProvider Serializer"""
|
||||
|
||||
def create(self, validated_data):
|
||||
@ -50,9 +50,7 @@ class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
model = ProxyProvider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"internal_host",
|
||||
"external_host",
|
||||
"internal_host_ssl_validation",
|
||||
@ -61,8 +59,6 @@ class ProxyProviderSerializer(MetaNameSerializer, ModelSerializer):
|
||||
"basic_auth_enabled",
|
||||
"basic_auth_password_attribute",
|
||||
"basic_auth_user_attribute",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
"""authentik Proxy app"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
@ -8,3 +10,6 @@ class AuthentikProviderProxyConfig(AppConfig):
|
||||
name = "authentik.providers.proxy"
|
||||
label = "authentik_providers_proxy"
|
||||
verbose_name = "authentik Providers.Proxy"
|
||||
|
||||
def ready(self) -> None:
|
||||
import_module("authentik.providers.proxy.managed")
|
||||
|
28
authentik/providers/proxy/managed.py
Normal file
28
authentik/providers/proxy/managed.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""OAuth2 Provider managed objects"""
|
||||
from authentik.managed.manager import EnsureExists, ObjectManager
|
||||
from authentik.providers.oauth2.models import ScopeMapping
|
||||
from authentik.providers.proxy.models import SCOPE_AK_PROXY
|
||||
|
||||
SCOPE_AK_PROXY_EXPRESSION = """
|
||||
# This mapping is used by the authentik proxy. It passes extra user attributes,
|
||||
# which are used for example for the HTTP-Basic Authentication mapping.
|
||||
return {
|
||||
"ak_proxy": {
|
||||
"user_attributes": user.group_attributes()
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
class ProxyScopeMappingManager(ObjectManager):
|
||||
"""OAuth2 Provider managed objects"""
|
||||
|
||||
def reconcile(self):
|
||||
return [
|
||||
EnsureExists(
|
||||
ScopeMapping,
|
||||
"goauthentik.io/providers/proxy/scope-proxy",
|
||||
name="authentik default OAuth Mapping: proxy outpost",
|
||||
scope_name=SCOPE_AK_PROXY,
|
||||
expression=SCOPE_AK_PROXY_EXPRESSION,
|
||||
),
|
||||
]
|
@ -1,35 +1,5 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-14 09:42
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
SCOPE_AK_PROXY_EXPRESSION = """return {
|
||||
"ak_proxy": {
|
||||
"user_attributes": user.group_attributes()
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
def create_proxy_scope(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from authentik.providers.proxy.models import SCOPE_AK_PROXY, ProxyProvider
|
||||
|
||||
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
|
||||
|
||||
ScopeMapping.objects.filter(scope_name="pb_proxy").delete()
|
||||
|
||||
ScopeMapping.objects.update_or_create(
|
||||
scope_name=SCOPE_AK_PROXY,
|
||||
defaults={
|
||||
"name": "Autogenerated OAuth2 Mapping: authentik Proxy",
|
||||
"scope_name": SCOPE_AK_PROXY,
|
||||
"description": "",
|
||||
"expression": SCOPE_AK_PROXY_EXPRESSION,
|
||||
},
|
||||
)
|
||||
|
||||
for provider in ProxyProvider.objects.all():
|
||||
provider.set_oauth_defaults()
|
||||
provider.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -38,4 +8,4 @@ class Migration(migrations.Migration):
|
||||
("authentik_providers_proxy", "0009_auto_20201007_1721"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(create_proxy_scope)]
|
||||
operations = []
|
||||
|
@ -6,7 +6,6 @@ from urllib.parse import urljoin
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
@ -119,10 +118,6 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
"""Use external_host as launch URL"""
|
||||
return self.external_host
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""Overwrite Setup URLs as they are not needed for proxy"""
|
||||
return None
|
||||
|
||||
def set_oauth_defaults(self):
|
||||
"""Ensure all OAuth2-related settings are correct"""
|
||||
self.client_type = ClientTypes.CONFIDENTIAL
|
||||
|
@ -1,20 +1,26 @@
|
||||
"""SAMLProvider API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import ReadOnlyField
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, Serializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.views import DescriptorDownloadView
|
||||
|
||||
|
||||
class SAMLProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
class SAMLProviderSerializer(ProviderSerializer):
|
||||
"""SAMLProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SAMLProvider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"acs_url",
|
||||
"audience",
|
||||
"issuer",
|
||||
@ -27,25 +33,52 @@ class SAMLProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"signature_algorithm",
|
||||
"signing_kp",
|
||||
"verification_kp",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
class SAMLMetadataSerializer(Serializer):
|
||||
"""SAML Provider Metadata serializer"""
|
||||
|
||||
metadata = ReadOnlyField()
|
||||
|
||||
def create(self, request: Request) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SAMLProviderViewSet(ModelViewSet):
|
||||
"""SAMLProvider Viewset"""
|
||||
|
||||
queryset = SAMLProvider.objects.all()
|
||||
serializer_class = SAMLProviderSerializer
|
||||
|
||||
@action(methods=["GET"], detail=True)
|
||||
@swagger_auto_schema(responses={200: SAMLMetadataSerializer(many=False)})
|
||||
# pylint: disable=invalid-name
|
||||
def metadata(self, request: Request, pk: int) -> str:
|
||||
"""Return metadata as XML string"""
|
||||
provider = get_object_or_404(SAMLProvider, pk=pk)
|
||||
metadata = DescriptorDownloadView.get_metadata(request, provider)
|
||||
return Response({"metadata": metadata})
|
||||
|
||||
class SAMLPropertyMappingSerializer(ModelSerializer):
|
||||
|
||||
class SAMLPropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""SAMLPropertyMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SAMLPropertyMapping
|
||||
fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"saml_name",
|
||||
"friendly_name",
|
||||
"expression",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
class SAMLPropertyMappingViewSet(ModelViewSet):
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""authentik SAML IdP app config"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
@ -10,3 +11,6 @@ class AuthentikProviderSAMLConfig(AppConfig):
|
||||
label = "authentik_providers_saml"
|
||||
verbose_name = "authentik Providers.SAML"
|
||||
mountpoint = "application/saml/"
|
||||
|
||||
def ready(self) -> None:
|
||||
import_module("authentik.providers.saml.managed")
|
||||
|
67
authentik/providers/saml/managed.py
Normal file
67
authentik/providers/saml/managed.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""SAML Provider managed objects"""
|
||||
from authentik.managed.manager import EnsureExists, ObjectManager
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping
|
||||
|
||||
GROUP_EXPRESSION = """
|
||||
for group in user.ak_groups.all():
|
||||
yield group.name
|
||||
"""
|
||||
|
||||
|
||||
class SAMLProviderManager(ObjectManager):
|
||||
"""SAML Provider managed objects"""
|
||||
|
||||
def reconcile(self):
|
||||
return [
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/upn",
|
||||
name="authentik default SAML Mapping: UPN",
|
||||
saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
|
||||
expression="return user.attributes.get('upn', user.email)",
|
||||
),
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/name",
|
||||
name="authentik default SAML Mapping: Name",
|
||||
saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
expression="return user.name",
|
||||
),
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/email",
|
||||
name="authentik default SAML Mapping: Email",
|
||||
saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
expression="return user.email",
|
||||
),
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/username",
|
||||
name="authentik default SAML Mapping: Username",
|
||||
saml_name="http://schemas.goauthentik.io/2021/02/saml/username",
|
||||
expression="return user.username",
|
||||
),
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/uid",
|
||||
name="authentik default SAML Mapping: User ID",
|
||||
saml_name="http://schemas.goauthentik.io/2021/02/saml/uid",
|
||||
expression="return user.pk",
|
||||
),
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/groups",
|
||||
name="authentik default SAML Mapping: Groups",
|
||||
saml_name="http://schemas.xmlsoap.org/claims/Group",
|
||||
expression=GROUP_EXPRESSION,
|
||||
),
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/ms-windowsaccountname",
|
||||
name="authentik default SAML Mapping: WindowsAccountname (Username)",
|
||||
saml_name=(
|
||||
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
|
||||
),
|
||||
expression="return user.username",
|
||||
),
|
||||
]
|
@ -3,61 +3,10 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_default_property_mappings(apps, schema_editor):
|
||||
"""Create default SAML Property Mappings"""
|
||||
SAMLPropertyMapping = apps.get_model(
|
||||
"authentik_providers_saml", "SAMLPropertyMapping"
|
||||
)
|
||||
db_alias = schema_editor.connection.alias
|
||||
defaults = [
|
||||
{
|
||||
"FriendlyName": "eduPersonPrincipalName",
|
||||
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
|
||||
"Expression": "return user.email",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "cn",
|
||||
"Name": "http://schemas.xmlsoap.org/claims/CommonName",
|
||||
"Expression": "return user.name",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "mail",
|
||||
"Name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
"Expression": "return user.email",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "displayName",
|
||||
"Name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||
"Expression": "return user.username",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "uid",
|
||||
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
|
||||
"Expression": "return user.pk",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "member-of",
|
||||
"Name": "http://schemas.xmlsoap.org/claims/Group",
|
||||
"Expression": "for group in user.ak_groups.all():\n yield group.name",
|
||||
},
|
||||
]
|
||||
for default in defaults:
|
||||
SAMLPropertyMapping.objects.using(db_alias).get_or_create(
|
||||
saml_name=default["Name"],
|
||||
friendly_name=default["FriendlyName"],
|
||||
expression=default["Expression"],
|
||||
defaults={
|
||||
"name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_saml", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_default_property_mappings),
|
||||
]
|
||||
operations = []
|
||||
|
57
authentik/providers/saml/migrations/0012_managed.py
Normal file
57
authentik/providers/saml/migrations/0012_managed.py
Normal file
@ -0,0 +1,57 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-02 19:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
saml_name_map = {
|
||||
"http://schemas.xmlsoap.org/claims/CommonName": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname",
|
||||
"member-of": "http://schemas.xmlsoap.org/claims/Group",
|
||||
"http://schemas.xmlsoap.org/claims/Group": "http://schemas.xmlsoap.org/claims/Group",
|
||||
"urn:oid:0.9.2342.19200300.100.1.1": "http://schemas.goauthentik.io/2021/02/saml/uid",
|
||||
"urn:oid:0.9.2342.19200300.100.1.3": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
"urn:oid:1.3.6.1.4.1.5923.1.1.1.6": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
|
||||
"urn:oid:2.16.840.1.113730.3.1.241": "http://schemas.goauthentik.io/2021/02/saml/username",
|
||||
"urn:oid:2.5.4.3": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
||||
}
|
||||
|
||||
saml_name_uid_map = {
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn": "goauthentik.io/providers/saml/upn",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "goauthentik.io/providers/saml/name",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "goauthentik.io/providers/saml/email",
|
||||
"http://schemas.goauthentik.io/2021/02/saml/username": "goauthentik.io/providers/saml/username",
|
||||
"http://schemas.goauthentik.io/2021/02/saml/uid": "goauthentik.io/providers/saml/uid",
|
||||
"http://schemas.xmlsoap.org/claims/Group": "goauthentik.io/providers/saml/groups",
|
||||
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname": "goauthentik.io/providers/saml/ms-windowsaccountname",
|
||||
}
|
||||
|
||||
|
||||
def add_managed_update(apps, schema_editor):
|
||||
"""Create default SAML Property Mappings"""
|
||||
SAMLPropertyMapping = apps.get_model(
|
||||
"authentik_providers_saml", "SAMLPropertyMapping"
|
||||
)
|
||||
db_alias = schema_editor.connection.alias
|
||||
for pm in SAMLPropertyMapping.objects.using(db_alias).filter(
|
||||
name__startswith="Autogenerated "
|
||||
):
|
||||
if pm.saml_name not in saml_name_map:
|
||||
continue
|
||||
new_name = saml_name_map[pm.saml_name]
|
||||
if not new_name:
|
||||
pm.delete()
|
||||
continue
|
||||
pm.saml_name = new_name
|
||||
pm.managed = saml_name_uid_map[new_name]
|
||||
pm.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0017_managed"),
|
||||
("authentik_providers_saml", "0011_samlprovider_name_id_mapping"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_managed_update),
|
||||
]
|
@ -4,15 +4,12 @@ from urllib.parse import urlparse
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import PropertyMapping, Provider
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.utils.template import render_to_string
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DSA_SHA1,
|
||||
@ -182,31 +179,6 @@ class SAMLProvider(Provider):
|
||||
def __str__(self):
|
||||
return f"SAML Provider {self.name}"
|
||||
|
||||
def link_download_metadata(self):
|
||||
"""Get link to download XML metadata for admin interface"""
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
return reverse(
|
||||
"authentik_providers_saml:metadata",
|
||||
kwargs={"application_slug": self.application.slug},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
def html_metadata_view(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal to view Metadata without downloading it"""
|
||||
from authentik.providers.saml.views import DescriptorDownloadView
|
||||
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
metadata = DescriptorDownloadView.get_metadata(request, self)
|
||||
return render_to_string(
|
||||
"providers/saml/admin_metadata_modal.html",
|
||||
{"provider": self, "metadata": metadata},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("SAML Provider")
|
||||
@ -225,6 +197,12 @@ class SAMLPropertyMapping(PropertyMapping):
|
||||
|
||||
return SAMLPropertyMappingForm
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[Serializer]:
|
||||
from authentik.providers.saml.api import SAMLPropertyMappingSerializer
|
||||
|
||||
return SAMLPropertyMappingSerializer
|
||||
|
||||
def __str__(self):
|
||||
name = self.friendly_name if self.friendly_name != "" else self.saml_name
|
||||
return f"{self.name} ({name})"
|
||||
|
@ -15,6 +15,7 @@ from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.request_parser import AuthNRequest
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.saml.exceptions import UnsupportedNameIDFormat
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
@ -80,7 +81,8 @@ class AssertionProcessor:
|
||||
continue
|
||||
|
||||
attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute")
|
||||
attribute.attrib["FriendlyName"] = mapping.friendly_name
|
||||
if mapping.friendly_name and mapping.friendly_name != "":
|
||||
attribute.attrib["FriendlyName"] = mapping.friendly_name
|
||||
attribute.attrib["Name"] = mapping.saml_name
|
||||
|
||||
if not isinstance(value, (list, GeneratorType)):
|
||||
@ -172,7 +174,7 @@ class AssertionProcessor:
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509:
|
||||
# This attribute is statically set by the LDAP source
|
||||
name_id.text = self.http_request.user.attributes.get(
|
||||
"distinguishedName", persistent
|
||||
LDAP_DISTINGUISHED_NAME, persistent
|
||||
)
|
||||
return name_id
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_WINDOWS:
|
||||
|
@ -76,6 +76,7 @@ class ServiceProviderMetadata:
|
||||
provider.property_mappings.set(
|
||||
SAMLPropertyMapping.objects.filter(name__startswith="Autogenerated")
|
||||
)
|
||||
provider.save()
|
||||
return provider
|
||||
|
||||
|
||||
|
@ -1,22 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ak-modal-button>
|
||||
<button slot="trigger" class="pf-c-button pf-m-tertiary">
|
||||
{% trans 'View Metadata' %}
|
||||
</button>
|
||||
<div slot="modal">
|
||||
<div class="pf-c-modal-box__header">
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Metadata' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<form method="post">
|
||||
<ak-codemirror mode="xml"><textarea class="pf-c-form-control"
|
||||
readonly>{{ metadata }}</textarea></ak-codemirror>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<a class="pf-c-button pf-m-primary">{% trans 'Close' %}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</ak-modal-button>
|
||||
|
@ -8,6 +8,7 @@ from lxml import etree # nosec
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||
from authentik.providers.saml.processors.request_parser import AuthNRequestParser
|
||||
@ -20,6 +21,7 @@ class TestSchema(TestCase):
|
||||
"""Test Requests and Responses against schema"""
|
||||
|
||||
def setUp(self):
|
||||
ObjectManager().run()
|
||||
cert = CertificateKeyPair.objects.first()
|
||||
self.provider: SAMLProvider = SAMLProvider.objects.create(
|
||||
authorization_flow=Flow.objects.get(
|
||||
|
@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from json import dumps
|
||||
@ -26,7 +27,7 @@ from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from authentik import __version__
|
||||
from authentik.core.middleware import structlog_add_request_id
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.logging import add_common_fields, add_process_id
|
||||
from authentik.lib.logging import add_process_id
|
||||
from authentik.lib.sentry import before_send
|
||||
|
||||
|
||||
@ -129,6 +130,7 @@ INSTALLED_APPS = [
|
||||
"django_prometheus",
|
||||
"channels",
|
||||
"dbbackup",
|
||||
"authentik.managed.apps.AuthentikManagedConfig",
|
||||
]
|
||||
|
||||
GUARDIAN_MONKEY_PATCH = False
|
||||
@ -336,7 +338,7 @@ if not DEBUG and _ERROR_REPORTING:
|
||||
RedisIntegration(),
|
||||
],
|
||||
before_send=before_send,
|
||||
release="authentik@%s" % __version__,
|
||||
release=f"authentik@{__version__}",
|
||||
traces_sample_rate=0.6,
|
||||
environment=CONFIG.y("error_reporting.environment", "customer"),
|
||||
send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False),
|
||||
@ -353,23 +355,26 @@ if not DEBUG and _ERROR_REPORTING:
|
||||
STATIC_URL = "/static/"
|
||||
MEDIA_URL = "/media/"
|
||||
|
||||
LOG_LEVEL = CONFIG.y("log_level").upper()
|
||||
|
||||
|
||||
structlog.configure_once(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.threadlocal.merge_threadlocal_context,
|
||||
add_process_id,
|
||||
add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
|
||||
structlog_add_request_id,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(),
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=False),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
context_class=structlog.threadlocal.wrap_dict(dict),
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
getattr(logging, LOG_LEVEL, logging.WARNING)
|
||||
),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
@ -410,8 +415,6 @@ LOGGING = {
|
||||
|
||||
TEST = False
|
||||
TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner"
|
||||
LOG_LEVEL = CONFIG.y("log_level").upper()
|
||||
|
||||
|
||||
_LOGGING_HANDLER_MAP = {
|
||||
"": LOG_LEVEL,
|
||||
|
@ -13,7 +13,7 @@ class PytestTestRunner: # pragma: no cover
|
||||
self.keepdb = keepdb
|
||||
settings.TEST = True
|
||||
settings.CELERY_TASK_ALWAYS_EAGER = True
|
||||
CONFIG.raw.get("authentik")["avatars"] = "none"
|
||||
CONFIG.y_set("authentik.avatars", "none")
|
||||
|
||||
def run_tests(self, test_labels):
|
||||
"""Run pytest and return the exitcode.
|
||||
|
@ -50,7 +50,7 @@ for _authentik_app in get_apps():
|
||||
LOGGER.debug(
|
||||
"Mounted URLs",
|
||||
app_name=_authentik_app.name,
|
||||
mountpoint=mountpoint,
|
||||
app_mountpoint=mountpoint,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
|
@ -22,23 +22,31 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"additional_group_dn",
|
||||
"user_object_filter",
|
||||
"group_object_filter",
|
||||
"user_group_membership_field",
|
||||
"group_membership_field",
|
||||
"object_uniqueness_field",
|
||||
"sync_users",
|
||||
"sync_users_password",
|
||||
"sync_groups",
|
||||
"sync_parent_group",
|
||||
"property_mappings",
|
||||
"property_mappings_group",
|
||||
]
|
||||
extra_kwargs = {"bind_password": {"write_only": True}}
|
||||
|
||||
|
||||
class LDAPPropertyMappingSerializer(ModelSerializer):
|
||||
class LDAPPropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""LDAP PropertyMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = LDAPPropertyMapping
|
||||
fields = ["pk", "name", "expression", "object_field"]
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"expression",
|
||||
"object_field",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
class LDAPSourceViewSet(ModelViewSet):
|
||||
|
@ -13,3 +13,4 @@ class AuthentikSourceLDAPConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
import_module("authentik.sources.ldap.signals")
|
||||
import_module("authentik.sources.ldap.managed")
|
||||
|
@ -10,6 +10,7 @@ from authentik.core.models import User
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
LDAP_DISTINGUISHED_NAME = "distinguishedName"
|
||||
|
||||
|
||||
class LDAPBackend(ModelBackend):
|
||||
@ -35,7 +36,7 @@ class LDAPBackend(ModelBackend):
|
||||
if not users.exists():
|
||||
return None
|
||||
user: User = users.first()
|
||||
if "distinguishedName" not in user.attributes:
|
||||
if LDAP_DISTINGUISHED_NAME not in user.attributes:
|
||||
LOGGER.debug(
|
||||
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
||||
)
|
||||
@ -63,7 +64,7 @@ class LDAPBackend(ModelBackend):
|
||||
try:
|
||||
temp_connection = ldap3.Connection(
|
||||
source.connection.server,
|
||||
user=user.attributes.get("distinguishedName"),
|
||||
user=user.attributes.get(LDAP_DISTINGUISHED_NAME),
|
||||
password=password,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
|
@ -14,6 +14,9 @@ class LDAPSourceForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all()
|
||||
self.fields[
|
||||
"property_mappings_group"
|
||||
].queryset = LDAPPropertyMapping.objects.all()
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -33,14 +36,16 @@ class LDAPSourceForm(forms.ModelForm):
|
||||
"sync_users_password",
|
||||
"sync_groups",
|
||||
"property_mappings",
|
||||
"property_mappings_group",
|
||||
"additional_user_dn",
|
||||
"additional_group_dn",
|
||||
"user_object_filter",
|
||||
"group_object_filter",
|
||||
"user_group_membership_field",
|
||||
"group_membership_field",
|
||||
"object_uniqueness_field",
|
||||
"sync_parent_group",
|
||||
]
|
||||
labels = {"property_mappings_group": _("Group property mappings")}
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"server_uri": forms.TextInput(),
|
||||
@ -51,7 +56,7 @@ class LDAPSourceForm(forms.ModelForm):
|
||||
"additional_group_dn": forms.TextInput(),
|
||||
"user_object_filter": forms.TextInput(),
|
||||
"group_object_filter": forms.TextInput(),
|
||||
"user_group_membership_field": forms.TextInput(),
|
||||
"group_membership_field": forms.TextInput(),
|
||||
"object_uniqueness_field": forms.TextInput(),
|
||||
}
|
||||
|
||||
|
69
authentik/sources/ldap/managed.py
Normal file
69
authentik/sources/ldap/managed.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""LDAP Source managed objects"""
|
||||
from authentik.managed.manager import EnsureExists, ObjectManager
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping
|
||||
|
||||
|
||||
class LDAPProviderManager(ObjectManager):
|
||||
"""LDAP Source managed objects"""
|
||||
|
||||
def reconcile(self):
|
||||
return [
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/default-name",
|
||||
name="authentik default LDAP Mapping: Name",
|
||||
object_field="name",
|
||||
expression="return ldap.get('name')",
|
||||
),
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/default-mail",
|
||||
name="authentik default LDAP Mapping: mail",
|
||||
object_field="email",
|
||||
expression="return ldap.get('mail')",
|
||||
),
|
||||
# Active Directory-specific mappings
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/ms-samaccountname",
|
||||
name="authentik default Active Directory Mapping: sAMAccountName",
|
||||
object_field="username",
|
||||
expression="return ldap.get('sAMAccountName')",
|
||||
),
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/ms-userprincipalname",
|
||||
name="authentik default Active Directory Mapping: userPrincipalName",
|
||||
object_field="attributes.upn",
|
||||
expression="return ldap.get('userPrincipalName')",
|
||||
),
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/ms-givenName",
|
||||
name="authentik default Active Directory Mapping: givenName",
|
||||
object_field="attributes.givenName",
|
||||
expression="return ldap.get('givenName')",
|
||||
),
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/ms-sn",
|
||||
name="authentik default Active Directory Mapping: sn",
|
||||
object_field="attributes.sn",
|
||||
expression="return ldap.get('sn')",
|
||||
),
|
||||
# OpenLDAP specific mappings
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/openldap-uid",
|
||||
name="authentik default OpenLDAP Mapping: uid",
|
||||
object_field="username",
|
||||
expression="return ldap.get('uid')",
|
||||
),
|
||||
EnsureExists(
|
||||
LDAPPropertyMapping,
|
||||
"goauthentik.io/sources/ldap/openldap-cn",
|
||||
name="authentik default OpenLDAP Mapping: cn",
|
||||
object_field="name",
|
||||
expression="return ldap.get('cn')",
|
||||
),
|
||||
]
|
@ -1,37 +1,12 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-23 19:30
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_default_ad_property_mappings(apps: Apps, schema_editor):
|
||||
LDAPPropertyMapping = apps.get_model(
|
||||
"authentik_sources_ldap", "LDAPPropertyMapping"
|
||||
)
|
||||
mapping = {
|
||||
"name": "return ldap.get('name')",
|
||||
"first_name": "return ldap.get('givenName')",
|
||||
"last_name": "return ldap.get('sn')",
|
||||
"username": "return ldap.get('sAMAccountName')",
|
||||
"email": "return ldap.get('mail')",
|
||||
}
|
||||
db_alias = schema_editor.connection.alias
|
||||
for object_field, expression in mapping.items():
|
||||
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
|
||||
expression=expression,
|
||||
object_field=object_field,
|
||||
defaults={
|
||||
"name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_ldap", "0002_ldapsource_sync_users"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_default_ad_property_mappings),
|
||||
]
|
||||
operations = []
|
||||
|
@ -1,50 +1,12 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-15 19:19
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_default_property_mappings(apps: Apps, schema_editor):
|
||||
LDAPPropertyMapping = apps.get_model(
|
||||
"authentik_sources_ldap", "LDAPPropertyMapping"
|
||||
)
|
||||
db_alias = schema_editor.connection.alias
|
||||
mapping = {
|
||||
"name": "name",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
"email": "mail",
|
||||
}
|
||||
for object_field, ldap_field in mapping.items():
|
||||
expression = f"return ldap.get('{ldap_field}')"
|
||||
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
|
||||
expression=expression,
|
||||
object_field=object_field,
|
||||
defaults={
|
||||
"name": f"Autogenerated LDAP Mapping: {ldap_field} -> {object_field}"
|
||||
},
|
||||
)
|
||||
ad_mapping = {
|
||||
"username": "sAMAccountName",
|
||||
"attributes.upn": "userPrincipalName",
|
||||
}
|
||||
for object_field, ldap_field in ad_mapping.items():
|
||||
expression = f"return ldap.get('{ldap_field}')"
|
||||
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
|
||||
expression=expression,
|
||||
object_field=object_field,
|
||||
defaults={
|
||||
"name": f"Autogenerated Active Directory Mapping: {ldap_field} -> {object_field}"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_ldap", "0005_auto_20200913_1947"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_default_property_mappings),
|
||||
]
|
||||
operations = []
|
||||
|
34
authentik/sources/ldap/migrations/0008_managed.py
Normal file
34
authentik/sources/ldap/migrations/0008_managed.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-02 20:51
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_managed_flag(apps: Apps, schema_editor):
|
||||
LDAPPropertyMapping = apps.get_model(
|
||||
"authentik_sources_ldap", "LDAPPropertyMapping"
|
||||
)
|
||||
db_alias = schema_editor.connection.alias
|
||||
field_to_uid = {
|
||||
"name": "goauthentik.io/sources/ldap/default-name",
|
||||
"email": "goauthentik.io/sources/ldap/default-mail",
|
||||
"username": "goauthentik.io/sources/ldap/ms-samaccountname",
|
||||
"attributes.upn": "goauthentik.io/sources/ldap/ms-userprincipalname",
|
||||
"first_name": "goauthentik.io/sources/ldap/ms-givenName",
|
||||
"last_name": "goauthentik.io/sources/ldap/ms-sn",
|
||||
}
|
||||
for mapping in LDAPPropertyMapping.objects.using(db_alias).filter(
|
||||
name__startswith="Autogenerated "
|
||||
):
|
||||
mapping.managed = field_to_uid.get(mapping.object_field)
|
||||
mapping.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0017_managed"),
|
||||
("authentik_sources_ldap", "0007_ldapsource_sync_users_password"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(set_managed_flag)]
|
24
authentik/sources/ldap/migrations/0009_auto_20210204_1834.py
Normal file
24
authentik/sources/ldap/migrations/0009_auto_20210204_1834.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-04 18:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_ldap", "0008_managed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="ldapsource",
|
||||
name="user_group_membership_field",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ldapsource",
|
||||
name="group_membership_field",
|
||||
field=models.TextField(
|
||||
default="member", help_text="Field which contains members of a group."
|
||||
),
|
||||
),
|
||||
]
|
29
authentik/sources/ldap/migrations/0010_auto_20210205_1027.py
Normal file
29
authentik/sources/ldap/migrations/0010_auto_20210205_1027.py
Normal file
@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-05 10:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_ldap", "0009_auto_20210204_1834"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="group_object_filter",
|
||||
field=models.TextField(
|
||||
default="(objectClass=group)",
|
||||
help_text="Consider Objects matching this filter to be Groups.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="user_object_filter",
|
||||
field=models.TextField(
|
||||
default="(objectClass=person)",
|
||||
help_text="Consider Objects matching this filter to be Users.",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,43 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-06 14:01
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_default_group_mappings(apps: Apps, schema_editor):
|
||||
LDAPPropertyMapping = apps.get_model(
|
||||
"authentik_sources_ldap", "LDAPPropertyMapping"
|
||||
)
|
||||
LDAPSource = apps.get_model("authentik_sources_ldap", "LDAPSource")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for source in LDAPSource.objects.using(db_alias).all():
|
||||
if source.property_mappings_group.exists():
|
||||
continue
|
||||
source.property_mappings_group.set(
|
||||
LDAPPropertyMapping.objects.using(db_alias).filter(
|
||||
managed="goauthentik.io/sources/ldap/default-name"
|
||||
)
|
||||
)
|
||||
source.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_ldap", "0010_auto_20210205_1027"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ldapsource",
|
||||
name="property_mappings_group",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Property mappings used for group creation/updating.",
|
||||
to="authentik_core.PropertyMapping",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_default_group_mappings),
|
||||
]
|
@ -38,20 +38,27 @@ class LDAPSource(Source):
|
||||
)
|
||||
|
||||
user_object_filter = models.TextField(
|
||||
default="(objectCategory=Person)",
|
||||
default="(objectClass=person)",
|
||||
help_text=_("Consider Objects matching this filter to be Users."),
|
||||
)
|
||||
user_group_membership_field = models.TextField(
|
||||
default="memberOf", help_text=_("Field which contains Groups of user.")
|
||||
group_membership_field = models.TextField(
|
||||
default="member", help_text=_("Field which contains members of a group.")
|
||||
)
|
||||
group_object_filter = models.TextField(
|
||||
default="(objectCategory=Group)",
|
||||
default="(objectClass=group)",
|
||||
help_text=_("Consider Objects matching this filter to be Groups."),
|
||||
)
|
||||
object_uniqueness_field = models.TextField(
|
||||
default="objectSid", help_text=_("Field which contains a unique Identifier.")
|
||||
)
|
||||
|
||||
property_mappings_group = models.ManyToManyField(
|
||||
PropertyMapping,
|
||||
default=None,
|
||||
blank=True,
|
||||
help_text=_("Property mappings used for group creation/updating."),
|
||||
)
|
||||
|
||||
sync_users = models.BooleanField(default=True)
|
||||
sync_users_password = models.BooleanField(
|
||||
default=True,
|
||||
@ -130,6 +137,12 @@ class LDAPPropertyMapping(PropertyMapping):
|
||||
|
||||
return LDAPPropertyMappingForm
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[Serializer]:
|
||||
from authentik.sources.ldap.api import LDAPPropertyMappingSerializer
|
||||
|
||||
return LDAPPropertyMappingSerializer
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
@ -8,6 +8,7 @@ import ldap3.core.exceptions
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -74,9 +75,9 @@ class LDAPPasswordChanger:
|
||||
|
||||
def change_password(self, user: User, password: str):
|
||||
"""Change user's password"""
|
||||
user_dn = user.attributes.get("distinguishedName", None)
|
||||
user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
|
||||
if not user_dn:
|
||||
raise AttributeError("User has no distinguishedName set.")
|
||||
raise AttributeError(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
|
||||
self._source.connection.extend.microsoft.modify_password(user_dn, password)
|
||||
|
||||
def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
|
||||
@ -117,9 +118,9 @@ class LDAPPasswordChanger:
|
||||
"""
|
||||
if user:
|
||||
# Check if password contains sAMAccountName or displayNames
|
||||
if "distinguishedName" in user.attributes:
|
||||
if LDAP_DISTINGUISHED_NAME in user.attributes:
|
||||
existing_user_check = self._ad_check_password_existing(
|
||||
password, user.attributes.get("distinguishedName")
|
||||
password, user.attributes.get(LDAP_DISTINGUISHED_NAME)
|
||||
)
|
||||
if not existing_user_check:
|
||||
LOGGER.debug("Password failed name check", user=user)
|
||||
|
@ -1,191 +0,0 @@
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
from typing import Any, Dict
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from django.db.utils import IntegrityError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class LDAPSynchronizer:
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
_source: LDAPSource
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
self._source = source
|
||||
|
||||
@property
|
||||
def base_dn_users(self) -> str:
|
||||
"""Shortcut to get full base_dn for user lookups"""
|
||||
if self._source.additional_user_dn:
|
||||
return f"{self._source.additional_user_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
@property
|
||||
def base_dn_groups(self) -> str:
|
||||
"""Shortcut to get full base_dn for group lookups"""
|
||||
if self._source.additional_group_dn:
|
||||
return f"{self._source.additional_group_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
def sync_groups(self) -> int:
|
||||
"""Iterate over all LDAP Groups and create authentik_core.Group instances"""
|
||||
if not self._source.sync_groups:
|
||||
LOGGER.warning("Group syncing is disabled for this Source")
|
||||
return -1
|
||||
groups = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=ldap3.ALL_ATTRIBUTES,
|
||||
)
|
||||
group_count = 0
|
||||
for group in groups:
|
||||
attributes = group.get("attributes", {})
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
LOGGER.warning(
|
||||
"Cannot find uniqueness Field in attributes", user=attributes.keys()
|
||||
)
|
||||
continue
|
||||
uniq = attributes[self._source.object_uniqueness_field]
|
||||
_, created = Group.objects.update_or_create(
|
||||
attributes__ldap_uniq=uniq,
|
||||
parent=self._source.sync_parent_group,
|
||||
defaults={
|
||||
"name": attributes.get("name", ""),
|
||||
"attributes": {
|
||||
"ldap_uniq": uniq,
|
||||
"distinguishedName": attributes.get("distinguishedName"),
|
||||
},
|
||||
},
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Synced group", group=attributes.get("name", ""), created=created
|
||||
)
|
||||
group_count += 1
|
||||
return group_count
|
||||
|
||||
def sync_users(self) -> int:
|
||||
"""Iterate over all LDAP Users and create authentik_core.User instances"""
|
||||
if not self._source.sync_users:
|
||||
LOGGER.warning("User syncing is disabled for this Source")
|
||||
return -1
|
||||
users = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=ldap3.ALL_ATTRIBUTES,
|
||||
)
|
||||
user_count = 0
|
||||
for user in users:
|
||||
attributes = user.get("attributes", {})
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
LOGGER.warning(
|
||||
"Cannot find uniqueness Field in attributes", user=user.keys()
|
||||
)
|
||||
continue
|
||||
uniq = attributes[self._source.object_uniqueness_field]
|
||||
try:
|
||||
defaults = self._build_object_properties(attributes)
|
||||
user, created = User.objects.update_or_create(
|
||||
attributes__ldap_uniq=uniq,
|
||||
defaults=defaults,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
LOGGER.warning("Failed to create user", exc=exc)
|
||||
LOGGER.warning(
|
||||
(
|
||||
"To merge new User with existing user, set the User's "
|
||||
f"Attribute 'ldap_uniq' to '{uniq}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
if created:
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
LOGGER.debug(
|
||||
"Synced User", user=attributes.get("name", ""), created=created
|
||||
)
|
||||
user_count += 1
|
||||
return user_count
|
||||
|
||||
def sync_membership(self):
|
||||
"""Iterate over all Users and assign Groups using memberOf Field"""
|
||||
users = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[
|
||||
self._source.user_group_membership_field,
|
||||
self._source.object_uniqueness_field,
|
||||
],
|
||||
)
|
||||
group_cache: Dict[str, Group] = {}
|
||||
for user in users:
|
||||
member_of = user.get("attributes", {}).get(
|
||||
self._source.user_group_membership_field, []
|
||||
)
|
||||
uniq = user.get("attributes", {}).get(
|
||||
self._source.object_uniqueness_field, []
|
||||
)
|
||||
for group_dn in member_of:
|
||||
# Check if group_dn is within our base_dn_groups, and skip if not
|
||||
if not group_dn.endswith(self.base_dn_groups):
|
||||
continue
|
||||
# Check if we fetched the group already, and if not cache it for later
|
||||
if group_dn not in group_cache:
|
||||
groups = Group.objects.filter(
|
||||
attributes__distinguishedName=group_dn
|
||||
)
|
||||
if not groups.exists():
|
||||
LOGGER.warning(
|
||||
"Group does not exist in our DB yet, run sync_groups first.",
|
||||
group=group_dn,
|
||||
)
|
||||
return
|
||||
group_cache[group_dn] = groups.first()
|
||||
group = group_cache[group_dn]
|
||||
users = User.objects.filter(attributes__ldap_uniq=uniq)
|
||||
group.users.add(*list(users))
|
||||
# Now that all users are added, lets write everything
|
||||
for _, group in group_cache.items():
|
||||
group.save()
|
||||
LOGGER.debug("Successfully updated group membership")
|
||||
|
||||
def _build_object_properties(
|
||||
self, attributes: Dict[str, Any]
|
||||
) -> Dict[str, Dict[Any, Any]]:
|
||||
properties = {"attributes": {}}
|
||||
for mapping in self._source.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, LDAPPropertyMapping):
|
||||
continue
|
||||
mapping: LDAPPropertyMapping
|
||||
try:
|
||||
value = mapping.evaluate(user=None, request=None, ldap=attributes)
|
||||
if value is None:
|
||||
continue
|
||||
object_field = mapping.object_field
|
||||
if object_field.startswith("attributes."):
|
||||
properties["attributes"][
|
||||
object_field.replace("attributes.", "")
|
||||
] = value
|
||||
else:
|
||||
properties[object_field] = value
|
||||
except PropertyMappingExpressionException as exc:
|
||||
LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
|
||||
continue
|
||||
if self._source.object_uniqueness_field in attributes:
|
||||
properties["attributes"]["ldap_uniq"] = attributes.get(
|
||||
self._source.object_uniqueness_field
|
||||
)
|
||||
properties["attributes"]["distinguishedName"] = attributes.get(
|
||||
"distinguishedName"
|
||||
)
|
||||
return properties
|
0
authentik/sources/ldap/sync/__init__.py
Normal file
0
authentik/sources/ldap/sync/__init__.py
Normal file
95
authentik/sources/ldap/sync/base.py
Normal file
95
authentik/sources/ldap/sync/base.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
from typing import Any
|
||||
|
||||
from django.db.models.query import QuerySet
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
||||
LDAP_UNIQUENESS = "ldap_uniq"
|
||||
|
||||
|
||||
class BaseLDAPSynchronizer:
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
_source: LDAPSource
|
||||
_logger: BoundLogger
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
self._source = source
|
||||
self._logger = get_logger().bind(source=source)
|
||||
|
||||
@property
|
||||
def base_dn_users(self) -> str:
|
||||
"""Shortcut to get full base_dn for user lookups"""
|
||||
if self._source.additional_user_dn:
|
||||
return f"{self._source.additional_user_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
@property
|
||||
def base_dn_groups(self) -> str:
|
||||
"""Shortcut to get full base_dn for group lookups"""
|
||||
if self._source.additional_group_dn:
|
||||
return f"{self._source.additional_group_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
def sync(self) -> int:
|
||||
"""Sync function, implemented in subclass"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _flatten(self, value: Any) -> Any:
|
||||
"""Flatten `value` if its a list"""
|
||||
if isinstance(value, list):
|
||||
if len(value) < 1:
|
||||
return None
|
||||
return value[0]
|
||||
return value
|
||||
|
||||
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
|
||||
"""Build attributes for User object based on property mappings."""
|
||||
return self._build_object_properties(
|
||||
user_dn, self._source.property_mappings, **kwargs
|
||||
)
|
||||
|
||||
def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
|
||||
"""Build attributes for Group object based on property mappings."""
|
||||
return self._build_object_properties(
|
||||
group_dn, self._source.property_mappings_group, **kwargs
|
||||
)
|
||||
|
||||
def _build_object_properties(
|
||||
self, object_dn: str, mappings: QuerySet, **kwargs
|
||||
) -> dict[str, dict[Any, Any]]:
|
||||
properties = {"attributes": {}}
|
||||
for mapping in mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, LDAPPropertyMapping):
|
||||
continue
|
||||
mapping: LDAPPropertyMapping
|
||||
try:
|
||||
value = mapping.evaluate(
|
||||
user=None, request=None, ldap=kwargs, dn=object_dn
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
object_field = mapping.object_field
|
||||
if object_field.startswith("attributes."):
|
||||
# Because returning a list might desired, we can't
|
||||
# rely on self._flatten here. Instead, just save the result as-is
|
||||
properties["attributes"][
|
||||
object_field.replace("attributes.", "")
|
||||
] = value
|
||||
else:
|
||||
properties[object_field] = self._flatten(value)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
self._logger.warning(
|
||||
"Mapping failed to evaluate", exc=exc, mapping=mapping
|
||||
)
|
||||
continue
|
||||
if self._source.object_uniqueness_field in kwargs:
|
||||
properties["attributes"][LDAP_UNIQUENESS] = self._flatten(
|
||||
kwargs.get(self._source.object_uniqueness_field)
|
||||
)
|
||||
properties["attributes"][LDAP_DISTINGUISHED_NAME] = object_dn
|
||||
return properties
|
61
authentik/sources/ldap/sync/groups.py
Normal file
61
authentik/sources/ldap/sync/groups.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
|
||||
|
||||
|
||||
class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
def sync(self) -> int:
|
||||
"""Iterate over all LDAP Groups and create authentik_core.Group instances"""
|
||||
if not self._source.sync_groups:
|
||||
self._logger.warning("Group syncing is disabled for this Source")
|
||||
return -1
|
||||
groups = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
|
||||
)
|
||||
group_count = 0
|
||||
for group in groups:
|
||||
attributes = group.get("attributes", {})
|
||||
group_dn = self._flatten(
|
||||
self._flatten(group.get("entryDN", group.get("dn")))
|
||||
)
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
self._logger.warning(
|
||||
"Cannot find uniqueness Field in attributes",
|
||||
attributes=attributes.keys(),
|
||||
dn=group_dn,
|
||||
)
|
||||
continue
|
||||
uniq = self._flatten(attributes[self._source.object_uniqueness_field])
|
||||
try:
|
||||
defaults = self.build_group_properties(group_dn, **attributes)
|
||||
self._logger.debug("Creating group with attributes", **defaults)
|
||||
if "name" not in defaults:
|
||||
raise IntegrityError("Name was not set by propertymappings")
|
||||
ak_group, created = Group.objects.update_or_create(
|
||||
**{
|
||||
f"attributes__{LDAP_UNIQUENESS}": uniq,
|
||||
"parent": self._source.sync_parent_group,
|
||||
"defaults": defaults,
|
||||
}
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
self._logger.warning("Failed to create group", exc=exc)
|
||||
self._logger.warning(
|
||||
(
|
||||
"To merge new group with existing group, set the group's "
|
||||
f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._logger.debug("Synced group", group=ak_group.name, created=created)
|
||||
group_count += 1
|
||||
return group_count
|
86
authentik/sources/ldap/sync/membership.py
Normal file
86
authentik/sources/ldap/sync/membership.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
from typing import Any, Optional
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from django.db.models import Q
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
|
||||
|
||||
|
||||
class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
group_cache: dict[str, Group]
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
super().__init__(source)
|
||||
self.group_cache: dict[str, Group] = {}
|
||||
|
||||
def sync(self) -> int:
|
||||
"""Iterate over all Users and assign Groups using memberOf Field"""
|
||||
groups = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[
|
||||
self._source.group_membership_field,
|
||||
self._source.object_uniqueness_field,
|
||||
LDAP_DISTINGUISHED_NAME,
|
||||
],
|
||||
)
|
||||
membership_count = 0
|
||||
for group in groups:
|
||||
members = group.get("attributes", {}).get(
|
||||
self._source.group_membership_field, []
|
||||
)
|
||||
ak_group = self.get_group(group)
|
||||
if not ak_group:
|
||||
continue
|
||||
|
||||
users = User.objects.filter(
|
||||
Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members})
|
||||
| Q(
|
||||
**{
|
||||
f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True,
|
||||
"ak_groups__in": [ak_group],
|
||||
}
|
||||
)
|
||||
)
|
||||
membership_count += 1
|
||||
membership_count += users.count()
|
||||
ak_group.users.set(users)
|
||||
ak_group.save()
|
||||
self._logger.debug("Successfully updated group membership")
|
||||
return membership_count
|
||||
|
||||
def get_group(self, group_dict: dict[str, Any]) -> Optional[Group]:
|
||||
"""Check if we fetched the group already, and if not cache it for later"""
|
||||
group_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, [])
|
||||
group_uniq = group_dict.get("attributes", {}).get(
|
||||
self._source.object_uniqueness_field, []
|
||||
)
|
||||
# group_uniq might be a single string or an array with (hopefully) a single string
|
||||
if isinstance(group_uniq, list):
|
||||
if len(group_uniq) < 1:
|
||||
self._logger.warning(
|
||||
"Group does not have a uniqueness attribute.",
|
||||
group=group_dn,
|
||||
)
|
||||
return None
|
||||
group_uniq = group_uniq[0]
|
||||
if group_uniq not in self.group_cache:
|
||||
groups = Group.objects.filter(
|
||||
**{f"attributes__{LDAP_UNIQUENESS}": group_uniq}
|
||||
)
|
||||
if not groups.exists():
|
||||
self._logger.warning(
|
||||
"Group does not exist in our DB yet, run sync_groups first.",
|
||||
group=group_dn,
|
||||
)
|
||||
return None
|
||||
self.group_cache[group_uniq] = groups.first()
|
||||
return self.group_cache[group_uniq]
|
63
authentik/sources/ldap/sync/users.py
Normal file
63
authentik/sources/ldap/sync/users.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Sync LDAP Users into authentik"""
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
|
||||
|
||||
|
||||
class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
"""Sync LDAP Users into authentik"""
|
||||
|
||||
def sync(self) -> int:
|
||||
"""Iterate over all LDAP Users and create authentik_core.User instances"""
|
||||
if not self._source.sync_users:
|
||||
self._logger.warning("User syncing is disabled for this Source")
|
||||
return -1
|
||||
users = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
|
||||
)
|
||||
user_count = 0
|
||||
for user in users:
|
||||
attributes = user.get("attributes", {})
|
||||
user_dn = self._flatten(user.get("entryDN", user.get("dn")))
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
self._logger.warning(
|
||||
"Cannot find uniqueness Field in attributes",
|
||||
attributes=attributes.keys(),
|
||||
dn=user_dn,
|
||||
)
|
||||
continue
|
||||
uniq = self._flatten(attributes[self._source.object_uniqueness_field])
|
||||
try:
|
||||
defaults = self.build_user_properties(user_dn, **attributes)
|
||||
self._logger.debug("Creating user with attributes", **defaults)
|
||||
if "username" not in defaults:
|
||||
raise IntegrityError("Username was not set by propertymappings")
|
||||
ak_user, created = User.objects.update_or_create(
|
||||
**{
|
||||
f"attributes__{LDAP_UNIQUENESS}": uniq,
|
||||
"defaults": defaults,
|
||||
}
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
self._logger.warning("Failed to create user", exc=exc)
|
||||
self._logger.warning(
|
||||
(
|
||||
"To merge new user with existing user, set the user's "
|
||||
f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
if created:
|
||||
ak_user.set_unusable_password()
|
||||
ak_user.save()
|
||||
self._logger.debug(
|
||||
"Synced User", user=ak_user.username, created=created
|
||||
)
|
||||
user_count += 1
|
||||
return user_count
|
@ -8,7 +8,9 @@ from ldap3.core.exceptions import LDAPException
|
||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.sync import LDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
@ -29,16 +31,21 @@ def ldap_sync(self: MonitoredTask, source_pk: int):
|
||||
return
|
||||
self.set_uid(slugify(source.name))
|
||||
try:
|
||||
syncer = LDAPSynchronizer(source)
|
||||
user_count = syncer.sync_users()
|
||||
group_count = syncer.sync_groups()
|
||||
syncer.sync_membership()
|
||||
messages = []
|
||||
for sync_class in [
|
||||
UserLDAPSynchronizer,
|
||||
GroupLDAPSynchronizer,
|
||||
MembershipLDAPSynchronizer,
|
||||
]:
|
||||
sync_inst = sync_class(source)
|
||||
count = sync_inst.sync()
|
||||
messages.append(f"Synced {count} objects from {sync_class.__name__}")
|
||||
cache_key = source.state_cache_prefix("last_sync")
|
||||
cache.set(cache_key, time(), timeout=60 * 60)
|
||||
self.set_status(
|
||||
TaskResult(
|
||||
TaskResultStatus.SUCCESSFUL,
|
||||
[f"Synced {user_count} users", f"Synced {group_count} groups"],
|
||||
messages,
|
||||
)
|
||||
)
|
||||
except LDAPException as exc:
|
||||
|
@ -3,90 +3,94 @@
|
||||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||
|
||||
|
||||
def _build_mock_connection(password: str) -> Connection:
|
||||
"""Create mock connection"""
|
||||
def mock_ad_connection(password: str) -> Connection:
|
||||
"""Create mock AD connection"""
|
||||
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
|
||||
_pass = "foo" # noqa # nosec
|
||||
connection = Connection(
|
||||
server,
|
||||
user="cn=my_user,DC=AD2012,DC=LAB",
|
||||
user="cn=my_user,dc=goauthentik,dc=io",
|
||||
password=_pass,
|
||||
client_strategy=MOCK_SYNC,
|
||||
)
|
||||
# Entry for password checking
|
||||
connection.strategy.add_entry(
|
||||
"cn=user,ou=users,DC=AD2012,DC=LAB",
|
||||
"cn=user,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"name": "test-user",
|
||||
"objectSid": "unique-test-group",
|
||||
"objectCategory": "Person",
|
||||
"objectClass": "person",
|
||||
"displayName": "Erin M. Hagens",
|
||||
"sAMAccountName": "sAMAccountName",
|
||||
"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB",
|
||||
"distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||
"cn=group1,ou=groups,dc=goauthentik,dc=io",
|
||||
{
|
||||
"name": "test-group",
|
||||
"objectSid": "unique-test-group",
|
||||
"objectCategory": "Group",
|
||||
"distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||
"objectClass": "group",
|
||||
"distinguishedName": "cn=group1,ou=groups,dc=goauthentik,dc=io",
|
||||
"member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
|
||||
},
|
||||
)
|
||||
# Group without SID
|
||||
connection.strategy.add_entry(
|
||||
"cn=group2,ou=groups,DC=AD2012,DC=LAB",
|
||||
"cn=group2,ou=groups,dc=goauthentik,dc=io",
|
||||
{
|
||||
"name": "test-group",
|
||||
"objectCategory": "Group",
|
||||
"distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB",
|
||||
"objectClass": "group",
|
||||
"distinguishedName": "cn=group2,ou=groups,dc=goauthentik,dc=io",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=user0,ou=users,DC=AD2012,DC=LAB",
|
||||
"cn=user0,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": password,
|
||||
"sAMAccountName": "user0_sn",
|
||||
"name": "user0_sn",
|
||||
"revision": 0,
|
||||
"objectSid": "user0",
|
||||
"objectCategory": "Person",
|
||||
"memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||
"objectClass": "person",
|
||||
"distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io",
|
||||
},
|
||||
)
|
||||
# User without SID
|
||||
connection.strategy.add_entry(
|
||||
"cn=user1,ou=users,DC=AD2012,DC=LAB",
|
||||
"cn=user1,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": "test1111",
|
||||
"sAMAccountName": "user2_sn",
|
||||
"name": "user1_sn",
|
||||
"revision": 0,
|
||||
"objectCategory": "Person",
|
||||
"objectClass": "person",
|
||||
"distinguishedName": "cn=user1,ou=users,dc=goauthentik,dc=io",
|
||||
},
|
||||
)
|
||||
# Duplicate users
|
||||
connection.strategy.add_entry(
|
||||
"cn=user2,ou=users,DC=AD2012,DC=LAB",
|
||||
"cn=user2,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": "test2222",
|
||||
"sAMAccountName": "user2_sn",
|
||||
"name": "user2_sn",
|
||||
"revision": 0,
|
||||
"objectSid": "unique-test2222",
|
||||
"objectCategory": "Person",
|
||||
"objectClass": "person",
|
||||
"distinguishedName": "cn=user2,ou=users,dc=goauthentik,dc=io",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=user3,ou=users,DC=AD2012,DC=LAB",
|
||||
"cn=user3,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": "test2222",
|
||||
"sAMAccountName": "user2_sn",
|
||||
"name": "user2_sn",
|
||||
"revision": 0,
|
||||
"objectSid": "unique-test2222",
|
||||
"objectCategory": "Person",
|
||||
"objectClass": "person",
|
||||
"distinguishedName": "cn=user3,ou=users,dc=goauthentik,dc=io",
|
||||
},
|
||||
)
|
||||
connection.bind()
|
81
authentik/sources/ldap/tests/mock_slapd.py
Normal file
81
authentik/sources/ldap/tests/mock_slapd.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""ldap testing utils"""
|
||||
|
||||
from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server
|
||||
|
||||
|
||||
def mock_slapd_connection(password: str) -> Connection:
|
||||
"""Create mock AD connection"""
|
||||
server = Server("my_fake_server", get_info=OFFLINE_SLAPD_2_4)
|
||||
_pass = "foo" # noqa # nosec
|
||||
connection = Connection(
|
||||
server,
|
||||
user="cn=my_user,dc=goauthentik,dc=io",
|
||||
password=_pass,
|
||||
client_strategy=MOCK_SYNC,
|
||||
)
|
||||
# Entry for password checking
|
||||
connection.strategy.add_entry(
|
||||
"cn=user,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"name": "test-user",
|
||||
"uid": "unique-test-group",
|
||||
"objectClass": "person",
|
||||
"displayName": "Erin M. Hagens",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=group1,ou=groups,dc=goauthentik,dc=io",
|
||||
{
|
||||
"cn": "group1",
|
||||
"uid": "unique-test-group",
|
||||
"objectClass": "groupOfNames",
|
||||
"member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
|
||||
},
|
||||
)
|
||||
# Group without SID
|
||||
connection.strategy.add_entry(
|
||||
"cn=group2,ou=groups,dc=goauthentik,dc=io",
|
||||
{
|
||||
"cn": "group2",
|
||||
"objectClass": "groupOfNames",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=user0,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": password,
|
||||
"name": "user0_sn",
|
||||
"uid": "user0_sn",
|
||||
"objectClass": "person",
|
||||
},
|
||||
)
|
||||
# User without SID
|
||||
connection.strategy.add_entry(
|
||||
"cn=user1,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": "test1111",
|
||||
"name": "user1_sn",
|
||||
"objectClass": "person",
|
||||
},
|
||||
)
|
||||
# Duplicate users
|
||||
connection.strategy.add_entry(
|
||||
"cn=user2,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": "test2222",
|
||||
"name": "user2_sn",
|
||||
"uid": "unique-test2222",
|
||||
"objectClass": "person",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=user3,ou=users,dc=goauthentik,dc=io",
|
||||
{
|
||||
"userPassword": "test2222",
|
||||
"name": "user2_sn",
|
||||
"uid": "unique-test2222",
|
||||
"objectClass": "person",
|
||||
},
|
||||
)
|
||||
connection.bind()
|
||||
return connection
|
@ -1,47 +1,87 @@
|
||||
"""LDAP Source tests"""
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
from django.db.models import Q
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.ldap.auth import LDAPBackend
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from authentik.sources.ldap.sync import LDAPSynchronizer
|
||||
from authentik.sources.ldap.tests.utils import _build_mock_connection
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
|
||||
from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPSyncTests(TestCase):
|
||||
"""LDAP Sync tests"""
|
||||
|
||||
def setUp(self):
|
||||
ObjectManager().run()
|
||||
self.source = LDAPSource.objects.create(
|
||||
name="ldap",
|
||||
slug="ldap",
|
||||
base_dn="DC=AD2012,DC=LAB",
|
||||
base_dn="dc=goauthentik,dc=io",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||
self.source.save()
|
||||
|
||||
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_auth_synced_user(self):
|
||||
def test_auth_synced_user_ad(self):
|
||||
"""Test Cached auth"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_users()
|
||||
|
||||
user = User.objects.get(username="user0_sn")
|
||||
auth_user_by_bind = Mock(return_value=user)
|
||||
with patch(
|
||||
"authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
|
||||
auth_user_by_bind,
|
||||
):
|
||||
backend = LDAPBackend()
|
||||
self.assertEqual(
|
||||
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
|
||||
user,
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(name__startswith="authentik default LDAP Mapping")
|
||||
| Q(name__startswith="authentik default Active Directory Mapping")
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
|
||||
user = User.objects.get(username="user0_sn")
|
||||
auth_user_by_bind = Mock(return_value=user)
|
||||
with patch(
|
||||
"authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
|
||||
auth_user_by_bind,
|
||||
):
|
||||
backend = LDAPBackend()
|
||||
self.assertEqual(
|
||||
backend.authenticate(
|
||||
None, username="user0_sn", password=LDAP_PASSWORD
|
||||
),
|
||||
user,
|
||||
)
|
||||
|
||||
def test_auth_synced_user_openldap(self):
|
||||
"""Test Cached auth"""
|
||||
self.source.object_uniqueness_field = "uid"
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(name__startswith="authentik default LDAP Mapping")
|
||||
| Q(name__startswith="authentik default OpenLDAP Mapping")
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
|
||||
user = User.objects.get(username="user0_sn")
|
||||
auth_user_by_bind = Mock(return_value=user)
|
||||
with patch(
|
||||
"authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
|
||||
auth_user_by_bind,
|
||||
):
|
||||
backend = LDAPBackend()
|
||||
self.assertEqual(
|
||||
backend.authenticate(
|
||||
None, username="user0_sn", password=LDAP_PASSWORD
|
||||
),
|
||||
user,
|
||||
)
|
||||
|
@ -7,10 +7,10 @@ from authentik.core.models import User
|
||||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from authentik.sources.ldap.password import LDAPPasswordChanger
|
||||
from authentik.sources.ldap.tests.utils import _build_mock_connection
|
||||
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPPasswordTests(TestCase):
|
||||
@ -20,7 +20,7 @@ class LDAPPasswordTests(TestCase):
|
||||
self.source = LDAPSource.objects.create(
|
||||
name="ldap",
|
||||
slug="ldap",
|
||||
base_dn="DC=AD2012,DC=LAB",
|
||||
base_dn="dc=goauthentik,dc=io",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
@ -41,7 +41,7 @@ class LDAPPasswordTests(TestCase):
|
||||
pwc = LDAPPasswordChanger(self.source)
|
||||
user = User.objects.create(
|
||||
username="test",
|
||||
attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"},
|
||||
attributes={"distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io"},
|
||||
)
|
||||
self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category
|
||||
self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories
|
||||
|
@ -1,51 +1,141 @@
|
||||
"""LDAP Source tests"""
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.db.models import Q
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from authentik.sources.ldap.sync import LDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
from authentik.sources.ldap.tasks import ldap_sync_all
|
||||
from authentik.sources.ldap.tests.utils import _build_mock_connection
|
||||
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
|
||||
from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPSyncTests(TestCase):
|
||||
"""LDAP Sync tests"""
|
||||
|
||||
def setUp(self):
|
||||
ObjectManager().run()
|
||||
self.source = LDAPSource.objects.create(
|
||||
name="ldap",
|
||||
slug="ldap",
|
||||
base_dn="DC=AD2012,DC=LAB",
|
||||
base_dn="dc=goauthentik,dc=io",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||
self.source.save()
|
||||
|
||||
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_sync_users(self):
|
||||
def test_sync_users_ad(self):
|
||||
"""Test user sync"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_users()
|
||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
|
||||
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_sync_groups(self):
|
||||
def test_sync_users_openldap(self):
|
||||
"""Test user sync"""
|
||||
self.source.object_uniqueness_field = "uid"
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
|
||||
def test_sync_groups_ad(self):
|
||||
"""Test group sync"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_groups()
|
||||
syncer.sync_membership()
|
||||
group = Group.objects.filter(name="test-group")
|
||||
self.assertTrue(group.exists())
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
|
||||
)
|
||||
)
|
||||
self.source.property_mappings_group.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
managed="goauthentik.io/sources/ldap/default-name"
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
group_sync = GroupLDAPSynchronizer(self.source)
|
||||
group_sync.sync()
|
||||
membership_sync = MembershipLDAPSynchronizer(self.source)
|
||||
membership_sync.sync()
|
||||
group = Group.objects.filter(name="test-group")
|
||||
self.assertTrue(group.exists())
|
||||
|
||||
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_tasks(self):
|
||||
def test_sync_groups_openldap(self):
|
||||
"""Test group sync"""
|
||||
self.source.object_uniqueness_field = "uid"
|
||||
self.source.group_object_filter = "(objectClass=groupOfNames)"
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
|
||||
)
|
||||
)
|
||||
self.source.property_mappings_group.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
managed="goauthentik.io/sources/ldap/openldap-cn"
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
group_sync = GroupLDAPSynchronizer(self.source)
|
||||
group_sync.sync()
|
||||
membership_sync = MembershipLDAPSynchronizer(self.source)
|
||||
membership_sync.sync()
|
||||
group = Group.objects.filter(name="group1")
|
||||
self.assertTrue(group.exists())
|
||||
|
||||
def test_tasks_ad(self):
|
||||
"""Test Scheduled tasks"""
|
||||
ldap_sync_all.delay().get()
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
ldap_sync_all.delay().get()
|
||||
|
||||
def test_tasks_openldap(self):
|
||||
"""Test Scheduled tasks"""
|
||||
self.source.object_uniqueness_field = "uid"
|
||||
self.source.group_object_filter = "(objectClass=groupOfNames)"
|
||||
self.source.property_mappings.set(
|
||||
LDAPPropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
|
||||
)
|
||||
)
|
||||
self.source.save()
|
||||
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
ldap_sync_all.delay().get()
|
||||
|
@ -17,4 +17,5 @@ class ConsentStageForm(forms.ModelForm):
|
||||
fields = ["name", "mode", "consent_expire_in"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"consent_expire_in": forms.TextInput(),
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
{% extends "email/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<tr>
|
||||
<td class="alert alert-brand">
|
||||
@ -14,6 +16,29 @@
|
||||
{{ body }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if key_value %}
|
||||
<tr>
|
||||
<td class="content-block aligncenter">
|
||||
<table class="invoice">
|
||||
<tr>
|
||||
<td>{% trans "Additional Information" %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table class="invoice-items" cellpadding="0" cellspacing="0">
|
||||
{% for key, value in key_value.items %}
|
||||
<tr>
|
||||
<td>{{ key }}</td>
|
||||
<td class="alignright">{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -5,7 +5,6 @@ from django.contrib.auth import _clean_credentials
|
||||
from django.contrib.auth.backends import BaseBackend
|
||||
from django.contrib.auth.signals import user_login_failed
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.forms.utils import ErrorList
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView
|
||||
@ -116,8 +115,7 @@ class PasswordStageView(FormView, StageView):
|
||||
# No user was found -> invalid credentials
|
||||
LOGGER.debug("Invalid credentials")
|
||||
# Manually inject error into form
|
||||
errors = form._errors.setdefault("password", ErrorList())
|
||||
errors.append(_("Invalid password"))
|
||||
form.add_error("password", _("Invalid password"))
|
||||
return self.form_invalid(form)
|
||||
# User instance returned from authenticate() has .backend property set
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user