Compare commits
124 Commits
version/20
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
| b3bd979ecd | |||
| db113c5e8f | |||
| 78bcb90a1e | |||
| b64ecbde22 | |||
| 43bab840ec | |||
| f020b79384 | |||
| 820f658b49 | |||
| 5d460a2537 | |||
| efc46f52e6 | |||
| 9fac51f8c7 | |||
| fe4b2d1a34 | |||
| f8abe3e210 | |||
| 3ced67b151 | |||
| cd5631ec76 | |||
| 95df7c7f30 | |||
| 1e934aa5d5 | |||
| d93927755a | |||
| ddb3b71dce | |||
| bf9826873e | |||
| 6869b3c16a | |||
| 9b71b8da5f | |||
| bfc8e9200f | |||
| c4311abc9f | |||
| ec42869e00 | |||
| 45963c2ffc | |||
| 1aa27b5e80 | |||
| 1737feec91 | |||
| a0e0fb930a | |||
| 4a32c3ca11 | |||
| d307539fd0 | |||
| c060a3eec2 | |||
| 4612ae1ff4 | |||
| 7af883d80c | |||
| 4a5374d03f | |||
| 3b536f6e55 | |||
| 6aa13a8666 | |||
| 24e4924dec | |||
| a252f303c0 | |||
| 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-stable
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@ -16,6 +16,14 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: npm
|
||||
directory: "/website"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
||||
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-stable
|
||||
-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-stable
|
||||
- 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-stable \
|
||||
-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-stable
|
||||
- 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-stable \
|
||||
-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-stable
|
||||
- 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-stable
|
||||
environment: beryjuorg-prod
|
||||
|
||||
95
Pipfile.lock
generated
95
Pipfile.lock
generated
@ -53,10 +53,10 @@
|
||||
},
|
||||
"autobahn": {
|
||||
"hashes": [
|
||||
"sha256:410a93e0e29882c8b5d5ab05d220b07609b886ef5f23c0b8d39153254ffd6895",
|
||||
"sha256:52ee4236ff9a1fcbbd9500439dcf3284284b37f8a6b31ecc8a36e00cf9f95049"
|
||||
"sha256:93df8fc9d1821c9dabff9fed52181a9ad6eea5e9989d53102c391607d7c1666e",
|
||||
"sha256:cceed2121b7a93024daa93c91fae33007f8346f0e522796421f36a6183abea99"
|
||||
],
|
||||
"version": "==20.12.3"
|
||||
"version": "==21.1.1"
|
||||
},
|
||||
"automat": {
|
||||
"hashes": [
|
||||
@ -74,18 +74,18 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:a280123db79e73478bd23933486f3a0ffa2397d1a6381f32573f2731ff48c59a",
|
||||
"sha256:bb91fecf982e1bbfb68bb6bd2c9a0cce3c84ac6f97dd338d1ef9e47780679091"
|
||||
"sha256:92041aa7589c886020cabd80eb58b89ace2f0094571792fccae24b9a8b3b97d7",
|
||||
"sha256:9f132c34e20110dea019293c89cede49b0a56be615b3e1debf98390ed9f1f7b9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.16.62"
|
||||
"version": "==1.17.3"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:1046c152e5865aabbe6b10b2d33e652b3dd072516f3976e96cacc6b7c4460d02",
|
||||
"sha256:29b4b9be5b40f392a033926c08c004c01bd6471384ef6f12eaa49ee3870a010c"
|
||||
"sha256:1dae84c68b109f596f58cc2e9fa87704ccd40dcbc12144a89205f85efa7f9135",
|
||||
"sha256:a0fdded1c9636899ab273f50bf123f79b91439a8c282b5face8b5f4a48b493cb"
|
||||
],
|
||||
"version": "==1.19.62"
|
||||
"version": "==1.20.3"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
@ -127,6 +127,7 @@
|
||||
"sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009",
|
||||
"sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03",
|
||||
"sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b",
|
||||
"sha256:7ef7d4ced6b325e92eb4d3502946c78c5367bc416398d387b39591532536734e",
|
||||
"sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909",
|
||||
"sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53",
|
||||
"sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35",
|
||||
@ -265,11 +266,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 +398,10 @@
|
||||
},
|
||||
"google-auth": {
|
||||
"hashes": [
|
||||
"sha256:0b0e026b412a0ad096e753907559e4bdb180d9ba9f68dd9036164db4fdc4ad2e",
|
||||
"sha256:ce752cc51c31f479dbf9928435ef4b07514b20261b021c7383bee4bda646acb8"
|
||||
"sha256:008e23ed080674f69f9d2d7d80db4c2591b9bb307d136cea7b3bc129771d211d",
|
||||
"sha256:514e39f4190ca972200ba33876da5a8857c5665f2b4ccc36c8b8ee21228aae80"
|
||||
],
|
||||
"version": "==1.24.0"
|
||||
"version": "==1.25.0"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
@ -522,10 +523,10 @@
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
|
||||
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
|
||||
],
|
||||
"version": "==2.11.2"
|
||||
"version": "==2.11.3"
|
||||
},
|
||||
"jmespath": {
|
||||
"hashes": [
|
||||
@ -614,8 +615,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 +629,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 +707,11 @@
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
|
||||
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
|
||||
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
|
||||
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.8"
|
||||
"version": "==20.9"
|
||||
},
|
||||
"prometheus-client": {
|
||||
"hashes": [
|
||||
@ -900,10 +920,10 @@
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
||||
],
|
||||
"version": "==2020.5"
|
||||
"version": "==2021.1"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
@ -1084,7 +1104,6 @@
|
||||
"sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": null,
|
||||
"version": "==1.26.3"
|
||||
},
|
||||
"uvicorn": {
|
||||
@ -1275,10 +1294,11 @@
|
||||
},
|
||||
"autopep8": {
|
||||
"hashes": [
|
||||
"sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094"
|
||||
"sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea",
|
||||
"sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.5.4"
|
||||
"version": "==1.5.5"
|
||||
},
|
||||
"bandit": {
|
||||
"hashes": [
|
||||
@ -1382,11 +1402,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 +1507,11 @@
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858",
|
||||
"sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"
|
||||
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
|
||||
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.8"
|
||||
"version": "==20.9"
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
@ -1616,10 +1636,10 @@
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4",
|
||||
"sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"
|
||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
||||
],
|
||||
"version": "==2020.5"
|
||||
"version": "==2021.1"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
@ -1808,7 +1828,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-stable"
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
"""authentik core source form fields"""
|
||||
|
||||
SOURCE_FORM_FIELDS = [
|
||||
"name",
|
||||
"slug",
|
||||
"enabled",
|
||||
"authentication_flow",
|
||||
"enrollment_flow",
|
||||
]
|
||||
SOURCE_SERIALIZER_FIELDS = [
|
||||
"pk",
|
||||
"name",
|
||||
"slug",
|
||||
"enabled",
|
||||
"authentication_flow",
|
||||
"enrollment_flow",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
@ -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))
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
{% extends base_template|default:"generic/form.html" %}
|
||||
|
||||
{% load authentik_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block above_form %}
|
||||
<h1>
|
||||
{% trans 'Generate Certificate-Key Pair' %}
|
||||
</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block action %}
|
||||
{% trans 'Generate Certificate-Key Pair' %}
|
||||
{% endblock %}
|
||||
@ -26,6 +26,12 @@
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<ak-modal-button href="{% url 'authentik_admin:certificatekeypair-generate' %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-primary">
|
||||
{% trans 'Generate' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||
{% trans 'Refresh' %}
|
||||
</button>
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
{% extends "administration/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load authentik_utils %}
|
||||
{% load admin_reflection %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<i class="pf-icon pf-icon-zone"></i>
|
||||
{% trans 'Outposts' %}
|
||||
</h1>
|
||||
<p>{% trans "Outposts are deployments of authentik components to support different environments and protocols, like reverse proxies." %}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-c-card">
|
||||
{% if object_list %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
<div class="pf-c-toolbar__bulk-select">
|
||||
<ak-modal-button href="{% url 'authentik_admin:outpost-create' %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-primary">
|
||||
{% trans 'Create' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||
{% trans 'Refresh' %}
|
||||
</button>
|
||||
</div>
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Providers' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Health' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Version' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for outpost in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<span>{{ outpost.name }}</span>
|
||||
</th>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ outpost.providers.all.select_subclasses|join:", " }}
|
||||
</span>
|
||||
</td>
|
||||
{% with states=outpost.state %}
|
||||
{% if states|length > 0 %}
|
||||
<td role="cell">
|
||||
{% for state in states %}
|
||||
<div>
|
||||
{% if state.last_seen %}
|
||||
<i class="fas fa-check pf-m-success"></i> {{ state.last_seen|naturaltime }}
|
||||
{% else %}
|
||||
<i class="fas fa-times pf-m-danger"></i> {% trans 'Unhealthy' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td role="cell">
|
||||
{% for state in states %}
|
||||
<div>
|
||||
{% if not state.version %}
|
||||
<i class="fas fa-question-circle"></i>
|
||||
{% elif state.version_outdated %}
|
||||
<i class="fas fa-times pf-m-danger"></i> {% blocktrans with is=state.version should=state.version_should %}{{ is }}, should be {{ should }}{% endblocktrans %}
|
||||
{% else %}
|
||||
<i class="fas fa-check pf-m-success"></i> {{ state.version }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td role="cell">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<i class="fas fa-question-circle"></i>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<td>
|
||||
<ak-modal-button href="{% url 'authentik_admin:outpost-update' pk=outpost.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-secondary">
|
||||
{% trans 'Edit' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<ak-modal-button href="{% url 'authentik_admin:outpost-delete' pk=outpost.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-danger">
|
||||
{% trans 'Delete' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
{% get_htmls outpost as htmls %}
|
||||
{% for html in htmls %}
|
||||
{{ html|safe }}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pf-c-pagination pf-m-bottom">
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-empty-state">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="fas fa-map-marker pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans 'No Outposts.' %}
|
||||
</h1>
|
||||
<div class="pf-c-empty-state__body">
|
||||
{% if request.GET.search != "" %}
|
||||
{% trans "Your search query doesn't match any outposts." %}
|
||||
{% else %}
|
||||
{% trans 'Currently no outposts exist. Click the button below to create one.' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<ak-modal-button href="{% url 'authentik_admin:outpost-create' %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-primary">
|
||||
{% trans 'Create' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -3,7 +3,6 @@
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load authentik_utils %}
|
||||
{% load admin_reflection %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
|
||||
@ -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-label">
|
||||
<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-label">
|
||||
<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 %}
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
{% extends "administration/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load authentik_utils %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<i class="pf-icon pf-icon-blueprint"></i>
|
||||
{% trans 'Property Mappings' %}
|
||||
</h1>
|
||||
<p>{% trans "Control how authentik exposes and interprets information." %}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-c-card">
|
||||
{% if object_list %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
<div class="pf-c-toolbar__bulk-select">
|
||||
<ak-dropdown class="pf-c-dropdown">
|
||||
<button class="pf-m-primary pf-c-dropdown__toggle" type="button">
|
||||
<span class="pf-c-dropdown__toggle-text">{% trans 'Create' %}</span>
|
||||
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="pf-c-dropdown__menu" hidden>
|
||||
{% for type, name in types.items %}
|
||||
<li>
|
||||
<ak-modal-button href="{% url 'authentik_admin:property-mapping-create' %}?type={{ type }}">
|
||||
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||
{{ name|verbose_name }}<br>
|
||||
<small>
|
||||
{{ name|doc }}
|
||||
</small>
|
||||
</button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</ak-dropdown>
|
||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||
{% trans 'Refresh' %}
|
||||
</button>
|
||||
</div>
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Type' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for property_mapping in object_list %}
|
||||
<tr role="row">
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ property_mapping.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ property_mapping|verbose_name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ak-modal-button href="{% url 'authentik_admin:property-mapping-update' pk=property_mapping.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-secondary">
|
||||
{% trans 'Edit' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<ak-modal-button href="{% url 'authentik_admin:property-mapping-delete' pk=property_mapping.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-danger">
|
||||
{% trans 'Delete' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pf-c-pagination pf-m-bottom">
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-empty-state">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="pf-icon pf-icon-blueprint pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans 'No Property Mappings.' %}
|
||||
</h1>
|
||||
<div class="pf-c-empty-state__body">
|
||||
{% if request.GET.search != "" %}
|
||||
{% trans "Your search query doesn't match any property mappings." %}
|
||||
{% else %}
|
||||
{% trans 'Currently no property mappings exist. Click the button below to create one.' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<ak-dropdown class="pf-c-dropdown">
|
||||
<button class="pf-m-primary pf-c-dropdown__toggle" type="button">
|
||||
<span class="pf-c-dropdown__toggle-text">{% trans 'Create' %}</span>
|
||||
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="pf-c-dropdown__menu" hidden>
|
||||
{% for type, name in types.items %}
|
||||
<li>
|
||||
<ak-modal-button href="{% url 'authentik_admin:property-mapping-create' %}?type={{ type }}">
|
||||
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||
{{ name|verbose_name }}<br>
|
||||
<small>
|
||||
{{ name|doc }}
|
||||
</small>
|
||||
</button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</ak-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -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 %}
|
||||
@ -1,181 +0,0 @@
|
||||
{% extends "administration/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load authentik_utils %}
|
||||
{% load admin_reflection %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<i class="pf-icon pf-icon-integration"></i>
|
||||
{% trans 'Providers' %}
|
||||
</h1>
|
||||
<p>{% trans "Provide support for protocols like SAML and OAuth to assigned applications." %}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-c-card">
|
||||
{% if object_list %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
<div class="pf-c-toolbar__bulk-select">
|
||||
<ak-dropdown class="pf-c-dropdown">
|
||||
<button class="pf-m-primary pf-c-dropdown__toggle" type="button">
|
||||
<span class="pf-c-dropdown__toggle-text">{% trans 'Create' %}</span>
|
||||
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="pf-c-dropdown__menu" hidden>
|
||||
{% for type, name in types.items %}
|
||||
<li>
|
||||
<ak-modal-button href="{% url 'authentik_admin:provider-create' %}?type={{ type }}">
|
||||
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||
{{ name|verbose_name }}<br>
|
||||
<small>
|
||||
{{ name|doc }}
|
||||
</small>
|
||||
</button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<ak-modal-button href="{% url 'authentik_admin:provider-saml-from-metadata' %}">
|
||||
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||
{% trans 'SAML Provider from Metadata' %}<br>
|
||||
<small>
|
||||
{% trans "Create a SAML Provider by importing its Metadata." %}
|
||||
</small>
|
||||
</button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
</ul>
|
||||
</ak-dropdown>
|
||||
<button role="ak-refresh" class="pf-c-button pf-m-primary">
|
||||
{% trans 'Refresh' %}
|
||||
</button>
|
||||
</div>
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Type' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for provider in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<div>
|
||||
<div>{{ provider.name }}</div>
|
||||
{% if not provider.application %}
|
||||
<i class="pf-icon pf-icon-warning-triangle"></i>
|
||||
<small>{% trans 'Warning: Provider not assigned to any application.' %}</small>
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-ok"></i>
|
||||
<small>
|
||||
{% blocktrans with app=provider.application %}
|
||||
Assigned to application {{ app }}.
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</th>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ provider|verbose_name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ak-modal-button href="{% url 'authentik_admin:provider-update' pk=provider.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-secondary">
|
||||
{% trans 'Edit' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
<ak-modal-button href="{% url 'authentik_admin:provider-delete' pk=provider.pk %}">
|
||||
<ak-spinner-button slot="trigger" class="pf-m-danger">
|
||||
{% trans 'Delete' %}
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
{% get_links provider as links %}
|
||||
{% for name, href in links.items %}
|
||||
<a class="pf-c-button pf-m-tertiary ak-root-link" href="{{ href }}?back={{ request.get_full_path }}">{% trans name %}</a>
|
||||
{% endfor %}
|
||||
{% get_htmls provider as htmls %}
|
||||
{% for html in htmls %}
|
||||
{{ html|safe }}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pf-c-pagination pf-m-bottom">
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
{% include 'partials/toolbar_search.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-empty-state">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="pf-icon-integration pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans 'No Providers.' %}
|
||||
</h1>
|
||||
<div class="pf-c-empty-state__body">
|
||||
{% if request.GET.search != "" %}
|
||||
{% trans "Your search query doesn't match any providers." %}
|
||||
{% else %}
|
||||
{% trans 'Currently no providers exist. Click the button below to create one.' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<ak-dropdown class="pf-c-dropdown">
|
||||
<button class="pf-m-primary pf-c-dropdown__toggle" type="button">
|
||||
<span class="pf-c-dropdown__toggle-text">{% trans 'Create' %}</span>
|
||||
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="pf-c-dropdown__menu" hidden>
|
||||
{% for type, name in types.items %}
|
||||
<li>
|
||||
<ak-modal-button href="{% url 'authentik_admin:provider-create' %}?type={{ type }}">
|
||||
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||
{{ name|verbose_name }}<br>
|
||||
<small>
|
||||
{{ name|doc }}
|
||||
</small>
|
||||
</button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<ak-modal-button href="{% url 'authentik_admin:provider-saml-from-metadata' %}">
|
||||
<button slot="trigger" class="pf-c-dropdown__menu-item">
|
||||
{% trans 'SAML Provider from Metadata' %}<br>
|
||||
<small>
|
||||
{% trans "Create a SAML Provider by importing its Metadata." %}
|
||||
</small>
|
||||
</button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
</li>
|
||||
</ul>
|
||||
</ak-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
{% load i18n %}
|
||||
{% load authentik_utils %}
|
||||
{% load admin_reflection %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
@ -63,7 +62,7 @@
|
||||
{% for source in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<a href="/sources/{{ source.slug }}/">
|
||||
<a href="/sources/{{ source.slug }}">
|
||||
<div>{{ source.name }}</div>
|
||||
{% if not source.enabled %}
|
||||
<small>{% trans 'Disabled' %}</small>
|
||||
@ -93,10 +92,6 @@
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
{% get_links source as links %}
|
||||
{% for name, href in links %}
|
||||
<a class="pf-c-button pf-m-tertiary ak-root-link" href="{{ href }}?back={{ request.get_full_path }}">{% trans name %}</a>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
{% load i18n %}
|
||||
{% load authentik_utils %}
|
||||
{% load admin_reflection %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
@ -88,10 +87,6 @@
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
{% get_links stage as links %}
|
||||
{% for name, href in links.items %}
|
||||
<a class="pf-c-button pf-m-tertiary ak-root-link" href="{{ href }}?back={{ request.get_full_path }}">{% trans name %}</a>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
{% load i18n %}
|
||||
{% load authentik_utils %}
|
||||
{% load admin_reflection %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
@ -90,10 +89,6 @@
|
||||
</ak-spinner-button>
|
||||
<div slot="modal"></div>
|
||||
</ak-modal-button>
|
||||
{% get_links prompt as links %}
|
||||
{% for name, href in links.items %}
|
||||
<a class="pf-c-button pf-m-tertiary ak-root-link" href="{{ href }}?back={{ request.get_full_path }}">{% trans name %}</a>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
"""authentik admin templatetags"""
|
||||
from django import template
|
||||
from django.db.models import Model
|
||||
from django.utils.html import mark_safe
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
register = template.Library()
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def get_links(model_instance):
|
||||
"""Find all link_ methods on an object instance, run them and return as dict"""
|
||||
prefix = "link_"
|
||||
links = {}
|
||||
|
||||
if not isinstance(model_instance, Model):
|
||||
LOGGER.warning("Model is not instance of Model", model_instance=model_instance)
|
||||
return links
|
||||
|
||||
try:
|
||||
for name in dir(model_instance):
|
||||
if not name.startswith(prefix):
|
||||
continue
|
||||
value = getattr(model_instance, name)
|
||||
if not callable(value):
|
||||
continue
|
||||
human_name = name.replace(prefix, "").replace("_", " ").capitalize()
|
||||
link = value()
|
||||
if link:
|
||||
links[human_name] = link
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
return links
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def get_htmls(context, model_instance):
|
||||
"""Find all html_ methods on an object instance, run them and return as dict"""
|
||||
prefix = "html_"
|
||||
htmls = []
|
||||
|
||||
if not isinstance(model_instance, Model):
|
||||
LOGGER.warning("Model is not instance of Model", model_instance=model_instance)
|
||||
return htmls
|
||||
|
||||
try:
|
||||
for name in dir(model_instance):
|
||||
if not name.startswith(prefix):
|
||||
continue
|
||||
value = getattr(model_instance, name)
|
||||
if not callable(value):
|
||||
continue
|
||||
if name.startswith(prefix):
|
||||
html = value(context.get("request"))
|
||||
if html:
|
||||
htmls.append(mark_safe(html))
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
return htmls
|
||||
@ -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,
|
||||
|
||||
@ -24,7 +24,7 @@ from authentik.admin.views import (
|
||||
tokens,
|
||||
users,
|
||||
)
|
||||
from authentik.providers.saml.views import MetadataImportView
|
||||
from authentik.providers.saml.views.metadata import MetadataImportView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@ -113,7 +113,6 @@ urlpatterns = [
|
||||
name="policy-binding-delete",
|
||||
),
|
||||
# Providers
|
||||
path("providers/", providers.ProviderListView.as_view(), name="providers"),
|
||||
path(
|
||||
"providers/create/",
|
||||
providers.ProviderCreateView.as_view(),
|
||||
@ -170,22 +169,22 @@ urlpatterns = [
|
||||
),
|
||||
# Stage Prompts
|
||||
path(
|
||||
"stages/prompts/",
|
||||
"stages_prompts/",
|
||||
stages_prompts.PromptListView.as_view(),
|
||||
name="stage-prompts",
|
||||
),
|
||||
path(
|
||||
"stages/prompts/create/",
|
||||
"stages_prompts/create/",
|
||||
stages_prompts.PromptCreateView.as_view(),
|
||||
name="stage-prompt-create",
|
||||
),
|
||||
path(
|
||||
"stages/prompts/<uuid:pk>/update/",
|
||||
"stages_prompts/<uuid:pk>/update/",
|
||||
stages_prompts.PromptUpdateView.as_view(),
|
||||
name="stage-prompt-update",
|
||||
),
|
||||
path(
|
||||
"stages/prompts/<uuid:pk>/delete/",
|
||||
"stages_prompts/<uuid:pk>/delete/",
|
||||
stages_prompts.PromptDeleteView.as_view(),
|
||||
name="stage-prompt-delete",
|
||||
),
|
||||
@ -238,11 +237,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 +252,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"),
|
||||
@ -296,6 +295,11 @@ urlpatterns = [
|
||||
certificate_key_pair.CertificateKeyPairCreateView.as_view(),
|
||||
name="certificatekeypair-create",
|
||||
),
|
||||
path(
|
||||
"crypto/certificates/generate/",
|
||||
certificate_key_pair.CertificateKeyPairGenerateView.as_view(),
|
||||
name="certificatekeypair-generate",
|
||||
),
|
||||
path(
|
||||
"crypto/certificates/<uuid:pk>/update/",
|
||||
certificate_key_pair.CertificateKeyPairUpdateView.as_view(),
|
||||
@ -307,11 +311,6 @@ urlpatterns = [
|
||||
name="certificatekeypair-delete",
|
||||
),
|
||||
# Outposts
|
||||
path(
|
||||
"outposts/",
|
||||
outposts.OutpostListView.as_view(),
|
||||
name="outposts",
|
||||
),
|
||||
path(
|
||||
"outposts/create/",
|
||||
outposts.OutpostCreateView.as_view(),
|
||||
@ -329,22 +328,22 @@ urlpatterns = [
|
||||
),
|
||||
# Outpost Service Connections
|
||||
path(
|
||||
"outposts/service_connections/",
|
||||
"outpost_service_connections/",
|
||||
outposts_service_connections.OutpostServiceConnectionListView.as_view(),
|
||||
name="outpost-service-connections",
|
||||
),
|
||||
path(
|
||||
"outposts/service_connections/create/",
|
||||
"outpost_service_connections/create/",
|
||||
outposts_service_connections.OutpostServiceConnectionCreateView.as_view(),
|
||||
name="outpost-service-connection-create",
|
||||
),
|
||||
path(
|
||||
"outposts/service_connections/<uuid:pk>/update/",
|
||||
"outpost_service_connections/<uuid:pk>/update/",
|
||||
outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(),
|
||||
name="outpost-service-connection-update",
|
||||
),
|
||||
path(
|
||||
"outposts/service_connections/<uuid:pk>/delete/",
|
||||
"outpost_service_connections/<uuid:pk>/delete/",
|
||||
outposts_service_connections.OutpostServiceConnectionDeleteView.as_view(),
|
||||
name="outpost-service-connection-delete",
|
||||
),
|
||||
|
||||
@ -4,9 +4,11 @@ from django.contrib.auth.mixins import (
|
||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http.response import HttpResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from django.views.generic.edit import FormView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from authentik.admin.views.utils import (
|
||||
@ -15,7 +17,11 @@ from authentik.admin.views.utils import (
|
||||
SearchListMixin,
|
||||
UserPaginateListMixin,
|
||||
)
|
||||
from authentik.crypto.forms import CertificateKeyPairForm
|
||||
from authentik.crypto.builder import CertificateBuilder
|
||||
from authentik.crypto.forms import (
|
||||
CertificateKeyPairForm,
|
||||
CertificateKeyPairGenerateForm,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.views import CreateAssignPermView
|
||||
|
||||
@ -52,7 +58,35 @@ class CertificateKeyPairCreateView(
|
||||
|
||||
template_name = "generic/create.html"
|
||||
success_url = reverse_lazy("authentik_admin:certificate_key_pair")
|
||||
success_message = _("Successfully created CertificateKeyPair")
|
||||
success_message = _("Successfully created Certificate-Key Pair")
|
||||
|
||||
|
||||
class CertificateKeyPairGenerateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
LoginRequiredMixin,
|
||||
DjangoPermissionRequiredMixin,
|
||||
FormView,
|
||||
):
|
||||
"""Generate new CertificateKeyPair"""
|
||||
|
||||
model = CertificateKeyPair
|
||||
form_class = CertificateKeyPairGenerateForm
|
||||
permission_required = "authentik_crypto.add_certificatekeypair"
|
||||
|
||||
template_name = "administration/certificatekeypair/generate.html"
|
||||
success_url = reverse_lazy("authentik_admin:certificate_key_pair")
|
||||
success_message = _("Successfully generated Certificate-Key Pair")
|
||||
|
||||
def form_valid(self, form: CertificateKeyPairGenerateForm) -> HttpResponse:
|
||||
builder = CertificateBuilder()
|
||||
builder.common_name = form.data["common_name"]
|
||||
builder.build(
|
||||
subject_alt_names=form.data.get("subject_alt_name", "").split(","),
|
||||
validity_days=int(form.data["validity_days"]),
|
||||
)
|
||||
builder.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class CertificateKeyPairUpdateView(
|
||||
|
||||
@ -9,36 +9,15 @@ from django.contrib.auth.mixins import (
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
from django.views.generic import UpdateView
|
||||
from guardian.mixins import PermissionRequiredMixin
|
||||
|
||||
from authentik.admin.views.utils import (
|
||||
BackSuccessUrlMixin,
|
||||
DeleteMessageView,
|
||||
SearchListMixin,
|
||||
UserPaginateListMixin,
|
||||
)
|
||||
from authentik.admin.views.utils import BackSuccessUrlMixin, DeleteMessageView
|
||||
from authentik.lib.views import CreateAssignPermView
|
||||
from authentik.outposts.forms import OutpostForm
|
||||
from authentik.outposts.models import Outpost, OutpostConfig
|
||||
|
||||
|
||||
class OutpostListView(
|
||||
LoginRequiredMixin,
|
||||
PermissionListMixin,
|
||||
UserPaginateListMixin,
|
||||
SearchListMixin,
|
||||
ListView,
|
||||
):
|
||||
"""Show list of all outposts"""
|
||||
|
||||
model = Outpost
|
||||
permission_required = "authentik_outposts.view_outpost"
|
||||
ordering = "name"
|
||||
template_name = "administration/outpost/list.html"
|
||||
search_fields = ["name", "_config"]
|
||||
|
||||
|
||||
class OutpostCreateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
@ -53,7 +32,7 @@ class OutpostCreateView(
|
||||
permission_required = "authentik_outposts.add_outpost"
|
||||
|
||||
template_name = "generic/create.html"
|
||||
success_url = reverse_lazy("authentik_admin:outposts")
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully created Outpost")
|
||||
|
||||
def get_initial(self) -> Dict[str, Any]:
|
||||
@ -78,7 +57,7 @@ class OutpostUpdateView(
|
||||
permission_required = "authentik_outposts.change_outpost"
|
||||
|
||||
template_name = "generic/update.html"
|
||||
success_url = reverse_lazy("authentik_admin:outposts")
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully updated Outpost")
|
||||
|
||||
|
||||
@ -89,5 +68,5 @@ class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessa
|
||||
permission_required = "authentik_outposts.delete_outpost"
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("authentik_admin:outposts")
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully deleted Outpost")
|
||||
|
||||
@ -3,6 +3,7 @@ from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.cache import cache
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.urls.base import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView
|
||||
from structlog.stdlib import get_logger
|
||||
@ -20,7 +21,7 @@ class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView):
|
||||
form_class = PolicyCacheClearForm
|
||||
|
||||
template_name = "generic/form_non_model.html"
|
||||
success_url = "/"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully cleared Policy cache")
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
@ -28,7 +29,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)
|
||||
|
||||
@ -39,7 +40,7 @@ class FlowCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView):
|
||||
form_class = FlowCacheClearForm
|
||||
|
||||
template_name = "generic/form_non_model.html"
|
||||
success_url = "/"
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully cleared Flow cache")
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
|
||||
@ -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()
|
||||
)
|
||||
@ -117,13 +115,12 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo
|
||||
user = form.cleaned_data.get("user")
|
||||
|
||||
p_request = PolicyRequest(user)
|
||||
p_request.debug = True
|
||||
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,
|
||||
@ -49,7 +37,7 @@ class PropertyMappingCreateView(
|
||||
permission_required = "authentik_core.add_propertymapping"
|
||||
|
||||
template_name = "generic/create.html"
|
||||
success_url = reverse_lazy("authentik_admin:property-mappings")
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully created Property Mapping")
|
||||
|
||||
|
||||
@ -66,7 +54,7 @@ class PropertyMappingUpdateView(
|
||||
permission_required = "authentik_core.change_propertymapping"
|
||||
|
||||
template_name = "generic/update.html"
|
||||
success_url = reverse_lazy("authentik_admin:property-mappings")
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
success_message = _("Successfully updated Property Mapping")
|
||||
|
||||
|
||||
@ -79,5 +67,46 @@ class PropertyMappingDeleteView(
|
||||
permission_required = "authentik_core.delete_propertymapping"
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("authentik_admin:property-mappings")
|
||||
success_url = reverse_lazy("authentik_core:shell")
|
||||
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)
|
||||
|
||||
@ -6,36 +6,17 @@ from django.contrib.auth.mixins import (
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
from guardian.mixins import PermissionRequiredMixin
|
||||
|
||||
from authentik.admin.views.utils import (
|
||||
BackSuccessUrlMixin,
|
||||
DeleteMessageView,
|
||||
InheritanceCreateView,
|
||||
InheritanceListView,
|
||||
InheritanceUpdateView,
|
||||
SearchListMixin,
|
||||
UserPaginateListMixin,
|
||||
)
|
||||
from authentik.core.models import Provider
|
||||
|
||||
|
||||
class ProviderListView(
|
||||
LoginRequiredMixin,
|
||||
PermissionListMixin,
|
||||
UserPaginateListMixin,
|
||||
SearchListMixin,
|
||||
InheritanceListView,
|
||||
):
|
||||
"""Show list of all providers"""
|
||||
|
||||
model = Provider
|
||||
permission_required = "authentik_core.add_provider"
|
||||
template_name = "administration/provider/list.html"
|
||||
ordering = "pk"
|
||||
search_fields = ["pk", "name"]
|
||||
|
||||
|
||||
class ProviderCreateView(
|
||||
SuccessMessageMixin,
|
||||
BackSuccessUrlMixin,
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -29,11 +29,12 @@ from authentik.flows.api import (
|
||||
FlowViewSet,
|
||||
StageViewSet,
|
||||
)
|
||||
from authentik.outposts.api import (
|
||||
from authentik.outposts.api.outpost_service_connections import (
|
||||
DockerServiceConnectionViewSet,
|
||||
KubernetesServiceConnectionViewSet,
|
||||
OutpostViewSet,
|
||||
ServiceConnectionViewSet,
|
||||
)
|
||||
from authentik.outposts.api.outposts import OutpostViewSet
|
||||
from authentik.policies.api import (
|
||||
PolicyBindingViewSet,
|
||||
PolicyCacheViewSet,
|
||||
@ -88,6 +89,7 @@ router.register("core/users", UserViewSet)
|
||||
router.register("core/tokens", TokenViewSet)
|
||||
|
||||
router.register("outposts/outposts", OutpostViewSet)
|
||||
router.register("outposts/service_connections/all", ServiceConnectionViewSet)
|
||||
router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)
|
||||
router.register(
|
||||
"outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.core.models import Source
|
||||
|
||||
@ -10,22 +9,26 @@ from authentik.core.models import Source
|
||||
class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Source Serializer"""
|
||||
|
||||
__type__ = SerializerMethodField(method_name="get_type")
|
||||
object_type = SerializerMethodField()
|
||||
|
||||
def get_type(self, obj):
|
||||
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("source", "")
|
||||
|
||||
def to_representation(self, instance: Source):
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
if instance.__class__ == Source:
|
||||
return super().to_representation(instance)
|
||||
return instance.serializer(instance=instance).data
|
||||
return obj._meta.object_name.lower().replace("provider", "")
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Source
|
||||
fields = SOURCE_SERIALIZER_FIELDS + ["__type__"]
|
||||
fields = SOURCE_SERIALIZER_FIELDS = [
|
||||
"pk",
|
||||
"name",
|
||||
"slug",
|
||||
"enabled",
|
||||
"authentication_flow",
|
||||
"enrollment_flow",
|
||||
"object_type",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
]
|
||||
|
||||
|
||||
class SourceViewSet(ReadOnlyModelViewSet):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Create self-signed certificates"""
|
||||
import datetime
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
@ -8,6 +9,9 @@ from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
|
||||
|
||||
class CertificateBuilder:
|
||||
"""Build self-signed certificates"""
|
||||
@ -17,19 +21,39 @@ class CertificateBuilder:
|
||||
__builder = None
|
||||
__certificate = None
|
||||
|
||||
common_name: str
|
||||
|
||||
def __init__(self):
|
||||
self.__public_key = None
|
||||
self.__private_key = None
|
||||
self.__builder = None
|
||||
self.__certificate = None
|
||||
self.common_name = "authentik Self-signed Certificate"
|
||||
|
||||
def build(self):
|
||||
def save(self) -> Optional[CertificateKeyPair]:
|
||||
"""Save generated certificate as model"""
|
||||
if not self.__certificate:
|
||||
return None
|
||||
return CertificateKeyPair.objects.create(
|
||||
name=self.common_name,
|
||||
certificate_data=self.certificate,
|
||||
key_data=self.private_key,
|
||||
)
|
||||
|
||||
def build(
|
||||
self,
|
||||
validity_days: int = 365,
|
||||
subject_alt_names: Optional[list[str]] = None,
|
||||
):
|
||||
"""Build self-signed certificate"""
|
||||
one_day = datetime.timedelta(1, 0, 0)
|
||||
self.__private_key = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048, backend=default_backend()
|
||||
)
|
||||
self.__public_key = self.__private_key.public_key()
|
||||
alt_names: list[x509.GeneralName] = [
|
||||
x509.DNSName(x) for x in subject_alt_names or []
|
||||
]
|
||||
self.__builder = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(
|
||||
@ -37,7 +61,7 @@ class CertificateBuilder:
|
||||
[
|
||||
x509.NameAttribute(
|
||||
NameOID.COMMON_NAME,
|
||||
"authentik Self-signed Certificate",
|
||||
self.common_name,
|
||||
),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"),
|
||||
x509.NameAttribute(
|
||||
@ -51,13 +75,16 @@ class CertificateBuilder:
|
||||
[
|
||||
x509.NameAttribute(
|
||||
NameOID.COMMON_NAME,
|
||||
"authentik Self-signed Certificate",
|
||||
f"authentik {__version__}",
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
.add_extension(x509.SubjectAlternativeName(alt_names), critical=True)
|
||||
.not_valid_before(datetime.datetime.today() - one_day)
|
||||
.not_valid_after(datetime.datetime.today() + datetime.timedelta(days=365))
|
||||
.not_valid_after(
|
||||
datetime.datetime.today() + datetime.timedelta(days=validity_days)
|
||||
)
|
||||
.serial_number(int(uuid.uuid4()))
|
||||
.public_key(self.__public_key)
|
||||
)
|
||||
|
||||
@ -8,6 +8,14 @@ from django.utils.translation import gettext_lazy as _
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
|
||||
|
||||
class CertificateKeyPairGenerateForm(forms.Form):
|
||||
"""CertificateKeyPair generation form"""
|
||||
|
||||
common_name = forms.CharField()
|
||||
subject_alt_name = forms.CharField(required=False, label=_("Subject-alt name"))
|
||||
validity_days = forms.IntegerField(initial=365)
|
||||
|
||||
|
||||
class CertificateKeyPairForm(forms.ModelForm):
|
||||
"""CertificateKeyPair Form"""
|
||||
|
||||
|
||||
@ -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 notification")
|
||||
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
|
||||
|
||||
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)]))
|
||||
0
authentik/outposts/api/__init__.py
Normal file
0
authentik/outposts/api/__init__.py
Normal file
@ -1,30 +1,28 @@
|
||||
"""Outpost API Views"""
|
||||
from rest_framework.serializers import JSONField, ModelSerializer
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.outposts.models import (
|
||||
DockerServiceConnection,
|
||||
KubernetesServiceConnection,
|
||||
Outpost,
|
||||
OutpostServiceConnection,
|
||||
)
|
||||
|
||||
|
||||
class OutpostSerializer(ModelSerializer):
|
||||
"""Outpost Serializer"""
|
||||
|
||||
_config = JSONField()
|
||||
class ServiceConnectionSerializer(ModelSerializer):
|
||||
"""ServiceConnection Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Outpost
|
||||
fields = ["pk", "name", "providers", "service_connection", "_config"]
|
||||
model = OutpostServiceConnection
|
||||
fields = ["pk", "name"]
|
||||
|
||||
|
||||
class OutpostViewSet(ModelViewSet):
|
||||
"""Outpost Viewset"""
|
||||
class ServiceConnectionViewSet(ModelViewSet):
|
||||
"""ServiceConnection Viewset"""
|
||||
|
||||
queryset = Outpost.objects.all()
|
||||
serializer_class = OutpostSerializer
|
||||
queryset = OutpostServiceConnection.objects.all()
|
||||
serializer_class = ServiceConnectionSerializer
|
||||
|
||||
|
||||
class DockerServiceConnectionSerializer(ModelSerializer):
|
||||
79
authentik/outposts/api/outposts.py
Normal file
79
authentik/outposts/api/outposts.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Outpost API Views"""
|
||||
from django.db.models import Model
|
||||
from drf_yasg2.utils import swagger_auto_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import BooleanField, CharField, DateTimeField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import JSONField, ModelSerializer, Serializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
|
||||
class OutpostSerializer(ModelSerializer):
|
||||
"""Outpost Serializer"""
|
||||
|
||||
_config = JSONField()
|
||||
providers_obj = ProviderSerializer(source="providers", many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Outpost
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"providers",
|
||||
"providers_obj",
|
||||
"service_connection",
|
||||
"token_identifier",
|
||||
"_config",
|
||||
]
|
||||
|
||||
|
||||
class OutpostHealthSerializer(Serializer):
|
||||
"""Outpost health status"""
|
||||
|
||||
last_seen = DateTimeField(read_only=True)
|
||||
version = CharField(read_only=True)
|
||||
version_should = CharField(read_only=True)
|
||||
version_outdated = BooleanField(read_only=True)
|
||||
|
||||
def create(self, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class OutpostViewSet(ModelViewSet):
|
||||
"""Outpost Viewset"""
|
||||
|
||||
queryset = Outpost.objects.all()
|
||||
serializer_class = OutpostSerializer
|
||||
filterset_fields = {
|
||||
"providers": ["isnull"],
|
||||
}
|
||||
search_fields = [
|
||||
"name",
|
||||
"providers__name",
|
||||
]
|
||||
|
||||
@swagger_auto_schema(responses={200: OutpostHealthSerializer(many=True)})
|
||||
@action(methods=["GET"], detail=True)
|
||||
# pylint: disable=invalid-name, unused-argument
|
||||
def health(self, request: Request, pk: int) -> Response:
|
||||
"""Get outposts current health"""
|
||||
outpost: Outpost = self.get_object()
|
||||
states = []
|
||||
for state in outpost.state:
|
||||
states.append(
|
||||
{
|
||||
"last_seen": state.last_seen,
|
||||
"version": state.version,
|
||||
"version_should": state.version_should,
|
||||
"version_outdated": state.version_outdated,
|
||||
}
|
||||
)
|
||||
return Response(OutpostHealthSerializer(states, many=True).data)
|
||||
@ -9,7 +9,6 @@ from django.core.cache import cache
|
||||
from django.db import models, transaction
|
||||
from django.db.models.base import Model
|
||||
from django.forms.models import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from docker.client import DockerClient
|
||||
from docker.errors import DockerException
|
||||
@ -33,7 +32,6 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InheritanceForeignKey
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.template import render_to_string
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
|
||||
OUR_VERSION = parse(__version__)
|
||||
@ -363,6 +361,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]:
|
||||
@ -377,13 +376,6 @@ class Outpost(models.Model):
|
||||
objects.append(provider)
|
||||
return objects
|
||||
|
||||
def html_deployment_view(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal to view token and other config info"""
|
||||
return render_to_string(
|
||||
"outposts/deployment_modal.html",
|
||||
{"outpost": self, "full_url": request.build_absolute_uri("/")},
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Outpost {self.name}"
|
||||
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<ak-modal-button>
|
||||
<button slot="trigger" class="pf-c-button pf-m-tertiary">
|
||||
{% trans 'View Deployment Info' %}
|
||||
</button>
|
||||
<div slot="modal">
|
||||
<div class="pf-c-modal-box__header">
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Outpost Deployment Info' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<p><a href="https://goauthentik.io/docs/outposts/outposts/#deploy">{% trans 'View deployment documentation' %}</a></p>
|
||||
<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">AUTHENTIK_HOST</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ full_url }}" />
|
||||
</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">AUTHENTIK_TOKEN</span>
|
||||
</label>
|
||||
<div>
|
||||
<ak-token-copy-button identifier="{{ outpost.token_identifier }}">
|
||||
{% trans 'Click to copy token' %}
|
||||
</ak-token-copy-button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{% trans 'If your authentik Instance is using a self-signed certificate, set this value.' %}</h3>
|
||||
<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">AUTHENTIK_INSECURE</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="true" />
|
||||
</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>
|
||||
@ -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"""
|
||||
|
||||
@ -81,7 +80,7 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
)
|
||||
try:
|
||||
policy_result = self.binding.policy.passes(self.request)
|
||||
if self.binding.policy.execution_logging:
|
||||
if self.binding.policy.execution_logging and not self.request.debug:
|
||||
self.create_event(
|
||||
EventAction.POLICY_EXECUTION,
|
||||
message="Policy Execution",
|
||||
@ -95,25 +94,25 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
+ "".join(format_tb(src_exc.__traceback__))
|
||||
+ str(src_exc)
|
||||
)
|
||||
# Create policy exception event
|
||||
self.create_event(EventAction.POLICY_EXCEPTION, message=error_string)
|
||||
# Create policy exception event, only when we're not debugging
|
||||
if not self.request.debug:
|
||||
self.create_event(EventAction.POLICY_EXCEPTION, message=error_string)
|
||||
LOGGER.debug("P_ENG(proc): error", exc=src_exc)
|
||||
policy_result = PolicyResult(False, str(src_exc))
|
||||
policy_result.source_policy = self.binding.policy
|
||||
# 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)
|
||||
|
||||
@ -20,6 +20,7 @@ class PolicyRequest:
|
||||
http_request: Optional[HttpRequest]
|
||||
obj: Optional[Model]
|
||||
context: dict[str, Any]
|
||||
debug: bool = False
|
||||
|
||||
def __init__(self, user: User):
|
||||
super().__init__()
|
||||
|
||||
@ -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
|
||||
|
||||
@swagger_auto_schema(responses={200: OAuth2ProviderSetupURLs(many=False)})
|
||||
@action(methods=["GET"], detail=True)
|
||||
# pylint: disable=invalid-name
|
||||
def setup_urls(self, request: Request, pk: int) -> str:
|
||||
"""Get Providers setup URLs"""
|
||||
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):
|
||||
|
||||
60
authentik/providers/oauth2/managed.py
Normal file
60
authentik/providers/oauth2/managed.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""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,
|
||||
# groups is not part of the official userinfo schema, but is a quasi-standard
|
||||
"groups": [group.name for group in user.ak_groups.all()],
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
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>
|
||||
@ -253,15 +253,16 @@ class OAuthFulfillmentStage(StageView):
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=application,
|
||||
flow=self.executor.plan.flow_pk,
|
||||
scopes=", ".join(self.params.scope),
|
||||
).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 +380,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 +397,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.metadata 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
|
||||
|
||||
@swagger_auto_schema(responses={200: SAMLMetadataSerializer(many=False)})
|
||||
@action(methods=["GET"], detail=True)
|
||||
# pylint: disable=invalid-name
|
||||
def metadata(self, request: Request, pk: int) -> Response:
|
||||
"""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")
|
||||
|
||||
74
authentik/providers/saml/managed.py
Normal file
74
authentik/providers/saml/managed.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""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)",
|
||||
friendly_name="",
|
||||
),
|
||||
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",
|
||||
friendly_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",
|
||||
friendly_name="",
|
||||
),
|
||||
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",
|
||||
friendly_name="",
|
||||
),
|
||||
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",
|
||||
friendly_name="",
|
||||
),
|
||||
EnsureExists(
|
||||
SAMLPropertyMapping,
|
||||
"goauthentik.io/providers/saml/groups",
|
||||
name="authentik default SAML Mapping: Groups",
|
||||
saml_name="http://schemas.xmlsoap.org/claims/Group",
|
||||
expression=GROUP_EXPRESSION,
|
||||
friendly_name="",
|
||||
),
|
||||
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",
|
||||
friendly_name="",
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
"""authentik SAML IDP URLs"""
|
||||
from django.urls import path
|
||||
|
||||
from authentik.providers.saml import views
|
||||
from authentik.providers.saml.views import metadata, sso
|
||||
|
||||
urlpatterns = [
|
||||
# SSO Bindings
|
||||
path(
|
||||
"<slug:application_slug>/sso/binding/redirect/",
|
||||
views.SAMLSSOBindingRedirectView.as_view(),
|
||||
sso.SAMLSSOBindingRedirectView.as_view(),
|
||||
name="sso-redirect",
|
||||
),
|
||||
path(
|
||||
"<slug:application_slug>/sso/binding/post/",
|
||||
views.SAMLSSOBindingPOSTView.as_view(),
|
||||
sso.SAMLSSOBindingPOSTView.as_view(),
|
||||
name="sso-post",
|
||||
),
|
||||
# SSO IdP Initiated
|
||||
path(
|
||||
"<slug:application_slug>/sso/binding/init/",
|
||||
views.SAMLSSOBindingInitView.as_view(),
|
||||
sso.SAMLSSOBindingInitView.as_view(),
|
||||
name="sso-init",
|
||||
),
|
||||
path(
|
||||
"<slug:application_slug>/metadata/",
|
||||
views.DescriptorDownloadView.as_view(),
|
||||
metadata.DescriptorDownloadView.as_view(),
|
||||
name="metadata",
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,284 +0,0 @@
|
||||
"""authentik SAML IDP Views"""
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls.base import reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic.edit import FormView
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application, Provider
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.views import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.forms import SAMLProviderImportForm
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.providers.saml.processors.metadata_parser import (
|
||||
ServiceProviderMetadataParser,
|
||||
)
|
||||
from authentik.providers.saml.processors.request_parser import (
|
||||
AuthNRequest,
|
||||
AuthNRequestParser,
|
||||
)
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64
|
||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
|
||||
|
||||
LOGGER = get_logger()
|
||||
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
|
||||
REQUEST_KEY_SAML_REQUEST = "SAMLRequest"
|
||||
REQUEST_KEY_SAML_SIGNATURE = "Signature"
|
||||
REQUEST_KEY_SAML_SIG_ALG = "SigAlg"
|
||||
REQUEST_KEY_SAML_RESPONSE = "SAMLResponse"
|
||||
REQUEST_KEY_RELAY_STATE = "RelayState"
|
||||
|
||||
SESSION_KEY_AUTH_N_REQUEST = "authn_request"
|
||||
|
||||
|
||||
class SAMLSSOView(PolicyAccessView):
|
||||
""" "SAML SSO Base View, which plans a flow and injects our final stage.
|
||||
Calls get/post handler."""
|
||||
|
||||
def resolve_provider_application(self):
|
||||
self.application = get_object_or_404(
|
||||
Application, slug=self.kwargs["application_slug"]
|
||||
)
|
||||
self.provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=self.application.provider_id
|
||||
)
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Handler to verify the SAML Request. Must be implemented by a subclass"""
|
||||
raise NotImplementedError
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Verify the SAML Request, and if valid initiate the FlowPlanner for the application"""
|
||||
# Call the method handler, which checks the SAML
|
||||
# Request and returns a HTTP Response on error
|
||||
method_response = self.check_saml_request()
|
||||
if method_response:
|
||||
return method_response
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html",
|
||||
},
|
||||
)
|
||||
plan.append(in_memory_stage(SAMLFlowFinalView))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_flows:flow-executor-shell",
|
||||
request.GET,
|
||||
flow_slug=self.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""GET and POST use the same handler, but we can't
|
||||
override .dispatch easily because PolicyAccessView's dispatch"""
|
||||
return self.get(request, application_slug)
|
||||
|
||||
|
||||
class SAMLSSOBindingRedirectView(SAMLSSOView):
|
||||
"""SAML Handler for SSO/Redirect bindings, which are sent via GET"""
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Handle REDIRECT bindings"""
|
||||
if REQUEST_KEY_SAML_REQUEST not in self.request.GET:
|
||||
LOGGER.info("handle_saml_request: SAML payload missing")
|
||||
return bad_request_message(
|
||||
self.request, "The SAML request payload is missing."
|
||||
)
|
||||
|
||||
try:
|
||||
auth_n_request = AuthNRequestParser(self.provider).parse_detached(
|
||||
self.request.GET[REQUEST_KEY_SAML_REQUEST],
|
||||
self.request.GET.get(REQUEST_KEY_RELAY_STATE),
|
||||
self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
|
||||
self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
|
||||
)
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
except CannotHandleAssertion as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
provider=self.provider,
|
||||
message=str(exc),
|
||||
).save()
|
||||
LOGGER.info(str(exc))
|
||||
return bad_request_message(self.request, str(exc))
|
||||
return None
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SAMLSSOBindingPOSTView(SAMLSSOView):
|
||||
"""SAML Handler for SSO/POST bindings"""
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Handle POST bindings"""
|
||||
if REQUEST_KEY_SAML_REQUEST not in self.request.POST:
|
||||
LOGGER.info("check_saml_request: SAML payload missing")
|
||||
return bad_request_message(
|
||||
self.request, "The SAML request payload is missing."
|
||||
)
|
||||
|
||||
try:
|
||||
auth_n_request = AuthNRequestParser(self.provider).parse(
|
||||
self.request.POST[REQUEST_KEY_SAML_REQUEST],
|
||||
self.request.POST.get(REQUEST_KEY_RELAY_STATE),
|
||||
)
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
except CannotHandleAssertion as exc:
|
||||
LOGGER.info(str(exc))
|
||||
return bad_request_message(self.request, str(exc))
|
||||
return None
|
||||
|
||||
|
||||
class SAMLSSOBindingInitView(SAMLSSOView):
|
||||
"""SAML Handler for for IdP Initiated login flows"""
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Create SAML Response from scratch"""
|
||||
LOGGER.debug(
|
||||
"handle_saml_no_request: No SAML Request, using IdP-initiated flow."
|
||||
)
|
||||
auth_n_request = AuthNRequestParser(self.provider).idp_initiated()
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
|
||||
|
||||
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
|
||||
class SAMLFlowFinalView(StageView):
|
||||
"""View used by FlowExecutor after all stages have passed. Logs the authorization,
|
||||
and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for
|
||||
(if POST is configured)."""
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||
provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=application.provider_id
|
||||
)
|
||||
# Log Application Authorization
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=application,
|
||||
flow=self.executor.plan.flow_pk,
|
||||
).from_http(self.request)
|
||||
|
||||
if SESSION_KEY_AUTH_N_REQUEST not in self.request.session:
|
||||
return self.executor.stage_invalid()
|
||||
|
||||
auth_n_request: AuthNRequest = self.request.session.pop(
|
||||
SESSION_KEY_AUTH_N_REQUEST
|
||||
)
|
||||
response = AssertionProcessor(
|
||||
provider, request, auth_n_request
|
||||
).build_response()
|
||||
|
||||
if provider.sp_binding == SAMLBindings.POST:
|
||||
form_attrs = {
|
||||
"ACSUrl": provider.acs_url,
|
||||
REQUEST_KEY_SAML_RESPONSE: nice64(response),
|
||||
}
|
||||
if auth_n_request.relay_state:
|
||||
form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
|
||||
return render(
|
||||
self.request,
|
||||
"generic/autosubmit_form.html",
|
||||
{
|
||||
"url": provider.acs_url,
|
||||
"title": _("Redirecting to %(app)s..." % {"app": application.name}),
|
||||
"attrs": form_attrs,
|
||||
},
|
||||
)
|
||||
if provider.sp_binding == SAMLBindings.REDIRECT:
|
||||
url_args = {
|
||||
REQUEST_KEY_SAML_RESPONSE: deflate_and_base64_encode(response),
|
||||
}
|
||||
if auth_n_request.relay_state:
|
||||
url_args[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
|
||||
querystring = urlencode(url_args)
|
||||
return redirect(f"{provider.acs_url}?{querystring}")
|
||||
return bad_request_message(request, "Invalid sp_binding specified")
|
||||
|
||||
|
||||
class DescriptorDownloadView(View):
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
|
||||
@staticmethod
|
||||
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
||||
"""Return rendered XML Metadata"""
|
||||
return MetadataProcessor(provider, request).build_entity_descriptor()
|
||||
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=application.provider_id
|
||||
)
|
||||
try:
|
||||
metadata = DescriptorDownloadView.get_metadata(request, provider)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
return bad_request_message(
|
||||
request, "Provider is not assigned to an application."
|
||||
)
|
||||
else:
|
||||
response = HttpResponse(metadata, content_type="application/xml")
|
||||
response[
|
||||
"Content-Disposition"
|
||||
] = f'attachment; filename="{provider.name}_authentik_meta.xml"'
|
||||
return response
|
||||
|
||||
|
||||
class MetadataImportView(LoginRequiredMixin, FormView):
|
||||
"""Import Metadata from XML, and create provider"""
|
||||
|
||||
form_class = SAMLProviderImportForm
|
||||
template_name = "providers/saml/import.html"
|
||||
success_url = reverse_lazy("authentik_admin:providers")
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
return self.handle_no_permission()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form: SAMLProviderImportForm) -> HttpResponse:
|
||||
try:
|
||||
metadata = ServiceProviderMetadataParser().parse(
|
||||
form.cleaned_data["metadata"].read().decode()
|
||||
)
|
||||
metadata.to_provider(
|
||||
form.cleaned_data["provider_name"],
|
||||
form.cleaned_data["authorization_flow"],
|
||||
)
|
||||
messages.success(self.request, _("Successfully created Provider"))
|
||||
except ValueError as exc:
|
||||
LOGGER.warning(str(exc))
|
||||
messages.error(
|
||||
self.request,
|
||||
_("Failed to import Metadata: %(message)s" % {"message": str(exc)}),
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
return super().form_valid(form)
|
||||
0
authentik/providers/saml/views/__init__.py
Normal file
0
authentik/providers/saml/views/__init__.py
Normal file
82
authentik/providers/saml/views/flows.py
Normal file
82
authentik/providers/saml/views/flows.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""authentik SAML IDP Views"""
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||
from authentik.providers.saml.processors.request_parser import AuthNRequest
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64
|
||||
|
||||
LOGGER = get_logger()
|
||||
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
|
||||
REQUEST_KEY_SAML_REQUEST = "SAMLRequest"
|
||||
REQUEST_KEY_SAML_SIGNATURE = "Signature"
|
||||
REQUEST_KEY_SAML_SIG_ALG = "SigAlg"
|
||||
REQUEST_KEY_SAML_RESPONSE = "SAMLResponse"
|
||||
REQUEST_KEY_RELAY_STATE = "RelayState"
|
||||
|
||||
SESSION_KEY_AUTH_N_REQUEST = "authn_request"
|
||||
|
||||
|
||||
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
|
||||
class SAMLFlowFinalView(StageView):
|
||||
"""View used by FlowExecutor after all stages have passed. Logs the authorization,
|
||||
and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for
|
||||
(if POST is configured)."""
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||
provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=application.provider_id
|
||||
)
|
||||
# Log Application Authorization
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=application,
|
||||
flow=self.executor.plan.flow_pk,
|
||||
).from_http(self.request)
|
||||
|
||||
if SESSION_KEY_AUTH_N_REQUEST not in self.request.session:
|
||||
return self.executor.stage_invalid()
|
||||
|
||||
auth_n_request: AuthNRequest = self.request.session.pop(
|
||||
SESSION_KEY_AUTH_N_REQUEST
|
||||
)
|
||||
response = AssertionProcessor(
|
||||
provider, request, auth_n_request
|
||||
).build_response()
|
||||
|
||||
if provider.sp_binding == SAMLBindings.POST:
|
||||
form_attrs = {
|
||||
"ACSUrl": provider.acs_url,
|
||||
REQUEST_KEY_SAML_RESPONSE: nice64(response),
|
||||
}
|
||||
if auth_n_request.relay_state:
|
||||
form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
|
||||
return render(
|
||||
self.request,
|
||||
"generic/autosubmit_form.html",
|
||||
{
|
||||
"url": provider.acs_url,
|
||||
"title": _("Redirecting to %(app)s..." % {"app": application.name}),
|
||||
"attrs": form_attrs,
|
||||
},
|
||||
)
|
||||
if provider.sp_binding == SAMLBindings.REDIRECT:
|
||||
url_args = {
|
||||
REQUEST_KEY_SAML_RESPONSE: deflate_and_base64_encode(response),
|
||||
}
|
||||
if auth_n_request.relay_state:
|
||||
url_args[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
|
||||
querystring = urlencode(url_args)
|
||||
return redirect(f"{provider.acs_url}?{querystring}")
|
||||
return bad_request_message(request, "Invalid sp_binding specified")
|
||||
82
authentik/providers/saml/views/metadata.py
Normal file
82
authentik/providers/saml/views/metadata.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""authentik SAML IDP Views"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls.base import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic.edit import FormView
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application, Provider
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.forms import SAMLProviderImportForm
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.providers.saml.processors.metadata_parser import (
|
||||
ServiceProviderMetadataParser,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class DescriptorDownloadView(View):
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
|
||||
@staticmethod
|
||||
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
||||
"""Return rendered XML Metadata"""
|
||||
return MetadataProcessor(provider, request).build_entity_descriptor()
|
||||
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=application.provider_id
|
||||
)
|
||||
try:
|
||||
metadata = DescriptorDownloadView.get_metadata(request, provider)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
return bad_request_message(
|
||||
request, "Provider is not assigned to an application."
|
||||
)
|
||||
else:
|
||||
response = HttpResponse(metadata, content_type="application/xml")
|
||||
response[
|
||||
"Content-Disposition"
|
||||
] = f'attachment; filename="{provider.name}_authentik_meta.xml"'
|
||||
return response
|
||||
|
||||
|
||||
class MetadataImportView(LoginRequiredMixin, FormView):
|
||||
"""Import Metadata from XML, and create provider"""
|
||||
|
||||
form_class = SAMLProviderImportForm
|
||||
template_name = "providers/saml/import.html"
|
||||
success_url = reverse_lazy("authentik_admin:providers")
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
return self.handle_no_permission()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form: SAMLProviderImportForm) -> HttpResponse:
|
||||
try:
|
||||
metadata = ServiceProviderMetadataParser().parse(
|
||||
form.cleaned_data["metadata"].read().decode()
|
||||
)
|
||||
metadata.to_provider(
|
||||
form.cleaned_data["provider_name"],
|
||||
form.cleaned_data["authorization_flow"],
|
||||
)
|
||||
messages.success(self.request, _("Successfully created Provider"))
|
||||
except ValueError as exc:
|
||||
LOGGER.warning(str(exc))
|
||||
messages.error(
|
||||
self.request,
|
||||
_("Failed to import Metadata: %(message)s" % {"message": str(exc)}),
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
return super().form_valid(form)
|
||||
150
authentik/providers/saml/views/sso.py
Normal file
150
authentik/providers/saml/views/sso.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""authentik SAML IDP Views"""
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.views import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.request_parser import AuthNRequestParser
|
||||
from authentik.providers.saml.views.flows import (
|
||||
REQUEST_KEY_RELAY_STATE,
|
||||
REQUEST_KEY_SAML_REQUEST,
|
||||
REQUEST_KEY_SAML_SIG_ALG,
|
||||
REQUEST_KEY_SAML_SIGNATURE,
|
||||
SESSION_KEY_AUTH_N_REQUEST,
|
||||
SAMLFlowFinalView,
|
||||
)
|
||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class SAMLSSOView(PolicyAccessView):
|
||||
""" "SAML SSO Base View, which plans a flow and injects our final stage.
|
||||
Calls get/post handler."""
|
||||
|
||||
def resolve_provider_application(self):
|
||||
self.application = get_object_or_404(
|
||||
Application, slug=self.kwargs["application_slug"]
|
||||
)
|
||||
self.provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=self.application.provider_id
|
||||
)
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Handler to verify the SAML Request. Must be implemented by a subclass"""
|
||||
raise NotImplementedError
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Verify the SAML Request, and if valid initiate the FlowPlanner for the application"""
|
||||
# Call the method handler, which checks the SAML
|
||||
# Request and returns a HTTP Response on error
|
||||
method_response = self.check_saml_request()
|
||||
if method_response:
|
||||
return method_response
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html",
|
||||
},
|
||||
)
|
||||
plan.append(in_memory_stage(SAMLFlowFinalView))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_flows:flow-executor-shell",
|
||||
request.GET,
|
||||
flow_slug=self.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""GET and POST use the same handler, but we can't
|
||||
override .dispatch easily because PolicyAccessView's dispatch"""
|
||||
return self.get(request, application_slug)
|
||||
|
||||
|
||||
class SAMLSSOBindingRedirectView(SAMLSSOView):
|
||||
"""SAML Handler for SSO/Redirect bindings, which are sent via GET"""
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Handle REDIRECT bindings"""
|
||||
if REQUEST_KEY_SAML_REQUEST not in self.request.GET:
|
||||
LOGGER.info("handle_saml_request: SAML payload missing")
|
||||
return bad_request_message(
|
||||
self.request, "The SAML request payload is missing."
|
||||
)
|
||||
|
||||
try:
|
||||
auth_n_request = AuthNRequestParser(self.provider).parse_detached(
|
||||
self.request.GET[REQUEST_KEY_SAML_REQUEST],
|
||||
self.request.GET.get(REQUEST_KEY_RELAY_STATE),
|
||||
self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE),
|
||||
self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG),
|
||||
)
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
except CannotHandleAssertion as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
provider=self.provider,
|
||||
message=str(exc),
|
||||
).save()
|
||||
LOGGER.info(str(exc))
|
||||
return bad_request_message(self.request, str(exc))
|
||||
return None
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SAMLSSOBindingPOSTView(SAMLSSOView):
|
||||
"""SAML Handler for SSO/POST bindings"""
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Handle POST bindings"""
|
||||
if REQUEST_KEY_SAML_REQUEST not in self.request.POST:
|
||||
LOGGER.info("check_saml_request: SAML payload missing")
|
||||
return bad_request_message(
|
||||
self.request, "The SAML request payload is missing."
|
||||
)
|
||||
|
||||
try:
|
||||
auth_n_request = AuthNRequestParser(self.provider).parse(
|
||||
self.request.POST[REQUEST_KEY_SAML_REQUEST],
|
||||
self.request.POST.get(REQUEST_KEY_RELAY_STATE),
|
||||
)
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
except CannotHandleAssertion as exc:
|
||||
LOGGER.info(str(exc))
|
||||
return bad_request_message(self.request, str(exc))
|
||||
return None
|
||||
|
||||
|
||||
class SAMLSSOBindingInitView(SAMLSSOView):
|
||||
"""SAML Handler for for IdP Initiated login flows"""
|
||||
|
||||
def check_saml_request(self) -> Optional[HttpRequest]:
|
||||
"""Create SAML Response from scratch"""
|
||||
LOGGER.debug(
|
||||
"handle_saml_no_request: No SAML Request, using IdP-initiated flow."
|
||||
)
|
||||
auth_n_request = AuthNRequestParser(self.provider).idp_initiated()
|
||||
self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request
|
||||
@ -94,12 +94,6 @@ class ASGILogger:
|
||||
self.log(runtime)
|
||||
await send(message)
|
||||
|
||||
if self.headers.get(b"host", b"") == b"authentik-healthcheck-host":
|
||||
# Don't log healthcheck/readiness requests
|
||||
await send({"type": "http.response.start", "status": 204, "headers": []})
|
||||
await send({"type": "http.response.body", "body": ""})
|
||||
return
|
||||
|
||||
self.start = time()
|
||||
if scope["type"] == "lifespan":
|
||||
# https://code.djangoproject.com/ticket/31508
|
||||
@ -129,7 +123,7 @@ class ASGILogger:
|
||||
method=self.scope.get("method", ""),
|
||||
scheme=self.scope.get("scheme", ""),
|
||||
status=self.status_code,
|
||||
size=self.content_length / 1000 if self.content_length > 0 else "-",
|
||||
size=self.content_length / 1000 if self.content_length > 0 else 0,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user