diff --git a/Makefile b/Makefile
index 8a060c8bd..1bc336bf8 100644
--- a/Makefile
+++ b/Makefile
@@ -56,7 +56,7 @@ gen-outpost:
-i /local/schema.yml \
-g go \
-o /local/outpost/api \
- --additional-properties=packageName=api,enumClassPrefix=true
+ --additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true
rm -f outpost/api/go.mod outpost/api/go.sum
gen: gen-build gen-clean gen-web gen-outpost
diff --git a/Pipfile b/Pipfile
index 6ca7b5f3d..a6627d069 100644
--- a/Pipfile
+++ b/Pipfile
@@ -44,6 +44,7 @@ urllib3 = {extras = ["secure"],version = "*"}
uvicorn = {extras = ["standard"],version = "*"}
webauthn = "*"
xmlsec = "*"
+duo-client = "*"
[requires]
python_version = "3.9"
diff --git a/Pipfile.lock b/Pipfile.lock
index 52cde12fd..ce333d603 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "61354b75aa954ea0a995ee1909b861092a4be5c1af66d3c00c7c7845e056d064"
+ "sha256": "eb043e24ba05d5d78459a973fe0cd7c37dad1cca90431f68b6df773247c58cbb"
},
"pipfile-spec": 6,
"requires": {
@@ -56,6 +56,7 @@
"sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a",
"sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"
],
+ "markers": "python_version >= '3.6'",
"version": "==3.7.4.post0"
},
"aioredis": {
@@ -70,6 +71,7 @@
"sha256:03e16e94f2b34c31f8bf1206d8ddd3ccaa4c315f7f6a1879b7b1210d229568c2",
"sha256:493a2ac6788ce270a2f6a765b017299f60c1998f5a8617908ee9be082f7300fb"
],
+ "markers": "python_version >= '3.6'",
"version": "==5.0.6"
},
"asgiref": {
@@ -77,6 +79,7 @@
"sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee",
"sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"
],
+ "markers": "python_version >= '3.6'",
"version": "==3.3.4"
},
"async-timeout": {
@@ -84,6 +87,7 @@
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
],
+ "markers": "python_full_version >= '3.5.3'",
"version": "==3.0.1"
},
"attrs": {
@@ -91,6 +95,7 @@
"sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
"sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==21.2.0"
},
"autobahn": {
@@ -98,6 +103,7 @@
"sha256:9195df8af03b0ff29ccd4b7f5abbde957ee90273465942205f9a1bad6c3f07ac",
"sha256:e126c1f583e872fb59e79d36977cfa1f2d0a8a79f90ae31f406faae7664b8e03"
],
+ "markers": "python_version >= '3.7'",
"version": "==21.3.1"
},
"automat": {
@@ -127,6 +133,7 @@
"sha256:7518c3f028123f2c770cf1e568f24877259e0ec03badef657174fa93392a9e6a",
"sha256:b69c96f210a79f544c6dea923f708873121c174b8b4d72babd725328bf53e3d2"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==1.20.79"
},
"cachetools": {
@@ -134,6 +141,7 @@
"sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001",
"sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff"
],
+ "markers": "python_version ~= '3.5'",
"version": "==4.2.2"
},
"cbor2": {
@@ -152,6 +160,7 @@
"sha256:f0058d33b5eaffb176d6190d175a5391f13362f165881deea2b99e63b66ecf55",
"sha256:f5df0ad8c16f7992bf24e5c9a53f03a11a990fd18253c3c335315bd25a34f832"
],
+ "markers": "python_version >= '3.6'",
"version": "==5.3.0"
},
"celery": {
@@ -244,6 +253,7 @@
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==4.0.0"
},
"click": {
@@ -251,6 +261,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
},
"click-didyoumean": {
@@ -310,6 +321,7 @@
"sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f",
"sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393"
],
+ "markers": "python_version >= '3.6'",
"version": "==3.0.2"
},
"defusedxml": {
@@ -420,6 +432,14 @@
"index": "pypi",
"version": "==0.16.0"
},
+ "duo-client": {
+ "hashes": [
+ "sha256:790de9573e2a0a85cc10cdb671b37759ee5f6c8557fe9972a8837bb07d71f69b",
+ "sha256:a5c9282cba3a02ae2ffbb16552e66379c19272664561baec86ad3a661f26ebf2"
+ ],
+ "index": "pypi",
+ "version": "==4.3.1"
+ },
"facebook-sdk": {
"hashes": [
"sha256:2e987b3e0f466a6f4ee77b935eb023dba1384134f004a2af21f1cfff7fe0806e",
@@ -432,6 +452,7 @@
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.18.2"
},
"geoip2": {
@@ -447,6 +468,7 @@
"sha256:044d81b1e58012f8ebc71cc134e191c1fa312f543f1fbc99973afe28c25e3228",
"sha256:b3ca7a8ff9ab3bdefee3ad5aefb11fc6485423767eee016f5942d8e606ca23fb"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==1.30.1"
},
"gunicorn": {
@@ -462,6 +484,7 @@
"sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6",
"sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"
],
+ "markers": "python_version >= '3.6'",
"version": "==0.12.0"
},
"hiredis": {
@@ -508,6 +531,7 @@
"sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0",
"sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a"
],
+ "markers": "python_version >= '3.6'",
"version": "==2.0.0"
},
"httptools": {
@@ -556,6 +580,7 @@
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
"sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
],
+ "markers": "python_version >= '3.5'",
"version": "==0.5.1"
},
"jmespath": {
@@ -563,6 +588,7 @@
"sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9",
"sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"
],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.0"
},
"jsonschema": {
@@ -577,6 +603,7 @@
"sha256:01481d99f4606f6939cdc9b637264ed353ee9e3e4f62cfb582324142c41a572d",
"sha256:e2dedd8a86c9077c350555153825a31e456a0dc20c15d5751f00137ec9c75f0a"
],
+ "markers": "python_version >= '3.6'",
"version": "==5.1.0"
},
"kubernetes": {
@@ -590,7 +617,10 @@
"ldap3": {
"hashes": [
"sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91",
- "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57"
+ "sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59",
+ "sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c",
+ "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57",
+ "sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056"
],
"index": "pypi",
"version": "==2.9"
@@ -651,6 +681,7 @@
"hashes": [
"sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc"
],
+ "markers": "python_version >= '3.6'",
"version": "==2.0.3"
},
"msgpack": {
@@ -726,6 +757,7 @@
"sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281",
"sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"
],
+ "markers": "python_version >= '3.6'",
"version": "==5.1.0"
},
"oauthlib": {
@@ -733,6 +765,7 @@
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.1.0"
},
"packaging": {
@@ -748,6 +781,7 @@
"sha256:030e4f9df5f53db2292eec37c6255957eb76168c6f974e4176c711cf91ed34aa",
"sha256:b6c5a9643e3545bcbfd9451766cbaa5d9c67e7303c7bc32c750b6fa70ecb107d"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.1"
},
"prompt-toolkit": {
@@ -755,6 +789,7 @@
"sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04",
"sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc"
],
+ "markers": "python_full_version >= '3.6.1'",
"version": "==3.0.18"
},
"psycopg2-binary": {
@@ -800,15 +835,37 @@
},
"pyasn1": {
"hashes": [
+ "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
+ "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
+ "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
+ "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
+ "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
+ "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
+ "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
+ "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
+ "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
+ "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
+ "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3",
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
- "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"
+ "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"
],
"version": "==0.4.8"
},
"pyasn1-modules": {
"hashes": [
+ "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0",
+ "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d",
+ "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74",
+ "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed",
+ "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199",
+ "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb",
+ "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd",
+ "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811",
+ "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8",
"sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e",
- "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"
+ "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4",
+ "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45",
+ "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"
],
"version": "==0.2.8"
},
@@ -817,6 +874,7 @@
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.20"
},
"pycryptodome": {
@@ -860,6 +918,7 @@
"sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316",
"sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29"
],
+ "markers": "python_version >= '3.5'",
"version": "==2.0.2"
},
"pyjwt": {
@@ -882,12 +941,14 @@
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7"
},
"pyrsistent": {
"hashes": [
"sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"
],
+ "markers": "python_version >= '3.5'",
"version": "==0.17.3"
},
"python-dateutil": {
@@ -895,6 +956,7 @@
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.1"
},
"python-dotenv": {
@@ -951,6 +1013,7 @@
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==3.5.3"
},
"requests": {
@@ -958,12 +1021,14 @@
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.25.1"
},
"requests-oauthlib": {
"hashes": [
+ "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a",
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
- "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
+ "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"
],
"index": "pypi",
"version": "==1.3.0"
@@ -1004,6 +1069,7 @@
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"sqlparse": {
@@ -1011,6 +1077,7 @@
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
],
+ "markers": "python_version >= '3.5'",
"version": "==0.4.1"
},
"structlog": {
@@ -1066,6 +1133,7 @@
"sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8",
"sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb"
],
+ "markers": "python_version >= '3.6'",
"version": "==21.2.1"
},
"typing-extensions": {
@@ -1081,6 +1149,7 @@
"sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f",
"sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.1"
},
"urllib3": {
@@ -1125,6 +1194,7 @@
"sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30",
"sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"
],
+ "markers": "python_version >= '3.6'",
"version": "==5.0.0"
},
"watchgod": {
@@ -1154,6 +1224,7 @@
"sha256:3e2bf58191d4619b161389a95bdce84ce9e0b24eb8107e7e590db682c2d0ca81",
"sha256:abf306dc6351dcef07f4d40453037e51cc5d9da2ef60d0fc5d0fe3bcda255372"
],
+ "markers": "python_version >= '3.6'",
"version": "==1.0.1"
},
"websockets": {
@@ -1240,6 +1311,7 @@
"sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a",
"sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"
],
+ "markers": "python_version >= '3.6'",
"version": "==1.6.3"
},
"zope.interface": {
@@ -1296,6 +1368,7 @@
"sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4",
"sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==5.4.0"
}
},
@@ -1312,6 +1385,7 @@
"sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e",
"sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975"
],
+ "markers": "python_version ~= '3.6'",
"version": "==2.5.6"
},
"attrs": {
@@ -1319,6 +1393,7 @@
"sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
"sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==21.2.0"
},
"bandit": {
@@ -1357,6 +1432,7 @@
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==4.0.0"
},
"click": {
@@ -1364,6 +1440,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
},
"colorama": {
@@ -1437,6 +1514,7 @@
"sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0",
"sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"
],
+ "markers": "python_version >= '3.4'",
"version": "==4.0.7"
},
"gitpython": {
@@ -1444,6 +1522,7 @@
"sha256:29fe82050709760081f588dd50ce83504feddbebdc4da6956d02351552b1c135",
"sha256:ee24bdc93dce357630764db659edaf6b8d664d4ff5447ccfeedd2dc5c253f41e"
],
+ "markers": "python_version >= '3.5'",
"version": "==3.1.17"
},
"idna": {
@@ -1465,6 +1544,7 @@
"sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6",
"sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"
],
+ "markers": "python_version >= '3.6' and python_version < '4.0'",
"version": "==5.8.0"
},
"lazy-object-proxy": {
@@ -1492,6 +1572,7 @@
"sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93",
"sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==1.6.0"
},
"mccabe": {
@@ -1528,6 +1609,7 @@
"sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd",
"sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"
],
+ "markers": "python_version >= '2.6'",
"version": "==5.6.0"
},
"pluggy": {
@@ -1535,6 +1617,7 @@
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.13.1"
},
"py": {
@@ -1542,6 +1625,7 @@
"sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
"sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.10.0"
},
"pylint": {
@@ -1572,6 +1656,7 @@
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7"
},
"pytest": {
@@ -1676,6 +1761,7 @@
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.25.1"
},
"requests-mock": {
@@ -1699,6 +1785,7 @@
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"smmap": {
@@ -1706,6 +1793,7 @@
"sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182",
"sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"
],
+ "markers": "python_version >= '3.5'",
"version": "==4.0.0"
},
"stevedore": {
@@ -1713,6 +1801,7 @@
"sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee",
"sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"
],
+ "markers": "python_version >= '3.6'",
"version": "==3.3.0"
},
"toml": {
@@ -1720,6 +1809,7 @@
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2"
},
"urllib3": {
diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py
index 82a4c1b01..c3a760141 100644
--- a/authentik/api/authentication.py
+++ b/authentik/api/authentication.py
@@ -18,7 +18,7 @@ LOGGER = get_logger()
def token_from_header(raw_header: bytes) -> Optional[Token]:
"""raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
auth_credentials = raw_header.decode()
- if auth_credentials == "":
+ if auth_credentials == "" or " " not in auth_credentials:
return None
auth_type, auth_credentials = auth_credentials.split()
if auth_type.lower() not in ["basic", "bearer"]:
diff --git a/authentik/api/schema.py b/authentik/api/schema.py
index 4548a80f0..994ed5af7 100644
--- a/authentik/api/schema.py
+++ b/authentik/api/schema.py
@@ -67,4 +67,11 @@ def postprocess_schema_responses(result, generator, **kwargs): # noqa: W0613
spectacular_settings.APPEND_COMPONENTS
)
+ # This is a workaround for authentik/stages/prompt/stage.py
+ # since the serializer PromptChallengeResponse
+ # accepts dynamic keys
+ for component in result["components"]["schemas"]:
+ if component == "PromptChallengeResponseRequest":
+ comp = result["components"]["schemas"][component]
+ comp["additionalProperties"] = {}
return result
diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py
index 6c7971836..6dce33066 100644
--- a/authentik/api/v2/urls.py
+++ b/authentik/api/v2/urls.py
@@ -64,6 +64,11 @@ from authentik.sources.oauth.api.source_connection import (
)
from authentik.sources.plex.api import PlexSourceViewSet
from authentik.sources.saml.api import SAMLSourceViewSet
+from authentik.stages.authenticator_duo.api import (
+ AuthenticatorDuoStageViewSet,
+ DuoAdminDeviceViewSet,
+ DuoDeviceViewSet,
+)
from authentik.stages.authenticator_static.api import (
AuthenticatorStaticStageViewSet,
StaticAdminDeviceViewSet,
@@ -158,9 +163,15 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
router.register("propertymappings/scope", ScopeMappingViewSet)
+router.register("authenticators/duo", DuoDeviceViewSet)
router.register("authenticators/static", StaticDeviceViewSet)
router.register("authenticators/totp", TOTPDeviceViewSet)
router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
+router.register(
+ "authenticators/admin/duo",
+ DuoAdminDeviceViewSet,
+ basename="admin-duodevice",
+)
router.register(
"authenticators/admin/static",
StaticAdminDeviceViewSet,
@@ -176,6 +187,7 @@ router.register(
)
router.register("stages/all", StageViewSet)
+router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet)
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
diff --git a/authentik/events/apps.py b/authentik/events/apps.py
index f0eb77c9c..e033b747c 100644
--- a/authentik/events/apps.py
+++ b/authentik/events/apps.py
@@ -4,7 +4,7 @@ from importlib import import_module
from django.apps import AppConfig
from django.db import ProgrammingError
-from django.utils.timezone import datetime
+from django.utils.timezone import now
class AuthentikEventsConfig(AppConfig):
@@ -19,7 +19,7 @@ class AuthentikEventsConfig(AppConfig):
try:
from authentik.events.models import Event
- date_from = datetime.now() - timedelta(days=1)
+ date_from = now() - timedelta(days=1)
for event in Event.objects.filter(created__gte=date_from):
event._set_prom_metrics()
diff --git a/authentik/events/monitored_tasks.py b/authentik/events/monitored_tasks.py
index d3a269aed..30da8bf44 100644
--- a/authentik/events/monitored_tasks.py
+++ b/authentik/events/monitored_tasks.py
@@ -88,7 +88,10 @@ class TaskInfo:
start = default_timer()
if hasattr(self, "start_timestamp"):
start = self.start_timestamp
- duration = max(self.finish_timestamp - start, 0)
+ try:
+ duration = max(self.finish_timestamp - start, 0)
+ except TypeError:
+ duration = 0
GAUGE_TASKS.labels(
task_name=self.task_name,
task_uid=self.result.uid or "",
diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py
index 269f79173..64d38943c 100644
--- a/authentik/flows/api/flows.py
+++ b/authentik/flows/api/flows.py
@@ -174,8 +174,8 @@ class FlowViewSet(ModelViewSet):
return HttpResponseBadRequest()
successful = importer.apply()
if not successful:
- return Response(status=204)
- return HttpResponseBadRequest()
+ return HttpResponseBadRequest()
+ return Response(status=204)
@permission_required(
"authentik_flows.export_flow",
diff --git a/authentik/flows/apps.py b/authentik/flows/apps.py
index 513b3f044..9fcc8c52a 100644
--- a/authentik/flows/apps.py
+++ b/authentik/flows/apps.py
@@ -2,6 +2,9 @@
from importlib import import_module
from django.apps import AppConfig
+from django.db.utils import ProgrammingError
+
+from authentik.lib.utils.reflection import all_subclasses
class AuthentikFlowsConfig(AppConfig):
@@ -14,3 +17,10 @@ class AuthentikFlowsConfig(AppConfig):
def ready(self):
import_module("authentik.flows.signals")
+ try:
+ from authentik.flows.models import Stage
+
+ for stage in all_subclasses(Stage):
+ _ = stage().type
+ except ProgrammingError:
+ pass
diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py
index f1f04a9ab..62887c2b7 100644
--- a/authentik/flows/challenge.py
+++ b/authentik/flows/challenge.py
@@ -35,9 +35,9 @@ class Challenge(PassiveSerializer):
type = ChoiceField(
choices=[(x.value, x.name) for x in ChallengeTypes],
)
- component = CharField(required=False)
- title = CharField(required=False)
+ title = CharField(required=False, allow_blank=True)
background = CharField(required=False)
+ component = CharField(default="")
response_errors = DictField(
child=ErrorDetailSerializer(many=True), allow_empty=True, required=False
@@ -48,18 +48,20 @@ class RedirectChallenge(Challenge):
"""Challenge type to redirect the client"""
to = CharField()
+ component = CharField(default="xak-flow-redirect")
class ShellChallenge(Challenge):
- """Legacy challenge type to render HTML as-is"""
+ """challenge type to render HTML as-is"""
body = CharField()
+ component = CharField(default="xak-flow-shell")
class WithUserInfoChallenge(Challenge):
"""Challenge base which shows some user info"""
- pending_user = CharField()
+ pending_user = CharField(allow_blank=True)
pending_user_avatar = CharField()
@@ -67,6 +69,7 @@ class AccessDeniedChallenge(Challenge):
"""Challenge when a flow's active stage calls `stage_invalid()`."""
error_message = CharField(required=False)
+ component = CharField(default="ak-stage-access-denied")
class PermissionSerializer(PassiveSerializer):
@@ -80,6 +83,7 @@ class ChallengeResponse(PassiveSerializer):
"""Base class for all challenge responses"""
stage: Optional["StageView"]
+ component = CharField(default="xak-flow-response-default")
def __init__(self, instance=None, data=None, **kwargs):
self.stage = kwargs.pop("stage", None)
diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py
index 718f64cfa..65f2e24cb 100644
--- a/authentik/flows/planner.py
+++ b/authentik/flows/planner.py
@@ -26,7 +26,7 @@ PLAN_CONTEXT_SOURCE = "source"
GAUGE_FLOWS_CACHED = UpdatingGauge(
"authentik_flows_cached",
"Cached flows",
- update_func=lambda: len(cache.keys("flow_*")),
+ update_func=lambda: len(cache.keys("flow_*") or []),
)
HIST_FLOWS_PLAN_TIME = Histogram(
"authentik_flows_plan_time",
diff --git a/authentik/flows/tests/test_views.py b/authentik/flows/tests/test_views.py
index f06ba18b5..40940196c 100644
--- a/authentik/flows/tests/test_views.py
+++ b/authentik/flows/tests/test_views.py
@@ -289,7 +289,11 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
def test_reevaluate_keep(self):
@@ -366,7 +370,11 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
def test_reevaluate_remove_consecutive(self):
@@ -458,7 +466,11 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
def test_stageview_user_identifier(self):
diff --git a/authentik/flows/views.py b/authentik/flows/views.py
index 509f86fd4..33490ae89 100644
--- a/authentik/flows/views.py
+++ b/authentik/flows/views.py
@@ -11,7 +11,12 @@ from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import View
from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
+from drf_spectacular.utils import (
+ OpenApiParameter,
+ OpenApiResponse,
+ PolymorphicProxySerializer,
+ extend_schema,
+)
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from sentry_sdk import capture_exception
@@ -22,10 +27,12 @@ from authentik.events.models import cleanse_dict
from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge,
+ ChallengeResponse,
ChallengeTypes,
HttpChallengeResponse,
RedirectChallenge,
ShellChallenge,
+ WithUserInfoChallenge,
)
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
@@ -35,7 +42,7 @@ from authentik.flows.planner import (
FlowPlan,
FlowPlanner,
)
-from authentik.lib.utils.reflection import class_to_path
+from authentik.lib.utils.reflection import all_subclasses, class_to_path
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
LOGGER = get_logger()
@@ -46,6 +53,43 @@ SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre"
SESSION_KEY_GET = "authentik_flows_get"
+def challenge_types():
+ """This is a workaround for PolymorphicProxySerializer not accepting a callable for
+ `serializers`. This function returns a class which is an iterator, which returns the
+ subclasses of Challenge, and Challenge itself."""
+
+ class Inner(dict):
+ """dummy class with custom callback on .items()"""
+
+ def items(self):
+ mapping = {}
+ classes = all_subclasses(Challenge)
+ classes.remove(WithUserInfoChallenge)
+ for cls in classes:
+ mapping[cls().fields["component"].default] = cls
+ return mapping.items()
+
+ return Inner()
+
+
+def challenge_response_types():
+ """This is a workaround for PolymorphicProxySerializer not accepting a callable for
+ `serializers`. This function returns a class which is an iterator, which returns the
+ subclasses of Challenge, and Challenge itself."""
+
+ class Inner(dict):
+ """dummy class with custom callback on .items()"""
+
+ def items(self):
+ mapping = {}
+ classes = all_subclasses(ChallengeResponse)
+ for cls in classes:
+ mapping[cls(stage=None).fields["component"].default] = cls
+ return mapping.items()
+
+ return Inner()
+
+
@method_decorator(xframe_options_sameorigin, name="dispatch")
class FlowExecutorView(APIView):
"""Stage 1 Flow executor, passing requests to Stage Views"""
@@ -126,7 +170,11 @@ class FlowExecutorView(APIView):
@extend_schema(
responses={
- 200: Challenge(),
+ 200: PolymorphicProxySerializer(
+ component_name="FlowChallengeRequest",
+ serializers=challenge_types(),
+ resource_type_field_name="component",
+ ),
404: OpenApiResponse(
description="No Token found"
), # This error can be raised by the email stage
@@ -159,8 +207,18 @@ class FlowExecutorView(APIView):
return to_stage_response(request, FlowErrorResponse(request, exc))
@extend_schema(
- responses={200: Challenge()},
- request=OpenApiTypes.OBJECT,
+ responses={
+ 200: PolymorphicProxySerializer(
+ component_name="FlowChallengeRequest",
+ serializers=challenge_types(),
+ resource_type_field_name="component",
+ ),
+ },
+ request=PolymorphicProxySerializer(
+ component_name="FlowChallengeResponse",
+ serializers=challenge_response_types(),
+ resource_type_field_name="component",
+ ),
parameters=[
OpenApiParameter(
name="query",
@@ -219,7 +277,7 @@ class FlowExecutorView(APIView):
if self.plan.stages:
self._logger.debug(
"f(exec): Continuing with next stage",
- reamining=len(self.plan.stages),
+ remaining=len(self.plan.stages),
)
kwargs = self.kwargs
kwargs.update({"flow_slug": self.flow.slug})
diff --git a/authentik/outposts/channels.py b/authentik/outposts/channels.py
index 3e46dceed..f4794de12 100644
--- a/authentik/outposts/channels.py
+++ b/authentik/outposts/channels.py
@@ -50,7 +50,7 @@ class WebsocketMessage:
class OutpostConsumer(AuthJsonConsumer):
"""Handler for Outposts that connect over websockets for health checks and live updates"""
- outpost: Outpost
+ outpost: Optional[Outpost] = None
last_uid: Optional[str] = None
@@ -95,6 +95,9 @@ class OutpostConsumer(AuthJsonConsumer):
uid = msg.args.get("uuid", self.channel_name)
self.last_uid = uid
+ if not self.outpost:
+ raise DenyConnection()
+
state = OutpostState.for_instance_uid(self.outpost, uid)
if self.channel_name not in state.channel_ids:
state.channel_ids.append(self.channel_name)
diff --git a/authentik/policies/engine.py b/authentik/policies/engine.py
index 99f27b1d5..1377d8228 100644
--- a/authentik/policies/engine.py
+++ b/authentik/policies/engine.py
@@ -25,7 +25,7 @@ CURRENT_PROCESS = current_process()
GAUGE_POLICIES_CACHED = UpdatingGauge(
"authentik_policies_cached",
"Cached Policies",
- update_func=lambda: len(cache.keys("policy_*")),
+ update_func=lambda: len(cache.keys("policy_*") or []),
)
HIST_POLICIES_BUILD_TIME = Histogram(
"authentik_policies_build_time",
diff --git a/authentik/providers/oauth2/tests/test_authorize.py b/authentik/providers/oauth2/tests/test_authorize.py
index 2c0d03955..b78d674d2 100644
--- a/authentik/providers/oauth2/tests/test_authorize.py
+++ b/authentik/providers/oauth2/tests/test_authorize.py
@@ -194,6 +194,7 @@ class TestAuthorize(OAuthTestCase):
self.assertJSONEqual(
force_str(response.content),
{
+ "component": "xak-flow-redirect",
"type": ChallengeTypes.REDIRECT.value,
"to": f"foo://localhost?code={code.code}&state={state}",
},
@@ -232,6 +233,7 @@ class TestAuthorize(OAuthTestCase):
self.assertJSONEqual(
force_str(response.content),
{
+ "component": "xak-flow-redirect",
"type": ChallengeTypes.REDIRECT.value,
"to": (
f"http://localhost#access_token={token.access_token}"
diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py
index 75f5135dd..0771c3313 100644
--- a/authentik/providers/oauth2/views/userinfo.py
+++ b/authentik/providers/oauth2/views/userinfo.py
@@ -3,7 +3,6 @@ from typing import Any, Optional
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseBadRequest
-from django.utils.translation import gettext_lazy as _
from django.views import View
from structlog.stdlib import get_logger
@@ -38,14 +37,14 @@ class UserInfoView(View):
# GitHub Compatibility Scopes are handeled differently, since they required custom paths
# Hence they don't exist as Scope objects
github_scope_map = {
- SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"),
- SCOPE_GITHUB_USER_READ: _(
+ SCOPE_GITHUB_USER: ("GitHub Compatibility: Access your User Information"),
+ SCOPE_GITHUB_USER_READ: (
"GitHub Compatibility: Access your User Information"
),
- SCOPE_GITHUB_USER_EMAIL: _(
+ SCOPE_GITHUB_USER_EMAIL: (
"GitHub Compatibility: Access you Email addresses"
),
- SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"),
+ SCOPE_GITHUB_ORG_READ: ("GitHub Compatibility: Access your Groups"),
}
for scope in scopes:
if scope in github_scope_map:
diff --git a/authentik/providers/saml/views/flows.py b/authentik/providers/saml/views/flows.py
index e6ebb368e..803ff6d19 100644
--- a/authentik/providers/saml/views/flows.py
+++ b/authentik/providers/saml/views/flows.py
@@ -34,6 +34,13 @@ class AutosubmitChallenge(Challenge):
url = CharField()
attrs = DictField(child=CharField())
+ component = CharField(default="ak-stage-autosubmit")
+
+
+class AutoSubmitChallengeResponse(ChallengeResponse):
+ """Pseudo class for autosubmit response"""
+
+ component = CharField(default="ak-stage-autosubmit")
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
@@ -42,6 +49,8 @@ class SAMLFlowFinalView(ChallengeStageView):
and redirects to the SP (if REDIRECT is configured) or shows an auto-submit element
(if POST is configured)."""
+ response_class = AutoSubmitChallengeResponse
+
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
provider: SAMLProvider = get_object_or_404(
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
index 3b1e92be2..86ebb8656 100644
--- a/authentik/root/settings.py
+++ b/authentik/root/settings.py
@@ -109,6 +109,7 @@ INSTALLED_APPS = [
"authentik.sources.oauth",
"authentik.sources.plex",
"authentik.sources.saml",
+ "authentik.stages.authenticator_duo",
"authentik.stages.authenticator_static",
"authentik.stages.authenticator_totp",
"authentik.stages.authenticator_validate",
diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py
index 9953cd290..fe215b467 100644
--- a/authentik/sources/plex/models.py
+++ b/authentik/sources/plex/models.py
@@ -8,7 +8,7 @@ from rest_framework.serializers import BaseSerializer
from authentik.core.models import Source, UserSourceConnection
from authentik.core.types import UILoginButton
-from authentik.flows.challenge import Challenge, ChallengeTypes
+from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.providers.oauth2.generators import generate_client_id
@@ -17,6 +17,13 @@ class PlexAuthenticationChallenge(Challenge):
client_id = CharField()
slug = CharField()
+ component = CharField(default="ak-flow-sources-plex")
+
+
+class PlexAuthenticationChallengeResponse(ChallengeResponse):
+ """Pseudo class for plex response"""
+
+ component = CharField(default="ak-flow-sources-plex")
class PlexSource(Source):
diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py
index afd16b38a..2685e3df4 100644
--- a/authentik/sources/saml/views.py
+++ b/authentik/sources/saml/views.py
@@ -8,7 +8,6 @@ from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
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 structlog.stdlib import get_logger
@@ -134,10 +133,8 @@ class InitiateView(View):
return bad_request_message(request, str(exc))
injected_stages = []
plan_kwargs = {
- PLAN_CONTEXT_TITLE: _("Redirecting to %(app)s..." % {"app": source.name}),
- PLAN_CONTEXT_CONSENT_TITLE: _(
- "Redirecting to %(app)s..." % {"app": source.name}
- ),
+ PLAN_CONTEXT_TITLE: f"Redirecting to {source.name}...",
+ PLAN_CONTEXT_CONSENT_TITLE: f"Redirecting to {source.name}...",
PLAN_CONTEXT_ATTRS: {
"SAMLRequest": saml_request,
"RelayState": relay_state,
diff --git a/authentik/stages/authenticator_duo/__init__.py b/authentik/stages/authenticator_duo/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/authentik/stages/authenticator_duo/api.py b/authentik/stages/authenticator_duo/api.py
new file mode 100644
index 000000000..fe69a1ace
--- /dev/null
+++ b/authentik/stages/authenticator_duo/api.py
@@ -0,0 +1,103 @@
+"""AuthenticatorDuoStage API Views"""
+from django_filters.rest_framework.backends import DjangoFilterBackend
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiResponse, extend_schema
+from rest_framework import mixins
+from rest_framework.decorators import action
+from rest_framework.filters import OrderingFilter, SearchFilter
+from rest_framework.permissions import IsAdminUser
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ModelSerializer
+from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet
+
+from authentik.api.authorization import OwnerFilter, OwnerPermissions
+from authentik.flows.api.stages import StageSerializer
+from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
+from authentik.stages.authenticator_duo.stage import (
+ SESSION_KEY_DUO_ACTIVATION_CODE,
+ SESSION_KEY_DUO_USER_ID,
+)
+
+
+class AuthenticatorDuoStageSerializer(StageSerializer):
+ """AuthenticatorDuoStage Serializer"""
+
+ class Meta:
+
+ model = AuthenticatorDuoStage
+ fields = StageSerializer.Meta.fields + [
+ "configure_flow",
+ "client_id",
+ "client_secret",
+ "api_hostname",
+ ]
+ extra_kwargs = {
+ "client_secret": {"write_only": True},
+ }
+
+
+class AuthenticatorDuoStageViewSet(ModelViewSet):
+ """AuthenticatorDuoStage Viewset"""
+
+ queryset = AuthenticatorDuoStage.objects.all()
+ serializer_class = AuthenticatorDuoStageSerializer
+
+ @extend_schema(
+ request=OpenApiTypes.NONE,
+ responses={
+ 204: OpenApiResponse(description="Enrollment successful"),
+ 420: OpenApiResponse(description="Enrollment pending/failed"),
+ },
+ )
+ @action(methods=["POST"], detail=True, permission_classes=[])
+ # pylint: disable=invalid-name,unused-argument
+ def enrollment_status(self, request: Request, pk: str) -> Response:
+ """Check enrollment status of user details in current session"""
+ stage: AuthenticatorDuoStage = self.get_object()
+ client = stage.client
+ user_id = self.request.session.get(SESSION_KEY_DUO_USER_ID)
+ activation_code = self.request.session.get(SESSION_KEY_DUO_ACTIVATION_CODE)
+ status = client.enroll_status(user_id, activation_code)
+ if status == "success":
+ return Response(status=204)
+ return Response(status=420)
+
+
+class DuoDeviceSerializer(ModelSerializer):
+ """Serializer for Duo authenticator devices"""
+
+ class Meta:
+
+ model = DuoDevice
+ fields = ["pk", "name"]
+ depth = 2
+
+
+class DuoDeviceViewSet(
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+ mixins.DestroyModelMixin,
+ mixins.ListModelMixin,
+ GenericViewSet,
+):
+ """Viewset for Duo authenticator devices"""
+
+ queryset = DuoDevice.objects.all()
+ serializer_class = DuoDeviceSerializer
+ search_fields = ["name"]
+ filterset_fields = ["name"]
+ ordering = ["name"]
+ permission_classes = [OwnerPermissions]
+ filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
+
+
+class DuoAdminDeviceViewSet(ReadOnlyModelViewSet):
+ """Viewset for Duo authenticator devices (for admins)"""
+
+ permission_classes = [IsAdminUser]
+ queryset = DuoDevice.objects.all()
+ serializer_class = DuoDeviceSerializer
+ search_fields = ["name"]
+ filterset_fields = ["name"]
+ ordering = ["name"]
diff --git a/authentik/stages/authenticator_duo/apps.py b/authentik/stages/authenticator_duo/apps.py
new file mode 100644
index 000000000..a97979865
--- /dev/null
+++ b/authentik/stages/authenticator_duo/apps.py
@@ -0,0 +1,10 @@
+"""authentik duo app config"""
+from django.apps import AppConfig
+
+
+class AuthentikStageAuthenticatorDuoConfig(AppConfig):
+ """authentik duo config"""
+
+ name = "authentik.stages.authenticator_duo"
+ label = "authentik_stages_authenticator_duo"
+ verbose_name = "authentik Stages.Authenticator.Duo"
diff --git a/authentik/stages/authenticator_duo/migrations/0001_initial.py b/authentik/stages/authenticator_duo/migrations/0001_initial.py
new file mode 100644
index 000000000..89fd69207
--- /dev/null
+++ b/authentik/stages/authenticator_duo/migrations/0001_initial.py
@@ -0,0 +1,98 @@
+# Generated by Django 3.2.3 on 2021-05-23 20:28
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0018_oob_flows"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AuthenticatorDuoStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.stage",
+ ),
+ ),
+ ("client_id", models.TextField()),
+ ("client_secret", models.TextField()),
+ ("api_hostname", models.TextField()),
+ (
+ "configure_flow",
+ models.ForeignKey(
+ blank=True,
+ help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_flows.flow",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Duo Authenticator Setup Stage",
+ "verbose_name_plural": "Duo Authenticator Setup Stages",
+ },
+ bases=("authentik_flows.stage", models.Model),
+ ),
+ migrations.CreateModel(
+ name="DuoDevice",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ help_text="The human-readable name of this device.",
+ max_length=64,
+ ),
+ ),
+ (
+ "confirmed",
+ models.BooleanField(
+ default=True, help_text="Is this device ready for use?"
+ ),
+ ),
+ ("duo_user_id", models.TextField()),
+ (
+ "stage",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="authentik_stages_authenticator_duo.authenticatorduostage",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Duo Device",
+ "verbose_name_plural": "Duo Devices",
+ },
+ ),
+ ]
diff --git a/authentik/stages/authenticator_duo/migrations/__init__.py b/authentik/stages/authenticator_duo/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/authentik/stages/authenticator_duo/models.py b/authentik/stages/authenticator_duo/models.py
new file mode 100644
index 000000000..7edd1bda5
--- /dev/null
+++ b/authentik/stages/authenticator_duo/models.py
@@ -0,0 +1,86 @@
+"""Duo stage"""
+from typing import Optional, Type
+
+from django.contrib.auth import get_user_model
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from django_otp.models import Device
+from duo_client.auth import Auth
+from rest_framework.serializers import BaseSerializer
+
+from authentik import __version__
+from authentik.core.types import UserSettingSerializer
+from authentik.flows.models import ConfigurableStage, Stage
+
+
+class AuthenticatorDuoStage(ConfigurableStage, Stage):
+ """Setup Duo authenticator devices"""
+
+ client_id = models.TextField()
+ client_secret = models.TextField()
+ api_hostname = models.TextField()
+
+ @property
+ def serializer(self) -> BaseSerializer:
+ from authentik.stages.authenticator_duo.api import (
+ AuthenticatorDuoStageSerializer,
+ )
+
+ return AuthenticatorDuoStageSerializer
+
+ @property
+ def type(self) -> Type[View]:
+ from authentik.stages.authenticator_duo.stage import AuthenticatorDuoStageView
+
+ return AuthenticatorDuoStageView
+
+ @property
+ def client(self) -> Auth:
+ """Get an API Client to talk to duo"""
+ client = Auth(
+ self.client_id,
+ self.client_secret,
+ self.api_hostname,
+ user_agent=f"authentik {__version__}",
+ )
+ return client
+
+ @property
+ def component(self) -> str:
+ return "ak-stage-authenticator-duo-form"
+
+ @property
+ def ui_user_settings(self) -> Optional[UserSettingSerializer]:
+ return UserSettingSerializer(
+ data={
+ "title": str(self._meta.verbose_name),
+ "component": "ak-user-settings-authenticator-duo",
+ }
+ )
+
+ def __str__(self) -> str:
+ return f"Duo Authenticator Setup Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Duo Authenticator Setup Stage")
+ verbose_name_plural = _("Duo Authenticator Setup Stages")
+
+
+class DuoDevice(Device):
+ """Duo Device for a single user"""
+
+ user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
+
+ # Connect to the stage to when validating access we know the API Credentials
+ stage = models.ForeignKey(AuthenticatorDuoStage, on_delete=models.CASCADE)
+ duo_user_id = models.TextField()
+
+ def __str__(self):
+ return self.name or str(self.user)
+
+ class Meta:
+
+ verbose_name = _("Duo Device")
+ verbose_name_plural = _("Duo Devices")
diff --git a/authentik/stages/authenticator_duo/stage.py b/authentik/stages/authenticator_duo/stage.py
new file mode 100644
index 000000000..999f5d82c
--- /dev/null
+++ b/authentik/stages/authenticator_duo/stage.py
@@ -0,0 +1,86 @@
+"""Duo stage"""
+from django.http import HttpRequest, HttpResponse
+from rest_framework.fields import CharField
+from structlog.stdlib import get_logger
+
+from authentik.flows.challenge import (
+ Challenge,
+ ChallengeResponse,
+ ChallengeTypes,
+ WithUserInfoChallenge,
+)
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.stage import ChallengeStageView
+from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
+
+LOGGER = get_logger()
+
+SESSION_KEY_DUO_USER_ID = "authentik_stages_authenticator_duo_user_id"
+SESSION_KEY_DUO_ACTIVATION_CODE = "authentik_stages_authenticator_duo_activation_code"
+
+
+class AuthenticatorDuoChallenge(WithUserInfoChallenge):
+ """Duo Challenge"""
+
+ activation_barcode = CharField()
+ activation_code = CharField()
+ stage_uuid = CharField()
+ component = CharField(default="ak-stage-authenticator-duo")
+
+
+class AuthenticatorDuoChallengeResponse(ChallengeResponse):
+ """Pseudo class for duo response"""
+
+ component = CharField(default="ak-stage-authenticator-duo")
+
+
+class AuthenticatorDuoStageView(ChallengeStageView):
+ """Duo stage"""
+
+ response_class = AuthenticatorDuoChallengeResponse
+
+ def get_challenge(self, *args, **kwargs) -> Challenge:
+ user = self.get_pending_user()
+ stage: AuthenticatorDuoStage = self.executor.current_stage
+ enroll = stage.client.enroll(user.username)
+ user_id = enroll["user_id"]
+ self.request.session[SESSION_KEY_DUO_USER_ID] = user_id
+ self.request.session[SESSION_KEY_DUO_ACTIVATION_CODE] = enroll[
+ "activation_code"
+ ]
+ return AuthenticatorDuoChallenge(
+ data={
+ "type": ChallengeTypes.NATIVE.value,
+ "activation_barcode": enroll["activation_barcode"],
+ "activation_code": enroll["activation_code"],
+ "stage_uuid": stage.stage_uuid,
+ }
+ )
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
+ if not user:
+ LOGGER.debug("No pending user, continuing")
+ return self.executor.stage_ok()
+ return super().get(request, *args, **kwargs)
+
+ def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
+ # Duo Challenge has already been validated
+ stage: AuthenticatorDuoStage = self.executor.current_stage
+ user_id = self.request.session.get(SESSION_KEY_DUO_USER_ID)
+ activation_code = self.request.session.get(SESSION_KEY_DUO_ACTIVATION_CODE)
+ enroll_status = stage.client.enroll_status(user_id, activation_code)
+ if enroll_status != "success":
+ return HttpResponse(status=420)
+ existing_device = DuoDevice.objects.filter(duo_user_id=user_id).first()
+ self.request.session.pop(SESSION_KEY_DUO_USER_ID)
+ self.request.session.pop(SESSION_KEY_DUO_ACTIVATION_CODE)
+ if not existing_device:
+ DuoDevice.objects.create(
+ user=self.get_pending_user(), duo_user_id=user_id, stage=stage
+ )
+ else:
+ return self.executor.stage_invalid(
+ "Device with Credential ID already exists."
+ )
+ return self.executor.stage_ok()
diff --git a/authentik/stages/authenticator_static/stage.py b/authentik/stages/authenticator_static/stage.py
index 6cab085c5..5f40a10e6 100644
--- a/authentik/stages/authenticator_static/stage.py
+++ b/authentik/stages/authenticator_static/stage.py
@@ -22,17 +22,25 @@ class AuthenticatorStaticChallenge(WithUserInfoChallenge):
"""Static authenticator challenge"""
codes = ListField(child=CharField())
+ component = CharField(default="ak-stage-authenticator-static")
+
+
+class AuthenticatorStaticChallengeResponse(ChallengeResponse):
+ """Pseudo class for static response"""
+
+ component = CharField(default="ak-stage-authenticator-static")
class AuthenticatorStaticStageView(ChallengeStageView):
"""Static OTP Setup stage"""
+ response_class = AuthenticatorStaticChallengeResponse
+
def get_challenge(self, *args, **kwargs) -> AuthenticatorStaticChallenge:
tokens: list[StaticToken] = self.request.session[SESSION_STATIC_TOKENS]
return AuthenticatorStaticChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-authenticator-static",
"codes": [token.token for token in tokens],
}
)
diff --git a/authentik/stages/authenticator_totp/stage.py b/authentik/stages/authenticator_totp/stage.py
index 84adbd398..9e5bb8cbb 100644
--- a/authentik/stages/authenticator_totp/stage.py
+++ b/authentik/stages/authenticator_totp/stage.py
@@ -25,6 +25,7 @@ class AuthenticatorTOTPChallenge(WithUserInfoChallenge):
"""TOTP Setup challenge"""
config_url = CharField()
+ component = CharField(default="ak-stage-authenticator-totp")
class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
@@ -33,6 +34,7 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
device: TOTPDevice
code = IntegerField()
+ component = CharField(default="ak-stage-authenticator-totp")
def validate_code(self, code: int) -> int:
"""Validate totp code"""
@@ -52,7 +54,6 @@ class AuthenticatorTOTPStageView(ChallengeStageView):
return AuthenticatorTOTPChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-authenticator-totp",
"config_url": device.config_url,
}
)
diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py
index 92771a06d..bb592133c 100644
--- a/authentik/stages/authenticator_validate/challenge.py
+++ b/authentik/stages/authenticator_validate/challenge.py
@@ -1,12 +1,13 @@
"""Validation stage challenge checking"""
from django.http import HttpRequest
+from django.http.response import Http404
+from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django_otp import match_token
from django_otp.models import Device
-from django_otp.plugins.otp_static.models import StaticDevice
-from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.fields import CharField, JSONField
from rest_framework.serializers import ValidationError
+from structlog.stdlib import get_logger
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
from webauthn.webauthn import (
AuthenticationRejectedException,
@@ -16,9 +17,13 @@ from webauthn.webauthn import (
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User
+from authentik.lib.utils.http import get_client_ip
+from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge, get_origin
+LOGGER = get_logger()
+
class DeviceChallenge(PassiveSerializer):
"""Single device challenge"""
@@ -30,10 +35,10 @@ class DeviceChallenge(PassiveSerializer):
def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
"""Generate challenge for a single device"""
- if isinstance(device, (TOTPDevice, StaticDevice)):
- # Code-based challenges have no hints
- return {}
- return get_webauthn_challenge(request, device)
+ if isinstance(device, WebAuthnDevice):
+ return get_webauthn_challenge(request, device)
+ # Code-based challenges have no hints
+ return {}
def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict:
@@ -111,3 +116,24 @@ def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) ->
device.set_sign_count(sign_count)
return data
+
+
+def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> int:
+ """Duo authentication"""
+ device = get_object_or_404(DuoDevice, pk=device_pk)
+ if device.user != user:
+ LOGGER.warning("device mismatch")
+ raise Http404
+ stage: AuthenticatorDuoStage = device.stage
+ response = stage.client.auth(
+ "auto",
+ user_id=device.duo_user_id,
+ ipaddr=get_client_ip(request),
+ type="authentik Login request",
+ display_username=user.username,
+ device="auto",
+ )
+ # {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'}
+ if response["result"] == "deny":
+ raise ValidationError("Duo denied access")
+ return device_pk
diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py
index 321d51128..a9276babd 100644
--- a/authentik/stages/authenticator_validate/models.py
+++ b/authentik/stages/authenticator_validate/models.py
@@ -17,6 +17,7 @@ class DeviceClasses(models.TextChoices):
STATIC = "static"
TOTP = "totp", _("TOTP")
WEBAUTHN = "webauthn", _("WebAuthn")
+ DUO = "duo", _("Duo")
def default_device_classes() -> list:
diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py
index c900437e0..15888aa5f 100644
--- a/authentik/stages/authenticator_validate/stage.py
+++ b/authentik/stages/authenticator_validate/stage.py
@@ -1,7 +1,7 @@
"""Authenticator Validation"""
from django.http import HttpRequest, HttpResponse
from django_otp import devices_for_user
-from rest_framework.fields import CharField, JSONField, ListField
+from rest_framework.fields import CharField, IntegerField, JSONField, ListField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
@@ -17,6 +17,7 @@ from authentik.stages.authenticator_validate.challenge import (
DeviceChallenge,
get_challenge_for_device,
validate_challenge_code,
+ validate_challenge_duo,
validate_challenge_webauthn,
)
from authentik.stages.authenticator_validate.models import (
@@ -29,28 +30,31 @@ LOGGER = get_logger()
PER_DEVICE_CLASSES = [DeviceClasses.WEBAUTHN]
-class AuthenticatorChallenge(WithUserInfoChallenge):
+class AuthenticatorValidationChallenge(WithUserInfoChallenge):
"""Authenticator challenge"""
device_challenges = ListField(child=DeviceChallenge())
+ component = CharField(default="ak-stage-authenticator-validate")
-class AuthenticatorChallengeResponse(ChallengeResponse):
+class AuthenticatorValidationChallengeResponse(ChallengeResponse):
"""Challenge used for Code-based and WebAuthn authenticators"""
code = CharField(required=False)
webauthn = JSONField(required=False)
+ duo = IntegerField(required=False)
+ component = CharField(default="ak-stage-authenticator-validate")
- def validate_code(self, code: str) -> str:
- """Validate code-based response, raise error if code isn't allowed"""
+ def _challenge_allowed(self, classes: list):
device_challenges: list[dict] = self.stage.request.session.get(
"device_challenges"
)
- if not any(
- x["device_class"] in (DeviceClasses.TOTP, DeviceClasses.STATIC)
- for x in device_challenges
- ):
- raise ValidationError("Got code but no compatible device class allowed")
+ if not any(x["device_class"] in classes for x in device_challenges):
+ raise ValidationError("No compatible device class allowed")
+
+ def validate_code(self, code: str) -> str:
+ """Validate code-based response, raise error if code isn't allowed"""
+ self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC])
return validate_challenge_code(
code, self.stage.request, self.stage.get_pending_user()
)
@@ -58,21 +62,22 @@ class AuthenticatorChallengeResponse(ChallengeResponse):
def validate_webauthn(self, webauthn: dict) -> dict:
"""Validate webauthn response, raise error if webauthn wasn't allowed
or response is invalid"""
- device_challenges: list[dict] = self.stage.request.session.get(
- "device_challenges"
- )
- if not any(
- x["device_class"] in (DeviceClasses.WEBAUTHN) for x in device_challenges
- ):
- raise ValidationError("Got webauthn but no compatible device class allowed")
+ self._challenge_allowed([DeviceClasses.WEBAUTHN])
return validate_challenge_webauthn(
webauthn, self.stage.request, self.stage.get_pending_user()
)
+ def validate_duo(self, duo: int) -> int:
+ """Initiate Duo authentication"""
+ self._challenge_allowed([DeviceClasses.DUO])
+ return validate_challenge_duo(
+ duo, self.stage.request, self.stage.get_pending_user()
+ )
+
def validate(self, data: dict):
# Checking if the given data is from a valid device class is done above
# Here we only check if the any data was sent at all
- if "code" not in data and "webauthn" not in data:
+ if "code" not in data and "webauthn" not in data and "duo" not in data:
raise ValidationError("Empty response")
return data
@@ -80,7 +85,7 @@ class AuthenticatorChallengeResponse(ChallengeResponse):
class AuthenticatorValidateStageView(ChallengeStageView):
"""Authenticator Validation"""
- response_class = AuthenticatorChallengeResponse
+ response_class = AuthenticatorValidationChallengeResponse
def get_device_challenges(self) -> list[dict]:
"""Get a list of all device challenges applicable for the current stage"""
@@ -141,19 +146,18 @@ class AuthenticatorValidateStageView(ChallengeStageView):
return self.executor.stage_ok()
return super().get(request, *args, **kwargs)
- def get_challenge(self) -> AuthenticatorChallenge:
+ def get_challenge(self) -> AuthenticatorValidationChallenge:
challenges = self.request.session["device_challenges"]
- return AuthenticatorChallenge(
+ return AuthenticatorValidationChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-authenticator-validate",
"device_challenges": challenges,
}
)
# pylint: disable=unused-argument
def challenge_valid(
- self, challenge: AuthenticatorChallengeResponse
+ self, challenge: AuthenticatorValidationChallengeResponse
) -> HttpResponse:
# All validation is done by the serializer
return self.executor.stage_ok()
diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py
index 8da09c44a..12c5e888d 100644
--- a/authentik/stages/authenticator_webauthn/stage.py
+++ b/authentik/stages/authenticator_webauthn/stage.py
@@ -2,7 +2,7 @@
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
-from rest_framework.fields import JSONField
+from rest_framework.fields import CharField, JSONField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
from webauthn.webauthn import (
@@ -13,7 +13,12 @@ from webauthn.webauthn import (
)
from authentik.core.models import User
-from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
+from authentik.flows.challenge import (
+ Challenge,
+ ChallengeResponse,
+ ChallengeTypes,
+ WithUserInfoChallenge,
+)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
@@ -32,16 +37,18 @@ SESSION_KEY_WEBAUTHN_AUTHENTICATED = (
)
-class AuthenticatorWebAuthnChallenge(Challenge):
+class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge):
"""WebAuthn Challenge"""
registration = JSONField()
+ component = CharField(default="ak-stage-authenticator-webauthn")
class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
"""WebAuthn Challenge response"""
response = JSONField()
+ component = CharField(default="ak-stage-authenticator-webauthn")
request: HttpRequest
user: User
@@ -129,7 +136,6 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
return AuthenticatorWebAuthnChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-authenticator-webauthn",
"registration": registration_dict,
}
)
diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py
index 98db7728a..1bc0d8492 100644
--- a/authentik/stages/captcha/stage.py
+++ b/authentik/stages/captcha/stage.py
@@ -21,12 +21,14 @@ class CaptchaChallenge(WithUserInfoChallenge):
"""Site public key"""
site_key = CharField()
+ component = CharField(default="ak-stage-captcha")
class CaptchaChallengeResponse(ChallengeResponse):
"""Validate captcha token"""
token = CharField()
+ component = CharField(default="ak-stage-captcha")
def validate_token(self, token: str) -> str:
"""Validate captcha token"""
@@ -64,7 +66,6 @@ class CaptchaStageView(ChallengeStageView):
return CaptchaChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-captcha",
"site_key": self.executor.current_stage.public_key,
}
)
diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py
index b6b5f5bae..3579438e5 100644
--- a/authentik/stages/captcha/tests.py
+++ b/authentik/stages/captcha/tests.py
@@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import User
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import FlowPlan
@@ -54,5 +55,9 @@ class TestCaptchaStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py
index aba15031b..8227ebf91 100644
--- a/authentik/stages/consent/stage.py
+++ b/authentik/stages/consent/stage.py
@@ -25,11 +25,14 @@ class ConsentChallenge(WithUserInfoChallenge):
header_text = CharField()
permissions = PermissionSerializer(many=True)
+ component = CharField(default="ak-stage-consent")
class ConsentChallengeResponse(ChallengeResponse):
"""Consent challenge response, any valid response request is valid"""
+ component = CharField(default="ak-stage-consent")
+
class ConsentStageView(ChallengeStageView):
"""Simple consent checker."""
@@ -37,24 +40,19 @@ class ConsentStageView(ChallengeStageView):
response_class = ConsentChallengeResponse
def get_challenge(self) -> Challenge:
- challenge = ConsentChallenge(
- data={
- "type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-consent",
- }
- )
+ data = {
+ "type": ChallengeTypes.NATIVE.value,
+ "permissions": self.executor.plan.context.get(
+ PLAN_CONTEXT_CONSENT_PERMISSIONS, []
+ ),
+ }
if PLAN_CONTEXT_CONSENT_TITLE in self.executor.plan.context:
- challenge.initial_data["title"] = self.executor.plan.context[
- PLAN_CONTEXT_CONSENT_TITLE
- ]
+ data["title"] = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TITLE]
if PLAN_CONTEXT_CONSENT_HEADER in self.executor.plan.context:
- challenge.initial_data["header_text"] = self.executor.plan.context[
+ data["header_text"] = self.executor.plan.context[
PLAN_CONTEXT_CONSENT_HEADER
]
- if PLAN_CONTEXT_CONSENT_PERMISSIONS in self.executor.plan.context:
- challenge.initial_data["permissions"] = self.executor.plan.context[
- PLAN_CONTEXT_CONSENT_PERMISSIONS
- ]
+ challenge = ConsentChallenge(data=data)
return challenge
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
diff --git a/authentik/stages/consent/tests.py b/authentik/stages/consent/tests.py
index bdd8bc0a9..d395af6a0 100644
--- a/authentik/stages/consent/tests.py
+++ b/authentik/stages/consent/tests.py
@@ -7,6 +7,7 @@ from django.utils.encoding import force_str
from authentik.core.models import Application, User
from authentik.core.tasks import clean_expired_models
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlan
@@ -51,7 +52,11 @@ class TestConsentStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
self.assertFalse(UserConsent.objects.filter(user=self.user).exists())
@@ -82,7 +87,11 @@ class TestConsentStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
self.assertTrue(
UserConsent.objects.filter(
@@ -119,7 +128,11 @@ class TestConsentStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
self.assertTrue(
UserConsent.objects.filter(
diff --git a/authentik/stages/dummy/stage.py b/authentik/stages/dummy/stage.py
index 3ecef6f65..3732c71de 100644
--- a/authentik/stages/dummy/stage.py
+++ b/authentik/stages/dummy/stage.py
@@ -1,5 +1,6 @@
"""authentik multi-stage authentication engine"""
from django.http.response import HttpResponse
+from rest_framework.fields import CharField
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.stage import ChallengeStageView
@@ -8,10 +9,14 @@ from authentik.flows.stage import ChallengeStageView
class DummyChallenge(Challenge):
"""Dummy challenge"""
+ component = CharField(default="ak-stage-dummy")
+
class DummyChallengeResponse(ChallengeResponse):
"""Dummy challenge response"""
+ component = CharField(default="ak-stage-dummy")
+
class DummyStageView(ChallengeStageView):
"""Dummy stage for testing with multiple stages"""
@@ -25,7 +30,6 @@ class DummyStageView(ChallengeStageView):
return DummyChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-dummy",
"title": self.executor.current_stage.name,
}
)
diff --git a/authentik/stages/dummy/tests.py b/authentik/stages/dummy/tests.py
index 1bfdef559..0b173feff 100644
--- a/authentik/stages/dummy/tests.py
+++ b/authentik/stages/dummy/tests.py
@@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import User
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.stages.dummy.models import DummyStage
@@ -45,5 +46,9 @@ class TestDummyStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
diff --git a/authentik/stages/email/stage.py b/authentik/stages/email/stage.py
index 7c4c55831..ae672f1b0 100644
--- a/authentik/stages/email/stage.py
+++ b/authentik/stages/email/stage.py
@@ -8,6 +8,7 @@ from django.urls import reverse
from django.utils.http import urlencode
from django.utils.timezone import now
from django.utils.translation import gettext as _
+from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
@@ -28,11 +29,15 @@ PLAN_CONTEXT_EMAIL_SENT = "email_sent"
class EmailChallenge(Challenge):
"""Email challenge"""
+ component = CharField(default="ak-stage-email")
+
class EmailChallengeResponse(ChallengeResponse):
"""Email challenge resposen. No fields. This challenge is
always declared invalid to give the user a chance to retry"""
+ component = CharField(default="ak-stage-email")
+
def validate(self, data):
raise ValidationError("")
@@ -97,7 +102,6 @@ class EmailStageView(ChallengeStageView):
challenge = EmailChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-email",
"title": "Email sent.",
}
)
diff --git a/authentik/stages/email/tests/test_stage.py b/authentik/stages/email/tests/test_stage.py
index 432ee5f24..ae499b05b 100644
--- a/authentik/stages/email/tests/test_stage.py
+++ b/authentik/stages/email/tests/test_stage.py
@@ -8,6 +8,7 @@ from django.utils.encoding import force_str
from django.utils.http import urlencode
from authentik.core.models import Token, User
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
@@ -133,7 +134,11 @@ class TestEmailStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
session = self.client.session
diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py
index 625546c0f..163345e64 100644
--- a/authentik/stages/identification/stage.py
+++ b/authentik/stages/identification/stage.py
@@ -36,11 +36,15 @@ class IdentificationChallenge(Challenge):
primary_action = CharField()
sources = UILoginButtonSerializer(many=True, required=False)
+ component = CharField(default="ak-stage-identification")
+
class IdentificationChallengeResponse(ChallengeResponse):
"""Identification challenge"""
uid_field = CharField()
+ component = CharField(default="ak-stage-identification")
+
pre_user: Optional[User] = None
def validate_uid_field(self, value: str) -> str:
@@ -81,8 +85,8 @@ class IdentificationStageView(ChallengeStageView):
challenge = IdentificationChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-identification",
"primary_action": _("Log in"),
+ "component": "ak-stage-identification",
"user_fields": current_stage.user_fields,
}
)
diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py
index 64c051ddc..9a09ce5d9 100644
--- a/authentik/stages/identification/tests.py
+++ b/authentik/stages/identification/tests.py
@@ -53,7 +53,11 @@ class TestIdentificationStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
def test_invalid_with_username(self):
@@ -118,8 +122,9 @@ class TestIdentificationStage(TestCase):
"icon_url": "/static/authentik/sources/.svg",
"name": "test",
"challenge": {
+ "component": "xak-flow-redirect",
"to": "/source/oauth/login/test/",
- "type": "redirect",
+ "type": ChallengeTypes.REDIRECT.value,
},
}
],
@@ -162,8 +167,9 @@ class TestIdentificationStage(TestCase):
"sources": [
{
"challenge": {
+ "component": "xak-flow-redirect",
"to": "/source/oauth/login/test/",
- "type": "redirect",
+ "type": ChallengeTypes.REDIRECT.value,
},
"icon_url": "/static/authentik/sources/.svg",
"name": "test",
diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py
index 7e1b166c4..8d3cc3afe 100644
--- a/authentik/stages/invitation/tests.py
+++ b/authentik/stages/invitation/tests.py
@@ -89,7 +89,11 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
self.stage.continue_flow_without_invitation = False
@@ -123,7 +127,11 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
def test_with_invitation_prompt_data(self):
@@ -154,7 +162,11 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
self.assertFalse(Invitation.objects.filter(pk=invite.pk))
diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py
index 73cf9b1db..a548cc7be 100644
--- a/authentik/stages/password/stage.py
+++ b/authentik/stages/password/stage.py
@@ -63,12 +63,16 @@ class PasswordChallenge(WithUserInfoChallenge):
recovery_url = CharField(required=False)
+ component = CharField(default="ak-stage-password")
+
class PasswordChallengeResponse(ChallengeResponse):
"""Password challenge response"""
password = CharField()
+ component = CharField(default="ak-stage-password")
+
class PasswordStageView(ChallengeStageView):
"""Authentication stage which authenticates against django's AuthBackend"""
@@ -79,7 +83,6 @@ class PasswordStageView(ChallengeStageView):
challenge = PasswordChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-password",
}
)
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py
index 273c9834b..21a9a8f99 100644
--- a/authentik/stages/password/tests.py
+++ b/authentik/stages/password/tests.py
@@ -118,7 +118,11 @@ class TestPasswordStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
def test_invalid_password(self):
diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py
index 8b76e614e..6a90f66b1 100644
--- a/authentik/stages/prompt/stage.py
+++ b/authentik/stages/prompt/stage.py
@@ -26,31 +26,38 @@ LOGGER = get_logger()
PLAN_CONTEXT_PROMPT = "prompt_data"
-class PromptSerializer(PassiveSerializer):
+class StagePromptSerializer(PassiveSerializer):
"""Serializer for a single Prompt field"""
field_key = CharField()
label = CharField(allow_blank=True)
type = CharField()
required = BooleanField()
- placeholder = CharField()
+ placeholder = CharField(allow_blank=True)
order = IntegerField()
class PromptChallenge(Challenge):
"""Initial challenge being sent, define fields"""
- fields = PromptSerializer(many=True)
+ fields = StagePromptSerializer(many=True)
+ component = CharField(default="ak-stage-prompt")
-class PromptResponseChallenge(ChallengeResponse):
+class PromptChallengeResponse(ChallengeResponse):
"""Validate response, fields are dynamically created based
on the stage"""
- def __init__(self, *args, stage: PromptStage, plan: FlowPlan, **kwargs):
+ component = CharField(default="ak-stage-prompt")
+
+ def __init__(self, *args, **kwargs):
+ stage: PromptStage = kwargs.pop("stage", None)
+ plan: FlowPlan = kwargs.pop("plan", None)
super().__init__(*args, **kwargs)
self.stage = stage
self.plan = plan
+ if not self.stage:
+ return
# list() is called so we only load the fields once
fields = list(self.stage.fields.all())
for field in fields:
@@ -152,15 +159,14 @@ class ListPolicyEngine(PolicyEngine):
class PromptStageView(ChallengeStageView):
"""Prompt Stage, save form data in plan context."""
- response_class = PromptResponseChallenge
+ response_class = PromptChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
fields = list(self.executor.current_stage.fields.all().order_by("order"))
challenge = PromptChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
- "component": "ak-stage-prompt",
- "fields": [PromptSerializer(field).data for field in fields],
+ "fields": [StagePromptSerializer(field).data for field in fields],
},
)
return challenge
@@ -168,7 +174,7 @@ class PromptStageView(ChallengeStageView):
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
if not self.executor.plan:
raise ValueError
- return PromptResponseChallenge(
+ return PromptChallengeResponse(
instance=None,
data=data,
stage=self.executor.current_stage,
diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py
index 941a99142..4787ce3a6 100644
--- a/authentik/stages/prompt/tests.py
+++ b/authentik/stages/prompt/tests.py
@@ -6,13 +6,14 @@ from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import User
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import FlowPlan
from authentik.flows.views import SESSION_KEY_PLAN
from authentik.policies.expression.models import ExpressionPolicy
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
-from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptResponseChallenge
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptChallengeResponse
class TestPromptStage(TestCase):
@@ -111,7 +112,7 @@ class TestPromptStage(TestCase):
self.assertIn(prompt.label, force_str(response.content))
self.assertIn(prompt.placeholder, force_str(response.content))
- def test_valid_challenge_with_policy(self) -> PromptResponseChallenge:
+ def test_valid_challenge_with_policy(self) -> PromptChallengeResponse:
"""Test challenge_response validation"""
plan = FlowPlan(
flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
@@ -122,13 +123,13 @@ class TestPromptStage(TestCase):
)
self.stage.validation_policies.set([expr_policy])
self.stage.save()
- challenge_response = PromptResponseChallenge(
+ challenge_response = PromptChallengeResponse(
None, stage=self.stage, plan=plan, data=self.prompt_data
)
self.assertEqual(challenge_response.is_valid(), True)
return challenge_response
- def test_invalid_challenge(self) -> PromptResponseChallenge:
+ def test_invalid_challenge(self) -> PromptChallengeResponse:
"""Test challenge_response validation"""
plan = FlowPlan(
flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
@@ -139,7 +140,7 @@ class TestPromptStage(TestCase):
)
self.stage.validation_policies.set([expr_policy])
self.stage.save()
- challenge_response = PromptResponseChallenge(
+ challenge_response = PromptChallengeResponse(
None, stage=self.stage, plan=plan, data=self.prompt_data
)
self.assertEqual(challenge_response.is_valid(), False)
@@ -167,7 +168,11 @@ class TestPromptStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
# Check that valid data has been saved
diff --git a/authentik/stages/user_delete/tests.py b/authentik/stages/user_delete/tests.py
index 48d0a86b7..b3d1bcf14 100644
--- a/authentik/stages/user_delete/tests.py
+++ b/authentik/stages/user_delete/tests.py
@@ -75,7 +75,11 @@ class TestUserDeleteStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
self.assertFalse(User.objects.filter(username=self.username).exists())
diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py
index 33c921e17..003f42c40 100644
--- a/authentik/stages/user_login/tests.py
+++ b/authentik/stages/user_login/tests.py
@@ -49,7 +49,11 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
def test_expiry(self):
@@ -70,7 +74,11 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
self.assertNotEqual(list(self.client.session.keys()), [])
sleep(3)
diff --git a/authentik/stages/user_logout/tests.py b/authentik/stages/user_logout/tests.py
index 4eb464f6a..04c2d5111 100644
--- a/authentik/stages/user_logout/tests.py
+++ b/authentik/stages/user_logout/tests.py
@@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import User
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
@@ -48,5 +49,9 @@ class TestUserLogoutStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py
index 31fdac412..9bcbd9cb1 100644
--- a/authentik/stages/user_write/stage.py
+++ b/authentik/stages/user_write/stage.py
@@ -2,6 +2,7 @@
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.backends import ModelBackend
+from django.db.utils import IntegrityError
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
@@ -84,7 +85,11 @@ class UserWriteStageView(StageView):
PLAN_CONTEXT_SOURCES_CONNECTION
]
user.attributes[USER_ATTRIBUTE_SOURCES].append(connection.source.name)
- user.save()
+ try:
+ user.save()
+ except IntegrityError as exc:
+ LOGGER.warning("Failed to save user", exc=exc)
+ self.executor.stage_invalid()
user_write.send(
sender=self, request=request, user=user, data=data, created=user_created
)
diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py
index d9f3de1f4..55c217b56 100644
--- a/authentik/stages/user_write/tests.py
+++ b/authentik/stages/user_write/tests.py
@@ -60,7 +60,11 @@ class TestUserWriteStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
user_qs = User.objects.filter(
username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
@@ -97,7 +101,11 @@ class TestUserWriteStage(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
- {"to": reverse("authentik_core:root-redirect"), "type": "redirect"},
+ {
+ "component": "xak-flow-redirect",
+ "to": reverse("authentik_core:root-redirect"),
+ "type": ChallengeTypes.REDIRECT.value,
+ },
)
user_qs = User.objects.filter(
username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
diff --git a/outpost/pkg/ldap/instance_bind.go b/outpost/pkg/ldap/instance_bind.go
index 2ffd9a9da..aaa8909ef 100644
--- a/outpost/pkg/ldap/instance_bind.go
+++ b/outpost/pkg/ldap/instance_bind.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/cookiejar"
"net/url"
+ "strconv"
"strings"
"time"
@@ -52,8 +53,6 @@ func (pi *ProviderInstance) Bind(username string, bindDN, bindPW string, conn ne
// Create new http client that also sets the correct ip
config := api.NewConfiguration()
- // Carry over the bearer authentication, so that failed login attempts are attributed to the outpost
- config.DefaultHeader = pi.s.ac.Client.GetConfig().DefaultHeader
config.Host = pi.s.ac.Client.GetConfig().Host
config.Scheme = pi.s.ac.Client.GetConfig().Scheme
config.HTTPClient = &http.Client{
@@ -75,7 +74,7 @@ func (pi *ProviderInstance) Bind(username string, bindDN, bindPW string, conn ne
if !passed {
return ldap.LDAPResultInvalidCredentials, nil
}
- r, err := pi.s.ac.Client.CoreApi.CoreApplicationsCheckAccessRetrieve(context.Background(), pi.appSlug).Execute()
+ r, err := apiClient.CoreApi.CoreApplicationsCheckAccessRetrieve(context.Background(), pi.appSlug).Execute()
if r.StatusCode == 403 {
pi.log.WithField("bindDN", bindDN).Info("Access denied for user")
return ldap.LDAPResultInsufficientAccessRights, nil
@@ -86,7 +85,7 @@ func (pi *ProviderInstance) Bind(username string, bindDN, bindPW string, conn ne
}
pi.log.WithField("bindDN", bindDN).Info("User has access")
// Get user info to store in context
- userInfo, _, err := pi.s.ac.Client.CoreApi.CoreUsersMeRetrieve(context.Background()).Execute()
+ userInfo, _, err := apiClient.CoreApi.CoreUsersMeRetrieve(context.Background()).Execute()
if err != nil {
pi.log.WithField("bindDN", bindDN).WithError(err).Warning("failed to get user info")
return ldap.LDAPResultOperationsError, nil
@@ -133,48 +132,69 @@ func (pi *ProviderInstance) delayDeleteUserInfo(dn string) {
}()
}
+type ChallengeInt interface {
+ GetComponent() string
+ GetType() api.ChallengeChoices
+ GetResponseErrors() map[string][]api.ErrorDetail
+}
+
func (pi *ProviderInstance) solveFlowChallenge(bindDN string, password string, client *api.APIClient, urlParams string, depth int) (bool, error) {
- req := client.FlowsApi.FlowsExecutorGet(context.Background(), pi.flowSlug)
- req.Query(urlParams)
+ req := client.FlowsApi.FlowsExecutorGet(context.Background(), pi.flowSlug).Query(urlParams)
challenge, _, err := req.Execute()
if err != nil {
pi.log.WithError(err).Warning("Failed to get challenge")
return false, err
}
- pi.log.WithField("component", challenge.Component).WithField("type", challenge.Type).Debug("Got challenge")
- responseReq := client.FlowsApi.FlowsExecutorSolve(context.Background(), pi.flowSlug)
- responseReq.Query(urlParams)
- switch *challenge.Component {
+ ch := challenge.GetActualInstance().(ChallengeInt)
+ pi.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got challenge")
+ responseReq := client.FlowsApi.FlowsExecutorSolve(context.Background(), pi.flowSlug).Query(urlParams)
+ switch ch.GetComponent() {
case "ak-stage-identification":
- responseReq.RequestBody(map[string]interface{}{
- "uid_field": bindDN,
- })
+ responseReq = responseReq.FlowChallengeResponseRequest(api.IdentificationChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewIdentificationChallengeResponseRequest(bindDN)))
case "ak-stage-password":
- responseReq.RequestBody(map[string]interface{}{
- "password": password,
- })
+ responseReq = responseReq.FlowChallengeResponseRequest(api.PasswordChallengeResponseRequestAsFlowChallengeResponseRequest(api.NewPasswordChallengeResponseRequest(password)))
+ case "ak-stage-authenticator-validate":
+ // We only support duo as authenticator, check if that's allowed
+ var deviceChallenge *api.DeviceChallenge
+ for _, devCh := range challenge.AuthenticatorValidationChallenge.DeviceChallenges {
+ if devCh.DeviceClass == string(api.DEVICECLASSESENUM_DUO) {
+ deviceChallenge = &devCh
+ }
+ }
+ if deviceChallenge == nil {
+ return false, errors.New("got ak-stage-authenticator-validate without duo")
+ }
+ devId, err := strconv.Atoi(deviceChallenge.DeviceUid)
+ if err != nil {
+ return false, errors.New("failed to convert duo device id to int")
+ }
+ devId32 := int32(devId)
+ inner := api.NewAuthenticatorValidationChallengeResponseRequest()
+ inner.Duo = &devId32
+ responseReq = responseReq.FlowChallengeResponseRequest(api.AuthenticatorValidationChallengeResponseRequestAsFlowChallengeResponseRequest(inner))
case "ak-stage-access-denied":
return false, errors.New("got ak-stage-access-denied")
default:
- return false, fmt.Errorf("unsupported challenge type: %s", *challenge.Component)
+ return false, fmt.Errorf("unsupported challenge type: %s", ch.GetComponent())
}
response, _, err := responseReq.Execute()
- pi.log.WithField("component", response.Component).WithField("type", response.Type).Debug("Got response")
- switch *response.Component {
+ ch = response.GetActualInstance().(ChallengeInt)
+ pi.log.WithField("component", ch.GetComponent()).WithField("type", ch.GetType()).Debug("Got response")
+ switch ch.GetComponent() {
case "ak-stage-access-denied":
return false, errors.New("got ak-stage-access-denied")
}
- if response.Type == "redirect" {
+ if ch.GetType() == "redirect" {
return true, nil
}
if err != nil {
pi.log.WithError(err).Warning("Failed to submit challenge")
return false, err
}
- if len(*response.ResponseErrors) > 0 {
- for key, errs := range *response.ResponseErrors {
+ if len(ch.GetResponseErrors()) > 0 {
+ for key, errs := range ch.GetResponseErrors() {
for _, err := range errs {
- pi.log.WithField("key", key).WithField("code", err.Code).Debug(err.String)
+ pi.log.WithField("key", key).WithField("code", err.Code).WithField("msg", err.String).Warning("Flow error")
return false, nil
}
}
diff --git a/outpost/pkg/ldap/instance_search.go b/outpost/pkg/ldap/instance_search.go
index f5a4462c7..c385068a0 100644
--- a/outpost/pkg/ldap/instance_search.go
+++ b/outpost/pkg/ldap/instance_search.go
@@ -8,8 +8,15 @@ import (
"strings"
"github.com/nmcclain/ldap"
+ "goauthentik.io/outpost/api"
)
+func (pi *ProviderInstance) SearchMe(user api.User, searchReq ldap.SearchRequest, conn net.Conn) (ldap.ServerSearchResult, error) {
+ entries := make([]*ldap.Entry, 1)
+ entries[0] = pi.UserEntry(user)
+ return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil
+}
+
func (pi *ProviderInstance) Search(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (ldap.ServerSearchResult, error) {
bindDN = strings.ToLower(bindDN)
baseDN := strings.ToLower("," + pi.BaseDN)
@@ -29,14 +36,13 @@ func (pi *ProviderInstance) Search(bindDN string, searchReq ldap.SearchRequest,
pi.boundUsersMutex.RLock()
defer pi.boundUsersMutex.RUnlock()
flags, ok := pi.boundUsers[bindDN]
- pi.log.WithField("bindDN", bindDN).WithField("ok", ok).Debugf("%+v\n", flags)
if !ok {
pi.log.Debug("User info not cached")
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied")
}
if !flags.CanSearch {
- pi.log.Debug("User can't search")
- return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied")
+ pi.log.Debug("User can't search, showing info about user")
+ return pi.SearchMe(flags.UserInfo, searchReq, conn)
}
switch filterEntity {
@@ -49,24 +55,7 @@ func (pi *ProviderInstance) Search(bindDN string, searchReq ldap.SearchRequest,
}
pi.log.WithField("count", len(groups.Results)).Trace("Got results from API")
for _, g := range groups.Results {
- attrs := []*ldap.EntryAttribute{
- {
- Name: "cn",
- Values: []string{g.Name},
- },
- {
- Name: "uid",
- Values: []string{string(g.Pk)},
- },
- {
- Name: "objectClass",
- Values: []string{GroupObjectClass, "goauthentik.io/ldap/group"},
- },
- }
- attrs = append(attrs, AKAttrsToLDAP(g.Attributes)...)
-
- dn := pi.GetGroupDN(g)
- entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs})
+ entries = append(entries, pi.GroupEntry(g))
}
case UserObjectClass, "":
users, _, err := pi.s.ac.Client.CoreApi.CoreUsersList(context.Background()).Execute()
@@ -74,53 +63,79 @@ func (pi *ProviderInstance) Search(bindDN string, searchReq ldap.SearchRequest,
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err)
}
for _, u := range users.Results {
- attrs := []*ldap.EntryAttribute{
- {
- Name: "cn",
- Values: []string{u.Username},
- },
- {
- Name: "uid",
- Values: []string{u.Uid},
- },
- {
- Name: "name",
- Values: []string{u.Name},
- },
- {
- Name: "displayName",
- Values: []string{u.Name},
- },
- {
- Name: "mail",
- Values: []string{*u.Email},
- },
- {
- Name: "objectClass",
- Values: []string{UserObjectClass, "organizationalPerson", "goauthentik.io/ldap/user"},
- },
- }
-
- if *u.IsActive {
- attrs = append(attrs, &ldap.EntryAttribute{Name: "accountStatus", Values: []string{"inactive"}})
- } else {
- attrs = append(attrs, &ldap.EntryAttribute{Name: "accountStatus", Values: []string{"active"}})
- }
-
- if u.IsSuperuser {
- attrs = append(attrs, &ldap.EntryAttribute{Name: "superuser", Values: []string{"inactive"}})
- } else {
- attrs = append(attrs, &ldap.EntryAttribute{Name: "superuser", Values: []string{"active"}})
- }
-
- attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: pi.GroupsForUser(u)})
-
- attrs = append(attrs, AKAttrsToLDAP(u.Attributes)...)
-
- dn := fmt.Sprintf("cn=%s,%s", u.Username, pi.UserDN)
- entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs})
+ entries = append(entries, pi.UserEntry(u))
}
}
pi.log.WithField("filter", searchReq.Filter).Debug("Search OK")
return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil
}
+
+func (pi *ProviderInstance) UserEntry(u api.User) *ldap.Entry {
+ attrs := []*ldap.EntryAttribute{
+ {
+ Name: "cn",
+ Values: []string{u.Username},
+ },
+ {
+ Name: "uid",
+ Values: []string{u.Uid},
+ },
+ {
+ Name: "name",
+ Values: []string{u.Name},
+ },
+ {
+ Name: "displayName",
+ Values: []string{u.Name},
+ },
+ {
+ Name: "mail",
+ Values: []string{*u.Email},
+ },
+ {
+ Name: "objectClass",
+ Values: []string{UserObjectClass, "organizationalPerson", "goauthentik.io/ldap/user"},
+ },
+ }
+
+ if *u.IsActive {
+ attrs = append(attrs, &ldap.EntryAttribute{Name: "accountStatus", Values: []string{"inactive"}})
+ } else {
+ attrs = append(attrs, &ldap.EntryAttribute{Name: "accountStatus", Values: []string{"active"}})
+ }
+
+ if u.IsSuperuser {
+ attrs = append(attrs, &ldap.EntryAttribute{Name: "superuser", Values: []string{"inactive"}})
+ } else {
+ attrs = append(attrs, &ldap.EntryAttribute{Name: "superuser", Values: []string{"active"}})
+ }
+
+ attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: pi.GroupsForUser(u)})
+
+ attrs = append(attrs, AKAttrsToLDAP(u.Attributes)...)
+
+ dn := fmt.Sprintf("cn=%s,%s", u.Username, pi.UserDN)
+
+ return &ldap.Entry{DN: dn, Attributes: attrs}
+}
+
+func (pi *ProviderInstance) GroupEntry(g api.Group) *ldap.Entry {
+ attrs := []*ldap.EntryAttribute{
+ {
+ Name: "cn",
+ Values: []string{g.Name},
+ },
+ {
+ Name: "uid",
+ Values: []string{string(g.Pk)},
+ },
+ {
+ Name: "objectClass",
+ Values: []string{GroupObjectClass, "goauthentik.io/ldap/group"},
+ },
+ }
+ attrs = append(attrs, AKAttrsToLDAP(g.Attributes)...)
+
+ dn := pi.GetGroupDN(g)
+ return &ldap.Entry{DN: dn, Attributes: attrs}
+}
diff --git a/outpost/pkg/ldap/utils.go b/outpost/pkg/ldap/utils.go
index 4c8dc5704..b32c20783 100644
--- a/outpost/pkg/ldap/utils.go
+++ b/outpost/pkg/ldap/utils.go
@@ -9,7 +9,8 @@ import (
func AKAttrsToLDAP(attrs interface{}) []*ldap.EntryAttribute {
attrList := []*ldap.EntryAttribute{}
- for attrKey, attrValue := range attrs.(map[string]interface{}) {
+ a := attrs.(*map[string]interface{})
+ for attrKey, attrValue := range *a {
entry := &ldap.EntryAttribute{Name: attrKey}
switch t := attrValue.(type) {
case []string:
diff --git a/schema.yml b/schema.yml
index 3b0837b7a..f3fc073d5 100644
--- a/schema.yml
+++ b/schema.yml
@@ -167,6 +167,82 @@ paths:
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
+ /api/v2beta/authenticators/admin/duo/:
+ get:
+ operationId: authenticators_admin_duo_list
+ description: Viewset for Duo authenticator devices (for admins)
+ parameters:
+ - in: query
+ name: name
+ schema:
+ type: string
+ - name: ordering
+ required: false
+ in: query
+ description: Which field to use when ordering the results.
+ schema:
+ type: string
+ - name: page
+ required: false
+ in: query
+ description: A page number within the paginated result set.
+ schema:
+ type: integer
+ - name: page_size
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: search
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ tags:
+ - authenticators
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaginatedDuoDeviceList'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ /api/v2beta/authenticators/admin/duo/{id}/:
+ get:
+ operationId: authenticators_admin_duo_retrieve
+ description: Viewset for Duo authenticator devices (for admins)
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this Duo Device.
+ required: true
+ tags:
+ - authenticators
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DuoDevice'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
/api/v2beta/authenticators/admin/static/:
get:
operationId: authenticators_admin_static_list
@@ -395,6 +471,179 @@ paths:
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
+ /api/v2beta/authenticators/duo/:
+ get:
+ operationId: authenticators_duo_list
+ description: Viewset for Duo authenticator devices
+ parameters:
+ - in: query
+ name: name
+ schema:
+ type: string
+ - name: ordering
+ required: false
+ in: query
+ description: Which field to use when ordering the results.
+ schema:
+ type: string
+ - name: page
+ required: false
+ in: query
+ description: A page number within the paginated result set.
+ schema:
+ type: integer
+ - name: page_size
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: search
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ tags:
+ - authenticators
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaginatedDuoDeviceList'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ /api/v2beta/authenticators/duo/{id}/:
+ get:
+ operationId: authenticators_duo_retrieve
+ description: Viewset for Duo authenticator devices
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this Duo Device.
+ required: true
+ tags:
+ - authenticators
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DuoDevice'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ put:
+ operationId: authenticators_duo_update
+ description: Viewset for Duo authenticator devices
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this Duo Device.
+ required: true
+ tags:
+ - authenticators
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DuoDeviceRequest'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/DuoDeviceRequest'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/DuoDeviceRequest'
+ required: true
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DuoDevice'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ patch:
+ operationId: authenticators_duo_partial_update
+ description: Viewset for Duo authenticator devices
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this Duo Device.
+ required: true
+ tags:
+ - authenticators
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PatchedDuoDeviceRequest'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/PatchedDuoDeviceRequest'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/PatchedDuoDeviceRequest'
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DuoDevice'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ delete:
+ operationId: authenticators_duo_destroy
+ description: Viewset for Duo authenticator devices
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: integer
+ description: A unique integer value identifying this Duo Device.
+ required: true
+ tags:
+ - authenticators
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '204':
+ description: No response body
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
/api/v2beta/authenticators/static/:
get:
operationId: authenticators_static_list
@@ -3520,7 +3769,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/Challenge'
+ $ref: '#/components/schemas/FlowChallengeRequest'
description: ''
'404':
description: No Token found
@@ -3550,16 +3799,13 @@ paths:
content:
application/json:
schema:
- type: object
- additionalProperties: {}
+ $ref: '#/components/schemas/FlowChallengeResponseRequest'
application/x-www-form-urlencoded:
schema:
- type: object
- additionalProperties: {}
+ $ref: '#/components/schemas/FlowChallengeResponseRequest'
multipart/form-data:
schema:
- type: object
- additionalProperties: {}
+ $ref: '#/components/schemas/FlowChallengeResponseRequest'
security:
- authentik: []
- cookieAuth: []
@@ -3569,7 +3815,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/Challenge'
+ $ref: '#/components/schemas/FlowChallengeRequest'
description: ''
'400':
$ref: '#/components/schemas/ValidationError'
@@ -10759,6 +11005,236 @@ paths:
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
+ /api/v2beta/stages/authenticator/duo/:
+ get:
+ operationId: stages_authenticator_duo_list
+ description: AuthenticatorDuoStage Viewset
+ parameters:
+ - name: ordering
+ required: false
+ in: query
+ description: Which field to use when ordering the results.
+ schema:
+ type: string
+ - name: page
+ required: false
+ in: query
+ description: A page number within the paginated result set.
+ schema:
+ type: integer
+ - name: page_size
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: search
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ tags:
+ - stages
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaginatedAuthenticatorDuoStageList'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ post:
+ operationId: stages_authenticator_duo_create
+ description: AuthenticatorDuoStage Viewset
+ tags:
+ - stages
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStageRequest'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStageRequest'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStageRequest'
+ required: true
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '201':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStage'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ /api/v2beta/stages/authenticator/duo/{stage_uuid}/:
+ get:
+ operationId: stages_authenticator_duo_retrieve
+ description: AuthenticatorDuoStage Viewset
+ parameters:
+ - in: path
+ name: stage_uuid
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this Duo Authenticator Setup Stage.
+ required: true
+ tags:
+ - stages
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStage'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ put:
+ operationId: stages_authenticator_duo_update
+ description: AuthenticatorDuoStage Viewset
+ parameters:
+ - in: path
+ name: stage_uuid
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this Duo Authenticator Setup Stage.
+ required: true
+ tags:
+ - stages
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStageRequest'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStageRequest'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStageRequest'
+ required: true
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStage'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ patch:
+ operationId: stages_authenticator_duo_partial_update
+ description: AuthenticatorDuoStage Viewset
+ parameters:
+ - in: path
+ name: stage_uuid
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this Duo Authenticator Setup Stage.
+ required: true
+ tags:
+ - stages
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PatchedAuthenticatorDuoStageRequest'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/PatchedAuthenticatorDuoStageRequest'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/PatchedAuthenticatorDuoStageRequest'
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthenticatorDuoStage'
+ description: ''
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ delete:
+ operationId: stages_authenticator_duo_destroy
+ description: AuthenticatorDuoStage Viewset
+ parameters:
+ - in: path
+ name: stage_uuid
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this Duo Authenticator Setup Stage.
+ required: true
+ tags:
+ - stages
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '204':
+ description: No response body
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
+ /api/v2beta/stages/authenticator/duo/{stage_uuid}/enrollment_status/:
+ post:
+ operationId: stages_authenticator_duo_enrollment_status_create
+ description: Check enrollment status of user details in current session
+ parameters:
+ - in: path
+ name: stage_uuid
+ schema:
+ type: string
+ format: uuid
+ description: A UUID string identifying this Duo Authenticator Setup Stage.
+ required: true
+ tags:
+ - stages
+ security:
+ - authentik: []
+ - cookieAuth: []
+ responses:
+ '204':
+ description: Enrollment successful
+ '420':
+ description: Enrollment pending/failed
+ '400':
+ $ref: '#/components/schemas/ValidationError'
+ '403':
+ $ref: '#/components/schemas/GenericError'
/api/v2beta/stages/authenticator/static/:
get:
operationId: stages_authenticator_static_list
@@ -14694,6 +15170,29 @@ paths:
$ref: '#/components/schemas/GenericError'
components:
schemas:
+ AccessDeniedChallenge:
+ type: object
+ description: Challenge when a flow's active stage calls `stage_invalid()`.
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-access-denied
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ error_message:
+ type: string
+ required:
+ - type
ActionEnum:
enum:
- login
@@ -14757,6 +15256,7 @@ components:
- authentik.sources.oauth
- authentik.sources.plex
- authentik.sources.saml
+ - authentik.stages.authenticator_duo
- authentik.stages.authenticator_static
- authentik.stages.authenticator_totp
- authentik.stages.authenticator_validate
@@ -14907,6 +15407,158 @@ components:
If empty, user will not be able to configure this stage.
required:
- name
+ AuthenticatorDuoChallenge:
+ type: object
+ description: Duo Challenge
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-authenticator-duo
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ activation_barcode:
+ type: string
+ activation_code:
+ type: string
+ stage_uuid:
+ type: string
+ required:
+ - activation_barcode
+ - activation_code
+ - pending_user
+ - pending_user_avatar
+ - stage_uuid
+ - type
+ AuthenticatorDuoChallengeResponseRequest:
+ type: object
+ description: Pseudo class for duo response
+ properties:
+ component:
+ type: string
+ default: ak-stage-authenticator-duo
+ AuthenticatorDuoStage:
+ type: object
+ description: AuthenticatorDuoStage Serializer
+ properties:
+ pk:
+ type: string
+ format: uuid
+ readOnly: true
+ title: Stage uuid
+ name:
+ type: string
+ component:
+ type: string
+ readOnly: true
+ verbose_name:
+ type: string
+ readOnly: true
+ verbose_name_plural:
+ type: string
+ readOnly: true
+ flow_set:
+ type: array
+ items:
+ $ref: '#/components/schemas/Flow'
+ configure_flow:
+ type: string
+ format: uuid
+ nullable: true
+ description: Flow used by an authenticated user to configure this Stage.
+ If empty, user will not be able to configure this stage.
+ client_id:
+ type: string
+ api_hostname:
+ type: string
+ required:
+ - api_hostname
+ - client_id
+ - component
+ - name
+ - pk
+ - verbose_name
+ - verbose_name_plural
+ AuthenticatorDuoStageRequest:
+ type: object
+ description: AuthenticatorDuoStage Serializer
+ properties:
+ name:
+ type: string
+ flow_set:
+ type: array
+ items:
+ $ref: '#/components/schemas/FlowRequest'
+ configure_flow:
+ type: string
+ format: uuid
+ nullable: true
+ description: Flow used by an authenticated user to configure this Stage.
+ If empty, user will not be able to configure this stage.
+ client_id:
+ type: string
+ client_secret:
+ type: string
+ writeOnly: true
+ api_hostname:
+ type: string
+ required:
+ - api_hostname
+ - client_id
+ - client_secret
+ - name
+ AuthenticatorStaticChallenge:
+ type: object
+ description: Static authenticator challenge
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-authenticator-static
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ codes:
+ type: array
+ items:
+ type: string
+ required:
+ - codes
+ - pending_user
+ - pending_user_avatar
+ - type
+ AuthenticatorStaticChallengeResponseRequest:
+ type: object
+ description: Pseudo class for static response
+ properties:
+ component:
+ type: string
+ default: ak-stage-authenticator-static
AuthenticatorStaticStage:
type: object
description: AuthenticatorStaticStage Serializer
@@ -14969,6 +15621,47 @@ components:
minimum: -2147483648
required:
- name
+ AuthenticatorTOTPChallenge:
+ type: object
+ description: TOTP Setup challenge
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-authenticator-totp
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ config_url:
+ type: string
+ required:
+ - config_url
+ - pending_user
+ - pending_user_avatar
+ - type
+ AuthenticatorTOTPChallengeResponseRequest:
+ type: object
+ description: TOTP Challenge response, device is set by get_response_instance
+ properties:
+ component:
+ type: string
+ default: ak-stage-authenticator-totp
+ code:
+ type: integer
+ required:
+ - code
AuthenticatorTOTPStage:
type: object
description: AuthenticatorTOTPStage Serializer
@@ -15105,6 +15798,131 @@ components:
is not prompted again.
required:
- name
+ AuthenticatorValidationChallenge:
+ type: object
+ description: Authenticator challenge
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-authenticator-validate
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ device_challenges:
+ type: array
+ items:
+ $ref: '#/components/schemas/DeviceChallenge'
+ required:
+ - device_challenges
+ - pending_user
+ - pending_user_avatar
+ - type
+ AuthenticatorValidationChallengeResponseRequest:
+ type: object
+ description: Challenge used for Code-based and WebAuthn authenticators
+ properties:
+ component:
+ type: string
+ default: ak-stage-authenticator-validate
+ code:
+ type: string
+ webauthn:
+ type: object
+ additionalProperties: {}
+ duo:
+ type: integer
+ AuthenticatorWebAuthnChallenge:
+ type: object
+ description: WebAuthn Challenge
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-authenticator-webauthn
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ registration:
+ type: object
+ additionalProperties: {}
+ required:
+ - pending_user
+ - pending_user_avatar
+ - registration
+ - type
+ AuthenticatorWebAuthnChallengeResponseRequest:
+ type: object
+ description: WebAuthn Challenge response
+ properties:
+ component:
+ type: string
+ default: ak-stage-authenticator-webauthn
+ response:
+ type: object
+ additionalProperties: {}
+ required:
+ - response
+ AutoSubmitChallengeResponseRequest:
+ type: object
+ description: Pseudo class for autosubmit response
+ properties:
+ component:
+ type: string
+ default: ak-stage-autosubmit
+ AutosubmitChallenge:
+ type: object
+ description: Autosubmit challenge used to send and navigate a POST request
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-autosubmit
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ url:
+ type: string
+ attrs:
+ type: object
+ additionalProperties:
+ type: string
+ required:
+ - attrs
+ - type
+ - url
BackendsEnum:
enum:
- django.contrib.auth.backends.ModelBackend
@@ -15129,6 +15947,47 @@ components:
enum:
- can_save_media
type: string
+ CaptchaChallenge:
+ type: object
+ description: Site public key
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-captcha
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ site_key:
+ type: string
+ required:
+ - pending_user
+ - pending_user_avatar
+ - site_key
+ - type
+ CaptchaChallengeResponseRequest:
+ type: object
+ description: Validate captcha token
+ properties:
+ component:
+ type: string
+ default: ak-stage-captcha
+ token:
+ type: string
+ required:
+ - token
CaptchaStage:
type: object
description: CaptchaStage Serializer
@@ -15255,28 +16114,6 @@ components:
required:
- certificate_data
- name
- Challenge:
- type: object
- description: |-
- Challenge that gets sent to the client based on which stage
- is currently active
- properties:
- type:
- $ref: '#/components/schemas/ChallengeChoices'
- component:
- type: string
- title:
- type: string
- background:
- type: string
- response_errors:
- type: object
- additionalProperties:
- type: array
- items:
- $ref: '#/components/schemas/ErrorDetail'
- required:
- - type
ChallengeChoices:
enum:
- native
@@ -15324,6 +16161,48 @@ components:
- error_reporting_environment
- error_reporting_send_pii
- ui_footer_links
+ ConsentChallenge:
+ type: object
+ description: Challenge info for consent screens
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-consent
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ header_text:
+ type: string
+ permissions:
+ type: array
+ items:
+ $ref: '#/components/schemas/Permission'
+ required:
+ - header_text
+ - pending_user
+ - pending_user_avatar
+ - permissions
+ - type
+ ConsentChallengeResponseRequest:
+ type: object
+ description: Consent challenge response, any valid response request is valid
+ properties:
+ component:
+ type: string
+ default: ak-stage-consent
ConsentStage:
type: object
description: ConsentStage Serializer
@@ -15439,11 +16318,27 @@ components:
$ref: '#/components/schemas/FlowRequest'
required:
- name
+ DeviceChallenge:
+ type: object
+ description: Single device challenge
+ properties:
+ device_class:
+ type: string
+ device_uid:
+ type: string
+ challenge:
+ type: object
+ additionalProperties: {}
+ required:
+ - challenge
+ - device_class
+ - device_uid
DeviceClassesEnum:
enum:
- static
- totp
- webauthn
+ - duo
type: string
DigestAlgorithmEnum:
enum:
@@ -15535,6 +16430,34 @@ components:
required:
- name
- url
+ DummyChallenge:
+ type: object
+ description: Dummy challenge
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-dummy
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ required:
+ - type
+ DummyChallengeResponseRequest:
+ type: object
+ description: Dummy challenge response
+ properties:
+ component:
+ type: string
+ default: ak-stage-dummy
DummyPolicy:
type: object
description: Dummy Policy Serializer
@@ -15642,6 +16565,61 @@ components:
$ref: '#/components/schemas/FlowRequest'
required:
- name
+ DuoDevice:
+ type: object
+ description: Serializer for Duo authenticator devices
+ properties:
+ pk:
+ type: integer
+ readOnly: true
+ title: ID
+ name:
+ type: string
+ description: The human-readable name of this device.
+ maxLength: 64
+ required:
+ - name
+ - pk
+ DuoDeviceRequest:
+ type: object
+ description: Serializer for Duo authenticator devices
+ properties:
+ name:
+ type: string
+ description: The human-readable name of this device.
+ maxLength: 64
+ required:
+ - name
+ EmailChallenge:
+ type: object
+ description: Email challenge
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-email
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ required:
+ - type
+ EmailChallengeResponseRequest:
+ type: object
+ description: |-
+ Email challenge resposen. No fields. This challenge is
+ always declared invalid to give the user a chance to retry
+ properties:
+ component:
+ type: string
+ default: ak-stage-email
EmailStage:
type: object
description: EmailStage Serializer
@@ -16049,6 +17027,78 @@ components:
- slug
- stages
- title
+ FlowChallengeRequest:
+ oneOf:
+ - $ref: '#/components/schemas/AccessDeniedChallenge'
+ - $ref: '#/components/schemas/AuthenticatorDuoChallenge'
+ - $ref: '#/components/schemas/AuthenticatorStaticChallenge'
+ - $ref: '#/components/schemas/AuthenticatorTOTPChallenge'
+ - $ref: '#/components/schemas/AuthenticatorValidationChallenge'
+ - $ref: '#/components/schemas/AuthenticatorWebAuthnChallenge'
+ - $ref: '#/components/schemas/AutosubmitChallenge'
+ - $ref: '#/components/schemas/CaptchaChallenge'
+ - $ref: '#/components/schemas/ConsentChallenge'
+ - $ref: '#/components/schemas/DummyChallenge'
+ - $ref: '#/components/schemas/EmailChallenge'
+ - $ref: '#/components/schemas/IdentificationChallenge'
+ - $ref: '#/components/schemas/PasswordChallenge'
+ - $ref: '#/components/schemas/PlexAuthenticationChallenge'
+ - $ref: '#/components/schemas/PromptChallenge'
+ - $ref: '#/components/schemas/RedirectChallenge'
+ - $ref: '#/components/schemas/ShellChallenge'
+ discriminator:
+ propertyName: component
+ mapping:
+ ak-stage-access-denied: '#/components/schemas/AccessDeniedChallenge'
+ ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallenge'
+ ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallenge'
+ ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallenge'
+ ak-stage-authenticator-validate: '#/components/schemas/AuthenticatorValidationChallenge'
+ ak-stage-authenticator-webauthn: '#/components/schemas/AuthenticatorWebAuthnChallenge'
+ ak-stage-autosubmit: '#/components/schemas/AutosubmitChallenge'
+ ak-stage-captcha: '#/components/schemas/CaptchaChallenge'
+ ak-stage-consent: '#/components/schemas/ConsentChallenge'
+ ak-stage-dummy: '#/components/schemas/DummyChallenge'
+ ak-stage-email: '#/components/schemas/EmailChallenge'
+ ak-stage-identification: '#/components/schemas/IdentificationChallenge'
+ ak-stage-password: '#/components/schemas/PasswordChallenge'
+ ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallenge'
+ ak-stage-prompt: '#/components/schemas/PromptChallenge'
+ xak-flow-redirect: '#/components/schemas/RedirectChallenge'
+ xak-flow-shell: '#/components/schemas/ShellChallenge'
+ FlowChallengeResponseRequest:
+ oneOf:
+ - $ref: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest'
+ - $ref: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest'
+ - $ref: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest'
+ - $ref: '#/components/schemas/AuthenticatorValidationChallengeResponseRequest'
+ - $ref: '#/components/schemas/AuthenticatorWebAuthnChallengeResponseRequest'
+ - $ref: '#/components/schemas/AutoSubmitChallengeResponseRequest'
+ - $ref: '#/components/schemas/CaptchaChallengeResponseRequest'
+ - $ref: '#/components/schemas/ConsentChallengeResponseRequest'
+ - $ref: '#/components/schemas/DummyChallengeResponseRequest'
+ - $ref: '#/components/schemas/EmailChallengeResponseRequest'
+ - $ref: '#/components/schemas/IdentificationChallengeResponseRequest'
+ - $ref: '#/components/schemas/PasswordChallengeResponseRequest'
+ - $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
+ - $ref: '#/components/schemas/PromptChallengeResponseRequest'
+ discriminator:
+ propertyName: component
+ mapping:
+ ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest'
+ ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest'
+ ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest'
+ ak-stage-authenticator-validate: '#/components/schemas/AuthenticatorValidationChallengeResponseRequest'
+ ak-stage-authenticator-webauthn: '#/components/schemas/AuthenticatorWebAuthnChallengeResponseRequest'
+ ak-stage-autosubmit: '#/components/schemas/AutoSubmitChallengeResponseRequest'
+ ak-stage-captcha: '#/components/schemas/CaptchaChallengeResponseRequest'
+ ak-stage-consent: '#/components/schemas/ConsentChallengeResponseRequest'
+ ak-stage-dummy: '#/components/schemas/DummyChallengeResponseRequest'
+ ak-stage-email: '#/components/schemas/EmailChallengeResponseRequest'
+ ak-stage-identification: '#/components/schemas/IdentificationChallengeResponseRequest'
+ ak-stage-password: '#/components/schemas/PasswordChallengeResponseRequest'
+ ak-flow-sources-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
+ ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest'
FlowDesignationEnum:
enum:
- authentication
@@ -16338,6 +17388,57 @@ components:
minimum: -2147483648
required:
- ip
+ IdentificationChallenge:
+ type: object
+ description: Identification challenges with all UI elements
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-identification
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ user_fields:
+ type: array
+ items:
+ type: string
+ nullable: true
+ application_pre:
+ type: string
+ enroll_url:
+ type: string
+ recovery_url:
+ type: string
+ primary_action:
+ type: string
+ sources:
+ type: array
+ items:
+ $ref: '#/components/schemas/UILoginButton'
+ required:
+ - primary_action
+ - type
+ - user_fields
+ IdentificationChallengeResponseRequest:
+ type: object
+ description: Identification challenge
+ properties:
+ component:
+ type: string
+ default: ak-stage-identification
+ uid_field:
+ type: string
+ required:
+ - uid_field
IdentificationStage:
type: object
description: IdentificationStage Serializer
@@ -17728,6 +18829,41 @@ components:
required:
- pagination
- results
+ PaginatedAuthenticatorDuoStageList:
+ type: object
+ properties:
+ pagination:
+ type: object
+ properties:
+ next:
+ type: number
+ previous:
+ type: number
+ count:
+ type: number
+ current:
+ type: number
+ total_pages:
+ type: number
+ start_index:
+ type: number
+ end_index:
+ type: number
+ required:
+ - next
+ - previous
+ - count
+ - current
+ - total_pages
+ - start_index
+ - end_index
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/AuthenticatorDuoStage'
+ required:
+ - pagination
+ - results
PaginatedAuthenticatorStaticStageList:
type: object
properties:
@@ -18078,6 +19214,41 @@ components:
required:
- pagination
- results
+ PaginatedDuoDeviceList:
+ type: object
+ properties:
+ pagination:
+ type: object
+ properties:
+ next:
+ type: number
+ previous:
+ type: number
+ count:
+ type: number
+ current:
+ type: number
+ total_pages:
+ type: number
+ start_index:
+ type: number
+ end_index:
+ type: number
+ required:
+ - next
+ - previous
+ - count
+ - current
+ - total_pages
+ - start_index
+ - end_index
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/DuoDevice'
+ required:
+ - pagination
+ - results
PaginatedEmailStageList:
type: object
properties:
@@ -20038,6 +21209,46 @@ components:
required:
- pagination
- results
+ PasswordChallenge:
+ type: object
+ description: Password challenge UI fields
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-password
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ pending_user:
+ type: string
+ pending_user_avatar:
+ type: string
+ recovery_url:
+ type: string
+ required:
+ - pending_user
+ - pending_user_avatar
+ - type
+ PasswordChallengeResponseRequest:
+ type: object
+ description: Password challenge response
+ properties:
+ component:
+ type: string
+ default: ak-stage-password
+ password:
+ type: string
+ required:
+ - password
PasswordExpiryPolicy:
type: object
description: Password Expiry Policy Serializer
@@ -20315,6 +21526,29 @@ components:
nullable: true
description: Flow used by an authenticated user to configure this Stage.
If empty, user will not be able to configure this stage.
+ PatchedAuthenticatorDuoStageRequest:
+ type: object
+ description: AuthenticatorDuoStage Serializer
+ properties:
+ name:
+ type: string
+ flow_set:
+ type: array
+ items:
+ $ref: '#/components/schemas/FlowRequest'
+ configure_flow:
+ type: string
+ format: uuid
+ nullable: true
+ description: Flow used by an authenticated user to configure this Stage.
+ If empty, user will not be able to configure this stage.
+ client_id:
+ type: string
+ client_secret:
+ type: string
+ writeOnly: true
+ api_hostname:
+ type: string
PatchedAuthenticatorStaticStageRequest:
type: object
description: AuthenticatorStaticStage Serializer
@@ -20496,6 +21730,14 @@ components:
type: array
items:
$ref: '#/components/schemas/FlowRequest'
+ PatchedDuoDeviceRequest:
+ type: object
+ description: Serializer for Duo authenticator devices
+ properties:
+ name:
+ type: string
+ description: The human-readable name of this device.
+ maxLength: 64
PatchedEmailStageRequest:
type: object
description: EmailStage Serializer
@@ -21678,6 +22920,51 @@ components:
name:
type: string
maxLength: 200
+ Permission:
+ type: object
+ description: Permission used for consent
+ properties:
+ name:
+ type: string
+ id:
+ type: string
+ required:
+ - id
+ - name
+ PlexAuthenticationChallenge:
+ type: object
+ description: Challenge shown to the user in identification stage
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-flow-sources-plex
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ client_id:
+ type: string
+ slug:
+ type: string
+ required:
+ - client_id
+ - slug
+ - type
+ PlexAuthenticationChallengeResponseRequest:
+ type: object
+ description: Pseudo class for plex response
+ properties:
+ component:
+ type: string
+ default: ak-flow-sources-plex
PlexSource:
type: object
description: Plex Source Serializer
@@ -21999,6 +23286,42 @@ components:
- label
- pk
- type
+ PromptChallenge:
+ type: object
+ description: Initial challenge being sent, define fields
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: ak-stage-prompt
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ fields:
+ type: array
+ items:
+ $ref: '#/components/schemas/StagePrompt'
+ required:
+ - fields
+ - type
+ PromptChallengeResponseRequest:
+ type: object
+ description: |-
+ Validate response, fields are dynamically created based
+ on the stage
+ properties:
+ component:
+ type: string
+ default: ak-stage-prompt
+ additionalProperties: {}
PromptRequest:
type: object
description: Prompt Serializer
@@ -22429,12 +23752,13 @@ components:
properties:
type:
$ref: '#/components/schemas/ChallengeChoices'
- component:
- type: string
title:
type: string
background:
type: string
+ component:
+ type: string
+ default: xak-flow-redirect
response_errors:
type: object
additionalProperties:
@@ -23102,6 +24426,30 @@ components:
- warning
- alert
type: string
+ ShellChallenge:
+ type: object
+ description: challenge type to render HTML as-is
+ properties:
+ type:
+ $ref: '#/components/schemas/ChallengeChoices'
+ title:
+ type: string
+ background:
+ type: string
+ component:
+ type: string
+ default: xak-flow-shell
+ response_errors:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: '#/components/schemas/ErrorDetail'
+ body:
+ type: string
+ required:
+ - body
+ - type
SignatureAlgorithmEnum:
enum:
- http://www.w3.org/2000/09/xmldsig#rsa-sha1
@@ -23231,6 +24579,29 @@ components:
- pk
- verbose_name
- verbose_name_plural
+ StagePrompt:
+ type: object
+ description: Serializer for a single Prompt field
+ properties:
+ field_key:
+ type: string
+ label:
+ type: string
+ type:
+ type: string
+ required:
+ type: boolean
+ placeholder:
+ type: string
+ order:
+ type: integer
+ required:
+ - field_key
+ - label
+ - order
+ - placeholder
+ - required
+ - type
StageRequest:
type: object
description: Stage Serializer
@@ -23458,6 +24829,21 @@ components:
- description
- model_name
- name
+ UILoginButton:
+ type: object
+ description: Serializer for Login buttons of sources
+ properties:
+ name:
+ type: string
+ challenge:
+ type: object
+ additionalProperties: {}
+ icon_url:
+ type: string
+ nullable: true
+ required:
+ - challenge
+ - name
User:
type: object
description: User Serializer
diff --git a/tests/e2e/test_flows_authenticators.py b/tests/e2e/test_flows_authenticators.py
index a6c485c4a..6b62cdd8f 100644
--- a/tests/e2e/test_flows_authenticators.py
+++ b/tests/e2e/test_flows_authenticators.py
@@ -27,6 +27,7 @@ class TestFlowsAuthenticator(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
def test_totp_validate(self):
"""test flow with otp stages"""
sleep(1)
@@ -65,6 +66,7 @@ class TestFlowsAuthenticator(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_stages_authenticator_totp", "0006_default_setup_flow")
def test_totp_setup(self):
"""test TOTP Setup stage"""
@@ -115,6 +117,7 @@ class TestFlowsAuthenticator(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_stages_authenticator_static", "0005_default_setup_flow")
def test_static_setup(self):
"""test Static OTP Setup stage"""
diff --git a/tests/e2e/test_flows_enroll.py b/tests/e2e/test_flows_enroll.py
index 2a9bef063..82a22d912 100644
--- a/tests/e2e/test_flows_enroll.py
+++ b/tests/e2e/test_flows_enroll.py
@@ -40,6 +40,7 @@ class TestFlowsEnroll(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
def test_enroll_2_step(self):
"""Test 2-step enroll flow"""
# First stage fields
@@ -77,6 +78,7 @@ class TestFlowsEnroll(SeleniumTestCase):
flow = Flow.objects.create(
name="default-enrollment-flow",
slug="default-enrollment-flow",
+ title="default-enrollment-flow",
designation=FlowDesignation.ENROLLMENT,
)
@@ -108,6 +110,7 @@ class TestFlowsEnroll(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend")
def test_enroll_email(self):
"""Test enroll with Email verification"""
@@ -152,6 +155,7 @@ class TestFlowsEnroll(SeleniumTestCase):
flow = Flow.objects.create(
name="default-enrollment-flow",
slug="default-enrollment-flow",
+ title="default-enrollment-flow",
designation=FlowDesignation.ENROLLMENT,
)
diff --git a/tests/e2e/test_flows_login.py b/tests/e2e/test_flows_login.py
index 2e663a179..5659e40b2 100644
--- a/tests/e2e/test_flows_login.py
+++ b/tests/e2e/test_flows_login.py
@@ -12,6 +12,7 @@ class TestFlowsLogin(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
def test_login(self):
"""test default login flow"""
self.driver.get(
diff --git a/tests/e2e/test_flows_stage_setup.py b/tests/e2e/test_flows_stage_setup.py
index 43e5efa9e..7e2ccab44 100644
--- a/tests/e2e/test_flows_stage_setup.py
+++ b/tests/e2e/test_flows_stage_setup.py
@@ -19,6 +19,7 @@ class TestFlowsStageSetup(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_stages_password", "0002_passwordstage_change_flow")
def test_password_change(self):
"""test password change flow"""
diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py
index 827395b9c..17b7caaf5 100644
--- a/tests/e2e/test_provider_oauth2_github.py
+++ b/tests/e2e/test_provider_oauth2_github.py
@@ -63,6 +63,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_authorization_consent_implied(self):
@@ -117,6 +118,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_authorization_consent_explicit(self):
@@ -194,6 +196,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_denied(self):
diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py
index ee6a0af0b..ef1b1d767 100644
--- a/tests/e2e/test_provider_oauth2_grafana.py
+++ b/tests/e2e/test_provider_oauth2_grafana.py
@@ -83,6 +83,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_redirect_uri_error(self):
@@ -124,6 +125,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -186,6 +188,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -256,6 +259,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -337,6 +341,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_authorization_denied(self):
diff --git a/tests/e2e/test_provider_oauth2_oidc.py b/tests/e2e/test_provider_oauth2_oidc.py
index 5aa5f9845..90140a0e8 100644
--- a/tests/e2e/test_provider_oauth2_oidc.py
+++ b/tests/e2e/test_provider_oauth2_oidc.py
@@ -78,6 +78,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_redirect_uri_error(self):
@@ -119,6 +120,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -168,6 +170,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -234,6 +237,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_authorization_denied(self):
diff --git a/tests/e2e/test_provider_oauth2_oidc_implicit.py b/tests/e2e/test_provider_oauth2_oidc_implicit.py
index 27dce41fe..7596a2700 100644
--- a/tests/e2e/test_provider_oauth2_oidc_implicit.py
+++ b/tests/e2e/test_provider_oauth2_oidc_implicit.py
@@ -78,6 +78,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_redirect_uri_error(self):
@@ -119,6 +120,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -165,6 +167,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -228,6 +231,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
def test_authorization_denied(self):
diff --git a/tests/e2e/test_provider_proxy.py b/tests/e2e/test_provider_proxy.py
index cdae54b66..48658c5fd 100644
--- a/tests/e2e/test_provider_proxy.py
+++ b/tests/e2e/test_provider_proxy.py
@@ -60,6 +60,7 @@ class TestProviderProxy(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -112,6 +113,7 @@ class TestProviderProxyConnect(ChannelsLiveServerTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
diff --git a/tests/e2e/test_provider_saml.py b/tests/e2e/test_provider_saml.py
index b05c5439c..cc49e693b 100644
--- a/tests/e2e/test_provider_saml.py
+++ b/tests/e2e/test_provider_saml.py
@@ -74,6 +74,7 @@ class TestProviderSAML(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -138,6 +139,7 @@ class TestProviderSAML(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -219,6 +221,7 @@ class TestProviderSAML(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -289,6 +292,7 @@ class TestProviderSAML(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0010_provider_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py
index a26d9c368..3f6ea6114 100644
--- a/tests/e2e/test_source_oauth.py
+++ b/tests/e2e/test_source_oauth.py
@@ -131,6 +131,7 @@ class TestSourceOAuth2(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0009_source_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -188,6 +189,7 @@ class TestSourceOAuth2(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0009_source_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -228,6 +230,7 @@ class TestSourceOAuth2(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0009_source_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@object_manager
@@ -318,6 +321,7 @@ class TestSourceOAuth1(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0009_source_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@patch(
diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py
index 7a409c484..5673e4dde 100644
--- a/tests/e2e/test_source_saml.py
+++ b/tests/e2e/test_source_saml.py
@@ -98,6 +98,7 @@ class TestSourceSAML(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0009_source_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@apply_migration(
@@ -166,6 +167,7 @@ class TestSourceSAML(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0009_source_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@apply_migration(
@@ -247,6 +249,7 @@ class TestSourceSAML(SeleniumTestCase):
@retry()
@apply_migration("authentik_core", "0003_default_user")
@apply_migration("authentik_flows", "0008_default_flows")
+ @apply_migration("authentik_flows", "0011_flow_title")
@apply_migration("authentik_flows", "0009_source_flows")
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
@apply_migration(
diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py
index f326bdb25..735dc4fde 100644
--- a/tests/e2e/utils.py
+++ b/tests/e2e/utils.py
@@ -136,13 +136,13 @@ class SeleniumTestCase(StaticLiveServerTestCase):
)
identification_stage.find_element(
- By.CSS_SELECTOR, "input[name=uid_field]"
+ By.CSS_SELECTOR, "input[name=uidField]"
).click()
identification_stage.find_element(
- By.CSS_SELECTOR, "input[name=uid_field]"
+ By.CSS_SELECTOR, "input[name=uidField]"
).send_keys(USER().username)
identification_stage.find_element(
- By.CSS_SELECTOR, "input[name=uid_field]"
+ By.CSS_SELECTOR, "input[name=uidField]"
).send_keys(Keys.ENTER)
flow_executor = self.get_shadow_root("ak-flow-executor")
diff --git a/web/src/api/Flows.ts b/web/src/api/Flows.ts
index 367ded8e1..2b147cc87 100644
--- a/web/src/api/Flows.ts
+++ b/web/src/api/Flows.ts
@@ -8,23 +8,3 @@ export interface Error {
export interface ErrorDict {
[key: string]: Error[];
}
-
-export interface Challenge {
- type: ChallengeChoices;
- component?: string;
- title?: string;
- response_errors?: ErrorDict;
-}
-
-export interface WithUserInfoChallenge extends Challenge {
- pending_user: string;
- pending_user_avatar: string;
-}
-
-export interface ShellChallenge extends Challenge {
- body: string;
-}
-
-export interface RedirectChallenge extends Challenge {
- to: string;
-}
diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts
index 7b8d3785d..98bdf4fd8 100644
--- a/web/src/flows/FlowExecutor.ts
+++ b/web/src/flows/FlowExecutor.ts
@@ -13,6 +13,7 @@ import { unsafeHTML } from "lit-html/directives/unsafe-html";
import "./access_denied/FlowAccessDenied";
import "./stages/authenticator_static/AuthenticatorStaticStage";
import "./stages/authenticator_totp/AuthenticatorTOTPStage";
+import "./stages/authenticator_duo/AuthenticatorDuoStage";
import "./stages/authenticator_validate/AuthenticatorValidateStage";
import "./stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
import "./stages/autosubmit/AutosubmitStage";
@@ -24,28 +25,14 @@ import "./stages/identification/IdentificationStage";
import "./stages/password/PasswordStage";
import "./stages/prompt/PromptStage";
import "./sources/plex/PlexLoginInit";
-import { ShellChallenge, RedirectChallenge } from "../api/Flows";
-import { IdentificationChallenge } from "./stages/identification/IdentificationStage";
-import { PasswordChallenge } from "./stages/password/PasswordStage";
-import { ConsentChallenge } from "./stages/consent/ConsentStage";
-import { EmailChallenge } from "./stages/email/EmailStage";
-import { AutosubmitChallenge } from "./stages/autosubmit/AutosubmitStage";
-import { PromptChallenge } from "./stages/prompt/PromptStage";
-import { AuthenticatorTOTPChallenge } from "./stages/authenticator_totp/AuthenticatorTOTPStage";
-import { AuthenticatorStaticChallenge } from "./stages/authenticator_static/AuthenticatorStaticStage";
-import { AuthenticatorValidateStageChallenge } from "./stages/authenticator_validate/AuthenticatorValidateStage";
-import { WebAuthnAuthenticatorRegisterChallenge } from "./stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
-import { CaptchaChallenge } from "./stages/captcha/CaptchaStage";
import { StageHost } from "./stages/base";
-import { Challenge, ChallengeChoices, Config, FlowsApi } from "authentik-api";
+import { ChallengeChoices, Config, FlowChallengeRequest, FlowChallengeResponseRequest, FlowsApi, RedirectChallenge, ShellChallenge } from "authentik-api";
import { config, DEFAULT_CONFIG } from "../api/Config";
import { ifDefined } from "lit-html/directives/if-defined";
import { until } from "lit-html/directives/until";
-import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied";
import { PFSize } from "../elements/Spinner";
import { TITLE_DEFAULT } from "../constants";
import { configureSentry } from "../api/Sentry";
-import { PlexAuthenticationChallenge } from "./sources/plex/PlexLoginInit";
@customElement("ak-flow-executor")
export class FlowExecutor extends LitElement implements StageHost {
@@ -53,7 +40,7 @@ export class FlowExecutor extends LitElement implements StageHost {
flowSlug: string;
@property({attribute: false})
- challenge?: Challenge;
+ challenge?: FlowChallengeRequest;
@property({type: Boolean})
loading = false;
@@ -88,9 +75,6 @@ export class FlowExecutor extends LitElement implements StageHost {
constructor() {
super();
- this.addEventListener("ak-flow-submit", () => {
- this.submit();
- });
this.flowSlug = window.location.pathname.split("/")[3];
}
@@ -110,19 +94,21 @@ export class FlowExecutor extends LitElement implements StageHost {
});
}
- submit
@@ -174,7 +158,7 @@ export class FlowExecutor extends LitElement implements StageHost {
`
- };
+ } as FlowChallengeRequest;
}
renderLoading(): TemplateResult {
@@ -200,33 +184,35 @@ export class FlowExecutor extends LitElement implements StageHost {
case ChallengeChoices.Native:
switch (this.challenge.component) {
case "ak-stage-access-denied":
- return html`
${this.challenge.error_message}
`} +${this.challenge.errorMessage}
`} diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts index f78c7329d..ebfe8262d 100644 --- a/web/src/flows/sources/plex/PlexLoginInit.ts +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -1,5 +1,5 @@ import { t } from "@lingui/macro"; -import { Challenge } from "authentik-api"; +import { PlexAuthenticationChallenge } from "authentik-api"; import PFLogin from "@patternfly/patternfly/components/Login/login.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; @@ -7,7 +7,7 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import AKGlobal from "../../../authentik.css"; -import { CSSResult, customElement, property } from "lit-element"; +import { CSSResult, customElement } from "lit-element"; import { html, TemplateResult } from "lit-html"; import { BaseStage } from "../../stages/base"; import { PlexAPIClient, popupCenterScreen } from "./API"; @@ -15,28 +15,20 @@ import { DEFAULT_CONFIG } from "../../../api/Config"; import { SourcesApi } from "authentik-api"; import { showMessage } from "../../../elements/messages/MessageContainer"; import { MessageLevel } from "../../../elements/messages/Message"; +import { PlexAuthenticationChallengeResponseRequest } from "authentik-api/dist/models/PlexAuthenticationChallengeResponseRequest"; -export interface PlexAuthenticationChallenge extends Challenge { - - client_id: string; - slug: string; - -} @customElement("ak-flow-sources-plex") -export class PlexLoginInit extends BaseStage { - - @property({ attribute: false }) - challenge?: PlexAuthenticationChallenge; +export class PlexLoginInit extends BaseStage