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(formData?: T): Promise { + submit(payload?: FlowChallengeResponseRequest): Promise { + if (!payload) return Promise.reject(); + if (!this.challenge) return Promise.reject(); + // @ts-ignore + payload.component = this.challenge.component; this.loading = true; - return new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolveRaw({ + return new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({ flowSlug: this.flowSlug, - requestBody: formData || {}, query: window.location.search.substring(1), - }).then((challengeRaw) => { - return challengeRaw.raw.json(); + flowChallengeResponseRequest: payload, }).then((data) => { this.challenge = data; this.postUpdate(); - }).catch((e: Response) => { - this.errorMessage(e.statusText); + }).catch((e: Error) => { + this.errorMessage(e.toString()); }).finally(() => { this.loading = false; }); @@ -133,28 +119,26 @@ export class FlowExecutor extends LitElement implements StageHost { this.config = config; }); this.loading = true; - new FlowsApi(DEFAULT_CONFIG).flowsExecutorGetRaw({ + new FlowsApi(DEFAULT_CONFIG).flowsExecutorGet({ flowSlug: this.flowSlug, query: window.location.search.substring(1), - }).then((challengeRaw) => { - return challengeRaw.raw.json(); }).then((challenge) => { - this.challenge = challenge as Challenge; + this.challenge = challenge; // Only set background on first update, flow won't change throughout execution if (this.challenge?.background) { this.setBackground(this.challenge.background); } this.postUpdate(); - }).catch((e: Response) => { + }).catch((e: Error) => { // Catch JSON or Update errors - this.errorMessage(e.statusText); + this.errorMessage(e.toString()); }).finally(() => { this.loading = false; }); } errorMessage(error: string): void { - this.challenge = { + this.challenge = { type: ChallengeChoices.Shell, body: `