diff --git a/Pipfile b/Pipfile index c4bb09316..8857dfb65 100644 --- a/Pipfile +++ b/Pipfile @@ -59,3 +59,4 @@ pylint-django = "*" pytest = "*" pytest-django = "*" selenium = "*" +requests-mock = "*" diff --git a/Pipfile.lock b/Pipfile.lock index a2e910476..e50aaf2a7 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "24f00363590649f2442c6ac28dfe8692f0f317e0a5b91c0696b84610cef299d2" + "sha256": "17be2923cf8d281e430ec1467aea723806ac6f7c58fc6553ede92317e43f4d14" }, "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:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.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:e4f8cb923edf035c2ae5f6169c70e77e31df70b88919b92b826a6b9bd14511b1", "sha256:f7c2c5c5ed5212b2628d8fb1c587b31c6e8d413ecbbd1a1cdf6f96ed6f5c8d5e" ], + "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.62" }, "cachetools": { @@ -134,6 +141,7 @@ "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001", "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff" ], + "markers": "python_version ~= '3.5'", "version": "==4.2.2" }, "cbor2": { @@ -220,6 +228,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": { @@ -227,6 +236,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": { @@ -300,6 +310,7 @@ "sha256:76ffae916ba3aa66b46996c14fa713e46004788167a4873d647544e750e0e99f", "sha256:a9af943c79717bc52fe64a3c236ae5d3adccc8b5be19c881b442d2c3db233393" ], + "markers": "python_version >= '3.6'", "version": "==3.0.2" }, "defusedxml": { @@ -425,6 +436,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": { @@ -440,6 +452,7 @@ "sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f", "sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206" ], + "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.0" }, "gunicorn": { @@ -455,6 +468,7 @@ "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" ], + "markers": "python_version >= '3.6'", "version": "==0.12.0" }, "hiredis": { @@ -501,6 +515,7 @@ "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0", "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a" ], + "markers": "python_version >= '3.6'", "version": "==2.0.0" }, "httptools": { @@ -549,6 +564,7 @@ "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" ], + "markers": "python_version >= '3.5'", "version": "==0.5.1" }, "itypes": { @@ -563,6 +579,7 @@ "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, "jmespath": { @@ -570,6 +587,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": { @@ -584,6 +602,7 @@ "sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006", "sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c" ], + "markers": "python_version >= '3.6'", "version": "==5.0.2" }, "kubernetes": { @@ -596,8 +615,11 @@ }, "ldap3": { "hashes": [ + "sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056", "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91", - "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57" + "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57", + "sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c", + "sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59" ], "index": "pypi", "version": "==2.9" @@ -709,12 +731,14 @@ "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "maxminddb": { "hashes": [ "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" ], + "markers": "python_version >= '3.6'", "version": "==2.0.3" }, "msgpack": { @@ -790,6 +814,7 @@ "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" ], + "markers": "python_version >= '3.6'", "version": "==5.1.0" }, "oauthlib": { @@ -797,6 +822,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": { @@ -812,6 +838,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": { @@ -819,6 +846,7 @@ "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04", "sha256:e1b4f11b9336a28fa11810bc623c357420f69dfdb6d2dac41ca2c21a55c033bc" ], + "markers": "python_full_version >= '3.6.1'", "version": "==3.0.18" }, "psycopg2-binary": { @@ -864,15 +892,37 @@ }, "pyasn1": { "hashes": [ + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86" ], "version": "==0.4.8" }, "pyasn1-modules": { "hashes": [ + "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", + "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", + "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", + "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405", + "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", + "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", - "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74" + "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", + "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", + "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", + "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", + "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", + "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8" ], "version": "==0.2.8" }, @@ -881,6 +931,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": { @@ -924,6 +975,7 @@ "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" ], + "markers": "python_version >= '3.5'", "version": "==2.0.2" }, "pyjwt": { @@ -946,12 +998,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": { @@ -959,6 +1013,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": { @@ -1015,6 +1070,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": { @@ -1022,12 +1078,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:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", - "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a" + "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" ], "index": "pypi", "version": "==1.3.0" @@ -1045,6 +1103,7 @@ "sha256:44bc6b54fddd45e4bc0619059196679f9e8b79c027f4131bb072e6a22f4d5e28", "sha256:ac79fb25f5476e8e9ed1c53b8a2286d2c3f5dde49eb37dbcee5c7eb6a8415a22" ], + "markers": "python_version >= '3'", "version": "==0.17.4" }, "ruamel.yaml.clib": { @@ -1081,7 +1140,7 @@ "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2", "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f" ], - "markers": "platform_python_implementation == 'CPython' and python_version < '3.10'", + "markers": "python_version < '3.10' and platform_python_implementation == 'CPython'", "version": "==0.2.2" }, "s3transfer": { @@ -1112,6 +1171,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "sqlparse": { @@ -1119,6 +1179,7 @@ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], + "markers": "python_version >= '3.5'", "version": "==0.4.1" }, "structlog": { @@ -1174,6 +1235,7 @@ "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8", "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb" ], + "markers": "python_version >= '3.6'", "version": "==21.2.1" }, "typing-extensions": { @@ -1189,6 +1251,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": { @@ -1233,6 +1296,7 @@ "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" ], + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "watchgod": { @@ -1262,6 +1326,7 @@ "sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663", "sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.58.0" }, "websockets": { @@ -1348,6 +1413,7 @@ "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" ], + "markers": "python_version >= '3.6'", "version": "==1.6.3" }, "zope.interface": { @@ -1404,6 +1470,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" } }, @@ -1420,6 +1487,7 @@ "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e", "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975" ], + "markers": "python_version ~= '3.6'", "version": "==2.5.6" }, "attrs": { @@ -1427,6 +1495,7 @@ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, "bandit": { @@ -1452,11 +1521,27 @@ "index": "pypi", "version": "==1.0.1" }, + "certifi": { + "hashes": [ + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" + ], + "version": "==2020.12.5" + }, + "chardet": { + "hashes": [ + "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": { "hashes": [ "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": { @@ -1530,14 +1615,23 @@ "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0", "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005" ], + "markers": "python_version >= '3.4'", "version": "==4.0.7" }, "gitpython": { "hashes": [ - "sha256:05af150f47a5cca3f4b0af289b73aef8cf3c4fe2385015b06220cbcdee48bb6e", - "sha256:a77824e516d3298b04fb36ec7845e92747df8fcfee9cacc32dd6239f9652f867" + "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b", + "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61" ], - "version": "==3.1.15" + "markers": "python_version >= '3.4'", + "version": "==3.1.14" + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "version": "==2.10" }, "iniconfig": { "hashes": [ @@ -1551,6 +1645,7 @@ "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6", "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d" ], + "markers": "python_version >= '3.6' and python_version < '4.0'", "version": "==5.8.0" }, "lazy-object-proxy": { @@ -1578,6 +1673,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": { @@ -1614,6 +1710,7 @@ "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" ], + "markers": "python_version >= '2.6'", "version": "==5.6.0" }, "pluggy": { @@ -1621,6 +1718,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": { @@ -1628,6 +1726,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": { @@ -1658,6 +1757,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": { @@ -1757,6 +1857,22 @@ ], "version": "==2021.4.4" }, + "requests": { + "hashes": [ + "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": { + "hashes": [ + "sha256:33296f228d8c5df11a7988b741325422480baddfdf5dd9318fd0eb40c3ed8595", + "sha256:5c8ef0254c14a84744be146e9799dc13ebc4f6186058112d9aeed96b131b58e2" + ], + "index": "pypi", + "version": "==1.9.2" + }, "selenium": { "hashes": [ "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", @@ -1770,6 +1886,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "smmap": { @@ -1777,6 +1894,7 @@ "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182", "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2" ], + "markers": "python_version >= '3.5'", "version": "==4.0.0" }, "stevedore": { @@ -1784,6 +1902,7 @@ "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" ], + "markers": "python_version >= '3.6'", "version": "==3.3.0" }, "toml": { @@ -1791,6 +1910,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" }, "typed-ast": { diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py index 0b71a2857..c3738e00b 100644 --- a/authentik/api/v2/urls.py +++ b/authentik/api/v2/urls.py @@ -63,6 +63,7 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet from authentik.sources.oauth.api.source_connection import ( UserOAuthSourceConnectionViewSet, ) +from authentik.sources.plex.api import PlexSourceViewSet from authentik.sources.saml.api import SAMLSourceViewSet from authentik.stages.authenticator_static.api import ( AuthenticatorStaticStageViewSet, @@ -136,6 +137,7 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS router.register("sources/ldap", LDAPSourceViewSet) router.register("sources/saml", SAMLSourceViewSet) router.register("sources/oauth", OAuthSourceViewSet) +router.register("sources/plex", PlexSourceViewSet) router.register("policies/all", PolicyViewSet) router.register("policies/bindings", PolicyBindingViewSet) diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index a9c481a07..e1da3a2fb 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -45,6 +45,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): "verbose_name", "verbose_name_plural", "policy_engine_mode", + "user_matching_mode", ] diff --git a/authentik/core/migrations/0020_source_user_matching_mode.py b/authentik/core/migrations/0020_source_user_matching_mode.py new file mode 100644 index 000000000..68f6f9524 --- /dev/null +++ b/authentik/core/migrations/0020_source_user_matching_mode.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2 on 2021-05-03 17:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0019_source_managed"), + ] + + operations = [ + migrations.AddField( + model_name="source", + name="user_matching_mode", + field=models.TextField( + choices=[ + ("identifier", "Use the source-specific identifier"), + ( + "email_link", + "Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.", + ), + ( + "email_deny", + "Use the user's email address, but deny enrollment when the email address already exists.", + ), + ( + "username_link", + "Link to a user with identical username address. Can have security implications when a username is used with another source.", + ), + ( + "username_deny", + "Use the user's username, but deny enrollment when the username already exists.", + ), + ], + default="identifier", + help_text="How the source determines if an existing user should be authenticated or a new user enrolled.", + ), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 501b556c7..b1f8fc2af 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -240,6 +240,30 @@ class Application(PolicyBindingModel): verbose_name_plural = _("Applications") +class SourceUserMatchingModes(models.TextChoices): + """Different modes a source can handle new/returning users""" + + IDENTIFIER = "identifier", _("Use the source-specific identifier") + EMAIL_LINK = "email_link", _( + ( + "Link to a user with identical email address. Can have security implications " + "when a source doesn't validate email addresses." + ) + ) + EMAIL_DENY = "email_deny", _( + "Use the user's email address, but deny enrollment when the email address already exists." + ) + USERNAME_LINK = "username_link", _( + ( + "Link to a user with identical username address. Can have security implications " + "when a username is used with another source." + ) + ) + USERNAME_DENY = "username_deny", _( + "Use the user's username, but deny enrollment when the username already exists." + ) + + class Source(ManagedModel, SerializerModel, PolicyBindingModel): """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" @@ -272,6 +296,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): related_name="source_enrollment", ) + user_matching_mode = models.TextField( + choices=SourceUserMatchingModes.choices, + default=SourceUserMatchingModes.IDENTIFIER, + help_text=_( + ( + "How the source determines if an existing user should be authenticated or " + "a new user enrolled." + ) + ), + ) + objects = InheritanceManager() @property @@ -301,6 +336,8 @@ class UserSourceConnection(CreatedUpdatedModel): user = models.ForeignKey(User, on_delete=models.CASCADE) source = models.ForeignKey(Source, on_delete=models.CASCADE) + objects = InheritanceManager() + class Meta: unique_together = (("user", "source"),) diff --git a/authentik/core/sources/__init__.py b/authentik/core/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py new file mode 100644 index 000000000..c6f5b9795 --- /dev/null +++ b/authentik/core/sources/flow_manager.py @@ -0,0 +1,261 @@ +"""Source decision helper""" +from enum import Enum +from typing import Any, Optional, Type + +from django.contrib import messages +from django.db.models.query_utils import Q +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import gettext as _ +from structlog.stdlib import get_logger + +from authentik.core.models import ( + Source, + SourceUserMatchingModes, + User, + UserSourceConnection, +) +from authentik.core.sources.stage import ( + PLAN_CONTEXT_SOURCES_CONNECTION, + PostUserEnrollmentStage, +) +from authentik.events.models import Event, EventAction +from authentik.flows.models import Flow, Stage, in_memory_stage +from authentik.flows.planner import ( + PLAN_CONTEXT_PENDING_USER, + PLAN_CONTEXT_REDIRECT, + PLAN_CONTEXT_SOURCE, + PLAN_CONTEXT_SSO, + FlowPlanner, +) +from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN +from authentik.lib.utils.urls import redirect_with_qs +from authentik.policies.utils import delete_none_keys +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + + +class Action(Enum): + """Actions that can be decided based on the request + and source settings""" + + LINK = "link" + AUTH = "auth" + ENROLL = "enroll" + DENY = "deny" + + +class SourceFlowManager: + """Help sources decide what they should do after authorization. Based on source settings and + previous connections, authenticate the user, enroll a new user, link to an existing user + or deny the request.""" + + source: Source + request: HttpRequest + + identifier: str + + connection_type: Type[UserSourceConnection] = UserSourceConnection + + def __init__( + self, + source: Source, + request: HttpRequest, + identifier: str, + enroll_info: dict[str, Any], + ) -> None: + self.source = source + self.request = request + self.identifier = identifier + self.enroll_info = enroll_info + self._logger = get_logger().bind(source=source, identifier=identifier) + + # pylint: disable=too-many-return-statements + def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]: + """decide which action should be taken""" + new_connection = self.connection_type( + source=self.source, identifier=self.identifier + ) + # When request is authenticated, always link + if self.request.user.is_authenticated: + new_connection.user = self.request.user + new_connection = self.update_connection(new_connection, **kwargs) + new_connection.save() + return Action.LINK, new_connection + + existing_connections = self.connection_type.objects.filter( + source=self.source, identifier=self.identifier + ) + if existing_connections.exists(): + connection = existing_connections.first() + return Action.AUTH, self.update_connection(connection, **kwargs) + # No connection exists, but we match on identifier, so enroll + if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER: + # We don't save the connection here cause it doesn't have a user assigned yet + return Action.ENROLL, self.update_connection(new_connection, **kwargs) + + # Check for existing users with matching attributes + query = Q() + # Either query existing user based on email or username + if self.source.user_matching_mode in [ + SourceUserMatchingModes.EMAIL_LINK, + SourceUserMatchingModes.EMAIL_DENY, + ]: + if not self.enroll_info.get("email", None): + self._logger.warning("Refusing to use none email", source=self.source) + return Action.DENY, None + query = Q(email__exact=self.enroll_info.get("email", None)) + if self.source.user_matching_mode in [ + SourceUserMatchingModes.USERNAME_LINK, + SourceUserMatchingModes.USERNAME_DENY, + ]: + if not self.enroll_info.get("username", None): + self._logger.warning( + "Refusing to use none username", source=self.source + ) + return Action.DENY, None + query = Q(username__exact=self.enroll_info.get("username", None)) + matching_users = User.objects.filter(query) + # No matching users, always enroll + if not matching_users.exists(): + return Action.ENROLL, self.update_connection(new_connection, **kwargs) + + user = matching_users.first() + if self.source.user_matching_mode in [ + SourceUserMatchingModes.EMAIL_LINK, + SourceUserMatchingModes.USERNAME_LINK, + ]: + new_connection.user = user + new_connection = self.update_connection(new_connection, **kwargs) + new_connection.save() + return Action.LINK, new_connection + if self.source.user_matching_mode in [ + SourceUserMatchingModes.EMAIL_DENY, + SourceUserMatchingModes.USERNAME_DENY, + ]: + return Action.DENY, None + return Action.DENY, None + + def update_connection( + self, connection: UserSourceConnection, **kwargs + ) -> UserSourceConnection: + """Optionally make changes to the connection after it is looked up/created.""" + return connection + + def get_flow(self, **kwargs) -> HttpResponse: + """Get the flow response based on user_matching_mode""" + action, connection = self.get_action() + if action == Action.LINK: + self._logger.debug("Linking existing user") + return self.handle_existing_user_link() + if not connection: + return redirect("/") + if action == Action.AUTH: + self._logger.debug("Handling auth user") + return self.handle_auth_user(connection) + if action == Action.ENROLL: + self._logger.debug("Handling enrollment of new user") + return self.handle_enroll(connection) + return redirect("/") + + # pylint: disable=unused-argument + def get_stages_to_append(self, flow: Flow) -> list[Stage]: + """Hook to override stages which are appended to the flow""" + if flow.slug == self.source.enrollment_flow.slug: + return [ + in_memory_stage(PostUserEnrollmentStage), + ] + return [] + + def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: + """Prepare Authentication Plan, redirect user FlowExecutor""" + # Ensure redirect is carried through when user was trying to + # authorize application + final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( + NEXT_ARG_NAME, "authentik_core:if-admin" + ) + kwargs.update( + { + # Since we authenticate the user by their token, they have no backend set + PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_SOURCE: self.source, + PLAN_CONTEXT_REDIRECT: final_redirect, + } + ) + if not flow: + return HttpResponseBadRequest() + # We run the Flow planner here so we can pass the Pending user in the context + planner = FlowPlanner(flow) + plan = planner.plan(self.request, kwargs) + for stage in self.get_stages_to_append(flow): + plan.append(stage) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_core:if-flow", + self.request.GET, + flow_slug=flow.slug, + ) + + # pylint: disable=unused-argument + def handle_auth_user( + self, + connection: UserSourceConnection, + ) -> HttpResponse: + """Login user and redirect.""" + messages.success( + self.request, + _( + "Successfully authenticated with %(source)s!" + % {"source": self.source.name} + ), + ) + flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user} + return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs) + + def handle_existing_user_link( + self, + ) -> HttpResponse: + """Handler when the user was already authenticated and linked an external source + to their account.""" + Event.new( + EventAction.SOURCE_LINKED, + message="Linked Source", + source=self.source, + ).from_http(self.request) + messages.success( + self.request, + _("Successfully linked %(source)s!" % {"source": self.source.name}), + ) + return redirect( + reverse( + "authentik_core:if-admin", + ) + + f"#/user;page-{self.source.slug}" + ) + + def handle_enroll( + self, + connection: UserSourceConnection, + ) -> HttpResponse: + """User was not authenticated and previous request was not authenticated.""" + messages.success( + self.request, + _( + "Successfully authenticated with %(source)s!" + % {"source": self.source.name} + ), + ) + + # We run the Flow planner here so we can pass the Pending user in the context + if not self.source.enrollment_flow: + self._logger.warning("source has no enrollment flow") + return HttpResponseBadRequest() + return self._handle_login_flow( + self.source.enrollment_flow, + **{ + PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), + PLAN_CONTEXT_SOURCES_CONNECTION: connection, + }, + ) diff --git a/authentik/sources/oauth/views/flows.py b/authentik/core/sources/stage.py similarity index 53% rename from authentik/sources/oauth/views/flows.py rename to authentik/core/sources/stage.py index 1dc239aed..bcc37adf0 100644 --- a/authentik/sources/oauth/views/flows.py +++ b/authentik/core/sources/stage.py @@ -1,32 +1,30 @@ -"""OAuth Stages""" +"""Source flow manager stages""" from django.http import HttpRequest, HttpResponse -from authentik.core.models import User +from authentik.core.models import User, UserSourceConnection from authentik.events.models import Event, EventAction from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import StageView -from authentik.sources.oauth.models import UserOAuthSourceConnection -PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access" +PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection" class PostUserEnrollmentStage(StageView): - """Dynamically injected stage which saves the OAuth Connection after + """Dynamically injected stage which saves the Connection after the user has been enrolled.""" # pylint: disable=unused-argument def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Stage used after the user has been enrolled""" - access: UserOAuthSourceConnection = self.executor.plan.context[ - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS + connection: UserSourceConnection = self.executor.plan.context[ + PLAN_CONTEXT_SOURCES_CONNECTION ] user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - access.user = user - access.save() - UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) + connection.user = user + connection.save() Event.new( EventAction.SOURCE_LINKED, - message="Linked OAuth Source", - source=access.source, + message="Linked Source", + source=connection.source, ).from_http(self.request) return self.executor.stage_ok() diff --git a/authentik/core/tests/test_models.py b/authentik/core/tests/test_models.py index cb0444632..513452808 100644 --- a/authentik/core/tests/test_models.py +++ b/authentik/core/tests/test_models.py @@ -1,11 +1,14 @@ """authentik core models tests""" from time import sleep +from typing import Callable, Type from django.test import TestCase from django.utils.timezone import now from guardian.shortcuts import get_anonymous_user -from authentik.core.models import Token +from authentik.core.models import Provider, Source, Token +from authentik.flows.models import Stage +from authentik.lib.utils.reflection import all_subclasses class TestModels(TestCase): @@ -24,3 +27,40 @@ class TestModels(TestCase): ) sleep(0.5) self.assertFalse(token.is_expired) + + +def source_tester_factory(test_model: Type[Stage]) -> Callable: + """Test source""" + + def tester(self: TestModels): + model_class = None + if test_model._meta.abstract: + model_class = test_model.__bases__[0]() + else: + model_class = test_model() + model_class.slug = "test" + self.assertIsNotNone(model_class.component) + _ = model_class.ui_login_button + _ = model_class.ui_user_settings + + return tester + + +def provider_tester_factory(test_model: Type[Stage]) -> Callable: + """Test provider""" + + def tester(self: TestModels): + model_class = None + if test_model._meta.abstract: + model_class = test_model.__bases__[0]() + else: + model_class = test_model() + self.assertIsNotNone(model_class.component) + + return tester + + +for model in all_subclasses(Source): + setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model)) +for model in all_subclasses(Provider): + setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model)) diff --git a/authentik/core/types.py b/authentik/core/types.py index 988a9aea6..d8ba7cadf 100644 --- a/authentik/core/types.py +++ b/authentik/core/types.py @@ -2,9 +2,10 @@ from dataclasses import dataclass from typing import Optional -from rest_framework.fields import CharField +from rest_framework.fields import CharField, DictField from authentik.core.api.utils import PassiveSerializer +from authentik.flows.challenge import Challenge @dataclass @@ -14,8 +15,8 @@ class UILoginButton: # Name, ran through i18n name: str - # URL Which Button points to - url: str + # Challenge which is presented to the user when they click the button + challenge: Challenge # Icon URL, used as-is icon_url: Optional[str] = None @@ -25,7 +26,7 @@ class UILoginButtonSerializer(PassiveSerializer): """Serializer for Login buttons of sources""" name = CharField() - url = CharField() + challenge = DictField() icon_url = CharField(required=False, allow_null=True) diff --git a/authentik/flows/tests/test_stage_model.py b/authentik/flows/tests/test_stage_model.py index fe887ee89..05db743f6 100644 --- a/authentik/flows/tests/test_stage_model.py +++ b/authentik/flows/tests/test_stage_model.py @@ -16,17 +16,14 @@ def model_tester_factory(test_model: Type[Stage]) -> Callable: """Test a form""" def tester(self: TestModels): - try: - model_class = None - if test_model._meta.abstract: - model_class = test_model.__bases__[0]() - else: - model_class = test_model() - self.assertTrue(issubclass(model_class.type, StageView)) - self.assertIsNotNone(test_model.component) - _ = test_model.ui_user_settings - except NotImplementedError: - pass + model_class = None + if test_model._meta.abstract: + model_class = test_model.__bases__[0]() + else: + model_class = test_model() + self.assertTrue(issubclass(model_class.type, StageView)) + self.assertIsNotNone(test_model.component) + _ = test_model.ui_user_settings return tester diff --git a/authentik/policies/event_matcher/migrations/0013_alter_eventmatcherpolicy_app.py b/authentik/policies/event_matcher/migrations/0013_alter_eventmatcherpolicy_app.py new file mode 100644 index 000000000..46cc8443a --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0013_alter_eventmatcherpolicy_app.py @@ -0,0 +1,84 @@ +# Generated by Django 3.2 on 2021-05-02 17:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0012_auto_20210323_1339"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="app", + field=models.TextField( + blank=True, + choices=[ + ("authentik.admin", "authentik Admin"), + ("authentik.api", "authentik API"), + ("authentik.events", "authentik Events"), + ("authentik.crypto", "authentik Crypto"), + ("authentik.flows", "authentik Flows"), + ("authentik.outposts", "authentik Outpost"), + ("authentik.lib", "authentik lib"), + ("authentik.policies", "authentik Policies"), + ("authentik.policies.dummy", "authentik Policies.Dummy"), + ( + "authentik.policies.event_matcher", + "authentik Policies.Event Matcher", + ), + ("authentik.policies.expiry", "authentik Policies.Expiry"), + ("authentik.policies.expression", "authentik Policies.Expression"), + ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), + ("authentik.policies.password", "authentik Policies.Password"), + ("authentik.policies.reputation", "authentik Policies.Reputation"), + ("authentik.providers.proxy", "authentik Providers.Proxy"), + ("authentik.providers.oauth2", "authentik Providers.OAuth2"), + ("authentik.providers.saml", "authentik Providers.SAML"), + ("authentik.recovery", "authentik Recovery"), + ("authentik.sources.ldap", "authentik Sources.LDAP"), + ("authentik.sources.oauth", "authentik Sources.OAuth"), + ("authentik.sources.plex", "authentik Sources.Plex"), + ("authentik.sources.saml", "authentik Sources.SAML"), + ( + "authentik.stages.authenticator_static", + "authentik Stages.Authenticator.Static", + ), + ( + "authentik.stages.authenticator_totp", + "authentik Stages.Authenticator.TOTP", + ), + ( + "authentik.stages.authenticator_validate", + "authentik Stages.Authenticator.Validate", + ), + ( + "authentik.stages.authenticator_webauthn", + "authentik Stages.Authenticator.WebAuthn", + ), + ("authentik.stages.captcha", "authentik Stages.Captcha"), + ("authentik.stages.consent", "authentik Stages.Consent"), + ("authentik.stages.deny", "authentik Stages.Deny"), + ("authentik.stages.dummy", "authentik Stages.Dummy"), + ("authentik.stages.email", "authentik Stages.Email"), + ( + "authentik.stages.identification", + "authentik Stages.Identification", + ), + ("authentik.stages.invitation", "authentik Stages.User Invitation"), + ("authentik.stages.password", "authentik Stages.Password"), + ("authentik.stages.prompt", "authentik Stages.Prompt"), + ("authentik.stages.user_delete", "authentik Stages.User Delete"), + ("authentik.stages.user_login", "authentik Stages.User Login"), + ("authentik.stages.user_logout", "authentik Stages.User Logout"), + ("authentik.stages.user_write", "authentik Stages.User Write"), + ("authentik.core", "authentik Core"), + ("authentik.managed", "authentik Managed"), + ], + default="", + help_text="Match events created by selected application. When left empty, all applications are matched.", + ), + ), + ] diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 238f42b49..4fa9d6355 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -107,6 +107,7 @@ INSTALLED_APPS = [ "authentik.recovery", "authentik.sources.ldap", "authentik.sources.oauth", + "authentik.sources.plex", "authentik.sources.saml", "authentik.stages.authenticator_static", "authentik.stages.authenticator_totp", diff --git a/authentik/sources/oauth/apps.py b/authentik/sources/oauth/apps.py index 7aad40515..657a5d942 100644 --- a/authentik/sources/oauth/apps.py +++ b/authentik/sources/oauth/apps.py @@ -2,11 +2,21 @@ from importlib import import_module from django.apps import AppConfig -from django.conf import settings from structlog.stdlib import get_logger LOGGER = get_logger() +AUTHENTIK_SOURCES_OAUTH_TYPES = [ + "authentik.sources.oauth.types.discord", + "authentik.sources.oauth.types.facebook", + "authentik.sources.oauth.types.github", + "authentik.sources.oauth.types.google", + "authentik.sources.oauth.types.reddit", + "authentik.sources.oauth.types.twitter", + "authentik.sources.oauth.types.azure_ad", + "authentik.sources.oauth.types.oidc", +] + class AuthentikSourceOAuthConfig(AppConfig): """authentik source.oauth config""" @@ -18,7 +28,7 @@ class AuthentikSourceOAuthConfig(AppConfig): def ready(self): """Load source_types from config file""" - for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES: + for source_type in AUTHENTIK_SOURCES_OAUTH_TYPES: try: import_module(source_type) LOGGER.debug("Loaded OAuth Source Type", type=source_type) diff --git a/authentik/sources/oauth/auth.py b/authentik/sources/oauth/auth.py deleted file mode 100644 index 62836f574..000000000 --- a/authentik/sources/oauth/auth.py +++ /dev/null @@ -1,23 +0,0 @@ -"""authentik oauth_client Authorization backend""" -from typing import Optional - -from django.contrib.auth.backends import ModelBackend -from django.http import HttpRequest - -from authentik.core.models import User -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection - - -class AuthorizedServiceBackend(ModelBackend): - "Authentication backend for users registered with remote OAuth provider." - - def authenticate( - self, request: HttpRequest, source: OAuthSource, identifier: str - ) -> Optional[User]: - "Fetch user for a given source by id." - access = UserOAuthSourceConnection.objects.filter( - source=source, identifier=identifier - ).select_related("user") - if not access.exists(): - return None - return access.first().user diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index c14caa43f..b44ff57f1 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -9,6 +9,7 @@ from rest_framework.serializers import Serializer from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UILoginButton, UserSettingSerializer +from authentik.flows.challenge import ChallengeTypes, RedirectChallenge if TYPE_CHECKING: from authentik.sources.oauth.types.manager import SourceType @@ -67,9 +68,14 @@ class OAuthSource(Source): @property def ui_login_button(self) -> UILoginButton: return UILoginButton( - url=reverse( - "authentik_sources_oauth:oauth-client-login", - kwargs={"source_slug": self.slug}, + challenge=RedirectChallenge( + instance={ + "type": ChallengeTypes.REDIRECT.value, + "to": reverse( + "authentik_sources_oauth:oauth-client-login", + kwargs={"source_slug": self.slug}, + ), + } ), icon_url=static(f"authentik/sources/{self.provider_type}.svg"), name=self.name, @@ -163,16 +169,6 @@ class OpenIDOAuthSource(OAuthSource): verbose_name_plural = _("OpenID OAuth Sources") -class PlexOAuthSource(OAuthSource): - """Login using plex.tv.""" - - class Meta: - - abstract = True - verbose_name = _("Plex OAuth Source") - verbose_name_plural = _("Plex OAuth Sources") - - class UserOAuthSourceConnection(UserSourceConnection): """Authorized remote OAuth provider.""" diff --git a/authentik/sources/oauth/settings.py b/authentik/sources/oauth/settings.py deleted file mode 100644 index 8585167fd..000000000 --- a/authentik/sources/oauth/settings.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Oauth2 Client Settings""" - -AUTHENTIK_SOURCES_OAUTH_TYPES = [ - "authentik.sources.oauth.types.discord", - "authentik.sources.oauth.types.facebook", - "authentik.sources.oauth.types.github", - "authentik.sources.oauth.types.google", - "authentik.sources.oauth.types.reddit", - "authentik.sources.oauth.types.twitter", - "authentik.sources.oauth.types.azure_ad", - "authentik.sources.oauth.types.oidc", - "authentik.sources.oauth.types.plex", -] diff --git a/authentik/sources/oauth/tests/test_type_discord.py b/authentik/sources/oauth/tests/test_type_discord.py index 6b337d1bd..86340afed 100644 --- a/authentik/sources/oauth/tests/test_type_discord.py +++ b/authentik/sources/oauth/tests/test_type_discord.py @@ -1,7 +1,7 @@ """Discord Type tests""" from django.test import TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.discord import DiscordOAuth2Callback # https://discord.com/developers/docs/resources/user#user-object @@ -33,9 +33,7 @@ class TestTypeDiscord(TestCase): def test_enroll_context(self): """Test discord Enrollment context""" - ak_context = DiscordOAuth2Callback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), DISCORD_USER - ) + ak_context = DiscordOAuth2Callback().get_user_enroll_context(DISCORD_USER) self.assertEqual(ak_context["username"], DISCORD_USER["username"]) self.assertEqual(ak_context["email"], DISCORD_USER["email"]) self.assertEqual(ak_context["name"], DISCORD_USER["username"]) diff --git a/authentik/sources/oauth/tests/test_type_github.py b/authentik/sources/oauth/tests/test_type_github.py index 3acce60fc..50a699b9c 100644 --- a/authentik/sources/oauth/tests/test_type_github.py +++ b/authentik/sources/oauth/tests/test_type_github.py @@ -1,7 +1,7 @@ """GitHub Type tests""" from django.test import TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.github import GitHubOAuth2Callback # https://developer.github.com/v3/users/#get-the-authenticated-user @@ -63,9 +63,7 @@ class TestTypeGitHub(TestCase): def test_enroll_context(self): """Test GitHub Enrollment context""" - ak_context = GitHubOAuth2Callback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), GITHUB_USER - ) + ak_context = GitHubOAuth2Callback().get_user_enroll_context(GITHUB_USER) self.assertEqual(ak_context["username"], GITHUB_USER["login"]) self.assertEqual(ak_context["email"], GITHUB_USER["email"]) self.assertEqual(ak_context["name"], GITHUB_USER["name"]) diff --git a/authentik/sources/oauth/tests/test_type_google.py b/authentik/sources/oauth/tests/test_type_google.py index 6f43812ad..6f79f8a2d 100644 --- a/authentik/sources/oauth/tests/test_type_google.py +++ b/authentik/sources/oauth/tests/test_type_google.py @@ -1,7 +1,7 @@ """google Type tests""" from django.test import TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.google import GoogleOAuth2Callback # https://developers.google.com/identity/protocols/oauth2/openid-connect?hl=en @@ -32,9 +32,7 @@ class TestTypeGoogle(TestCase): def test_enroll_context(self): """Test Google Enrollment context""" - ak_context = GoogleOAuth2Callback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), GOOGLE_USER - ) + ak_context = GoogleOAuth2Callback().get_user_enroll_context(GOOGLE_USER) self.assertEqual(ak_context["username"], GOOGLE_USER["email"]) self.assertEqual(ak_context["email"], GOOGLE_USER["email"]) self.assertEqual(ak_context["name"], GOOGLE_USER["name"]) diff --git a/authentik/sources/oauth/tests/test_type_twitter.py b/authentik/sources/oauth/tests/test_type_twitter.py index b0918fa62..84fdd0f80 100644 --- a/authentik/sources/oauth/tests/test_type_twitter.py +++ b/authentik/sources/oauth/tests/test_type_twitter.py @@ -1,7 +1,7 @@ """Twitter Type tests""" from django.test import Client, TestCase -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.twitter import TwitterOAuthCallback # https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/ \ @@ -104,9 +104,7 @@ class TestTypeGitHub(TestCase): def test_enroll_context(self): """Test Twitter Enrollment context""" - ak_context = TwitterOAuthCallback().get_user_enroll_context( - self.source, UserOAuthSourceConnection(), TWITTER_USER - ) + ak_context = TwitterOAuthCallback().get_user_enroll_context(TWITTER_USER) self.assertEqual(ak_context["username"], TWITTER_USER["screen_name"]) self.assertEqual(ak_context["email"], TWITTER_USER.get("email", None)) self.assertEqual(ak_context["name"], TWITTER_USER["name"]) diff --git a/authentik/sources/oauth/types/azure_ad.py b/authentik/sources/oauth/types/azure_ad.py index fbd81f08f..7d5dc02fb 100644 --- a/authentik/sources/oauth/types/azure_ad.py +++ b/authentik/sources/oauth/types/azure_ad.py @@ -2,7 +2,6 @@ from typing import Any, Optional from uuid import UUID -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback @@ -10,7 +9,7 @@ from authentik.sources.oauth.views.callback import OAuthCallback class AzureADOAuthCallback(OAuthCallback): """AzureAD OAuth2 Callback""" - def get_user_id(self, source: OAuthSource, info: dict[str, Any]) -> Optional[str]: + def get_user_id(self, info: dict[str, Any]) -> Optional[str]: try: return str(UUID(info.get("objectId")).int) except TypeError: @@ -18,8 +17,6 @@ class AzureADOAuthCallback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: mail = info.get("mail", None) or info.get("otherMails", [None])[0] diff --git a/authentik/sources/oauth/types/discord.py b/authentik/sources/oauth/types/discord.py index 00bac79fd..a97cca546 100644 --- a/authentik/sources/oauth/types/discord.py +++ b/authentik/sources/oauth/types/discord.py @@ -1,7 +1,6 @@ """Discord OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -21,8 +20,6 @@ class DiscordOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/facebook.py b/authentik/sources/oauth/types/facebook.py index ab27d6b6f..8efe16102 100644 --- a/authentik/sources/oauth/types/facebook.py +++ b/authentik/sources/oauth/types/facebook.py @@ -4,7 +4,6 @@ from typing import Any, Optional from facebook import GraphAPI from authentik.sources.oauth.clients.oauth2 import OAuth2Client -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -34,8 +33,6 @@ class FacebookOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/github.py b/authentik/sources/oauth/types/github.py index c830d4919..791e98912 100644 --- a/authentik/sources/oauth/types/github.py +++ b/authentik/sources/oauth/types/github.py @@ -1,7 +1,6 @@ """GitHub OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback @@ -11,8 +10,6 @@ class GitHubOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/google.py b/authentik/sources/oauth/types/google.py index e69004254..ee6bdf63f 100644 --- a/authentik/sources/oauth/types/google.py +++ b/authentik/sources/oauth/types/google.py @@ -1,7 +1,6 @@ """Google OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -21,8 +20,6 @@ class GoogleOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/oidc.py b/authentik/sources/oauth/types/oidc.py index e2acf4b63..01fae8dcd 100644 --- a/authentik/sources/oauth/types/oidc.py +++ b/authentik/sources/oauth/types/oidc.py @@ -1,7 +1,7 @@ """OpenID Connect OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -19,13 +19,11 @@ class OpenIDConnectOAuthRedirect(OAuthRedirect): class OpenIDConnectOAuth2Callback(OAuthCallback): """OpenIDConnect OAuth2 Callback""" - def get_user_id(self, source: OAuthSource, info: dict[str, str]) -> str: + def get_user_id(self, info: dict[str, str]) -> str: return info.get("sub", "") def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/plex.py b/authentik/sources/oauth/types/plex.py deleted file mode 100644 index d6e9914df..000000000 --- a/authentik/sources/oauth/types/plex.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Plex OAuth Views""" -from typing import Any, Optional -from urllib.parse import urlencode - -from django.http.response import Http404 -from requests import post -from requests.api import get -from requests.exceptions import RequestException -from structlog.stdlib import get_logger - -from authentik import __version__ -from authentik.sources.oauth.clients.oauth2 import OAuth2Client -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from authentik.sources.oauth.types.manager import MANAGER, SourceType -from authentik.sources.oauth.views.callback import OAuthCallback -from authentik.sources.oauth.views.redirect import OAuthRedirect - -LOGGER = get_logger() -SESSION_ID_KEY = "PLEX_ID" -SESSION_CODE_KEY = "PLEX_CODE" -DEFAULT_PAYLOAD = { - "X-Plex-Product": "authentik", - "X-Plex-Version": __version__, - "X-Plex-Device-Vendor": "BeryJu.org", -} - - -class PlexRedirect(OAuthRedirect): - """Plex Auth redirect, get a pin then redirect to a URL to claim it""" - - headers = {} - - def get_pin(self, **data) -> dict: - """Get plex pin that the user will claim - https://forums.plex.tv/t/authenticating-with-plex/609370""" - return post( - "https://plex.tv/api/v2/pins.json?strong=true", - data=data, - headers=self.headers, - ).json() - - def get_redirect_url(self, **kwargs) -> str: - slug = kwargs.get("source_slug", "") - self.headers = {"Origin": self.request.build_absolute_uri("/")} - try: - source: OAuthSource = OAuthSource.objects.get(slug=slug) - except OAuthSource.DoesNotExist: - raise Http404(f"Unknown OAuth source '{slug}'.") - else: - payload = DEFAULT_PAYLOAD.copy() - payload["X-Plex-Client-Identifier"] = source.consumer_key - # Get a pin first - pin = self.get_pin(**payload) - LOGGER.debug("Got pin", **pin) - self.request.session[SESSION_ID_KEY] = pin["id"] - self.request.session[SESSION_CODE_KEY] = pin["code"] - qs = { - "clientID": source.consumer_key, - "code": pin["code"], - "forwardUrl": self.request.build_absolute_uri( - self.get_callback_url(source) - ), - } - return f"https://app.plex.tv/auth#!?{urlencode(qs)}" - - -class PlexOAuthClient(OAuth2Client): - """Retrive the plex token after authentication, then ask the plex API about user info""" - - def check_application_state(self) -> bool: - return SESSION_ID_KEY in self.request.session - - def get_access_token(self, **request_kwargs) -> Optional[dict[str, Any]]: - payload = dict(DEFAULT_PAYLOAD) - payload["X-Plex-Client-Identifier"] = self.source.consumer_key - payload["Accept"] = "application/json" - response = get( - f"https://plex.tv/api/v2/pins/{self.request.session[SESSION_ID_KEY]}", - headers=payload, - ) - response.raise_for_status() - token = response.json()["authToken"] - return {"plex_token": token} - - def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]: - "Fetch user profile information." - qs = {"X-Plex-Token": token["plex_token"]} - try: - response = self.do_request( - "get", f"https://plex.tv/users/account.json?{urlencode(qs)}" - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch user profile", exc=exc) - return None - else: - return response.json().get("user", {}) - - -class PlexOAuth2Callback(OAuthCallback): - """Plex OAuth2 Callback""" - - client_class = PlexOAuthClient - - def get_user_id( - self, source: UserOAuthSourceConnection, info: dict[str, Any] - ) -> Optional[str]: - return info.get("uuid") - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> dict[str, Any]: - return { - "username": info.get("username"), - "email": info.get("email"), - "name": info.get("title"), - } - - -@MANAGER.type() -class PlexType(SourceType): - """Plex Type definition""" - - redirect_view = PlexRedirect - callback_view = PlexOAuth2Callback - name = "Plex" - slug = "plex" - - authorization_url = "" - access_token_url = "" # nosec - profile_url = "" diff --git a/authentik/sources/oauth/types/reddit.py b/authentik/sources/oauth/types/reddit.py index 74c777e6d..53757b38e 100644 --- a/authentik/sources/oauth/types/reddit.py +++ b/authentik/sources/oauth/types/reddit.py @@ -4,7 +4,6 @@ from typing import Any from requests.auth import HTTPBasicAuth from authentik.sources.oauth.clients.oauth2 import OAuth2Client -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback from authentik.sources.oauth.views.redirect import OAuthRedirect @@ -36,8 +35,6 @@ class RedditOAuth2Callback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/types/twitter.py b/authentik/sources/oauth/types/twitter.py index df1ed1a9f..b4df3d607 100644 --- a/authentik/sources/oauth/types/twitter.py +++ b/authentik/sources/oauth/types/twitter.py @@ -1,7 +1,6 @@ """Twitter OAuth Views""" from typing import Any -from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.types.manager import MANAGER, SourceType from authentik.sources.oauth.views.callback import OAuthCallback @@ -11,8 +10,6 @@ class TwitterOAuthCallback(OAuthCallback): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: return { diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index 5ca72c85e..fa6d9b735 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -4,35 +4,14 @@ from typing import Any, Optional from django.conf import settings from django.contrib import messages from django.http import Http404, HttpRequest, HttpResponse -from django.http.response import HttpResponseBadRequest from django.shortcuts import redirect -from django.urls import reverse from django.utils.translation import gettext as _ from django.views.generic import View from structlog.stdlib import get_logger -from authentik.core.models import User -from authentik.events.models import Event, EventAction -from authentik.flows.models import Flow, in_memory_stage -from authentik.flows.planner import ( - PLAN_CONTEXT_PENDING_USER, - PLAN_CONTEXT_REDIRECT, - PLAN_CONTEXT_SOURCE, - PLAN_CONTEXT_SSO, - FlowPlanner, -) -from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN -from authentik.lib.utils.urls import redirect_with_qs -from authentik.policies.utils import delete_none_keys -from authentik.sources.oauth.auth import AuthorizedServiceBackend +from authentik.core.sources.flow_manager import SourceFlowManager from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from authentik.sources.oauth.views.base import OAuthClientMixin -from authentik.sources.oauth.views.flows import ( - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS, - PostUserEnrollmentStage, -) -from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT LOGGER = get_logger() @@ -40,8 +19,7 @@ LOGGER = get_logger() class OAuthCallback(OAuthClientMixin, View): "Base OAuth callback view." - source_id = None - source = None + source: OAuthSource # pylint: disable=too-many-return-statements def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse: @@ -60,47 +38,27 @@ class OAuthCallback(OAuthClientMixin, View): # Fetch access token token = client.get_access_token() if token is None: - return self.handle_login_failure(self.source, "Could not retrieve token.") + return self.handle_login_failure("Could not retrieve token.") if "error" in token: - return self.handle_login_failure(self.source, token["error"]) + return self.handle_login_failure(token["error"]) # Fetch profile info - info = client.get_profile_info(token) - if info is None: - return self.handle_login_failure(self.source, "Could not retrieve profile.") - identifier = self.get_user_id(self.source, info) + raw_info = client.get_profile_info(token) + if raw_info is None: + return self.handle_login_failure("Could not retrieve profile.") + identifier = self.get_user_id(raw_info) if identifier is None: - return self.handle_login_failure(self.source, "Could not determine id.") + return self.handle_login_failure("Could not determine id.") # Get or create access record - defaults = { - "access_token": token.get("access_token"), - } - existing = UserOAuthSourceConnection.objects.filter( - source=self.source, identifier=identifier + enroll_info = self.get_user_enroll_context(raw_info) + sfm = OAuthSourceFlowManager( + source=self.source, + request=self.request, + identifier=identifier, + enroll_info=enroll_info, ) - - if existing.exists(): - connection = existing.first() - connection.access_token = token.get("access_token") - UserOAuthSourceConnection.objects.filter(pk=connection.pk).update( - **defaults - ) - else: - connection = UserOAuthSourceConnection( - source=self.source, - identifier=identifier, - access_token=token.get("access_token"), - ) - user = AuthorizedServiceBackend().authenticate( - source=self.source, identifier=identifier, request=request + return sfm.get_flow( + access_token=token.get("access_token"), ) - if user is None: - if self.request.user.is_authenticated: - LOGGER.debug("Linking existing user", source=self.source) - return self.handle_existing_user_link(self.source, connection, info) - LOGGER.debug("Handling enrollment of new user", source=self.source) - return self.handle_enroll(self.source, connection, info) - LOGGER.debug("Handling existing user", source=self.source) - return self.handle_existing_user(self.source, user, connection, info) # pylint: disable=unused-argument def get_callback_url(self, source: OAuthSource) -> str: @@ -114,132 +72,35 @@ class OAuthCallback(OAuthClientMixin, View): def get_user_enroll_context( self, - source: OAuthSource, - access: UserOAuthSourceConnection, info: dict[str, Any], ) -> dict[str, Any]: """Create a dict of User data""" raise NotImplementedError() # pylint: disable=unused-argument - def get_user_id( - self, source: UserOAuthSourceConnection, info: dict[str, Any] - ) -> Optional[str]: + def get_user_id(self, info: dict[str, Any]) -> Optional[str]: """Return unique identifier from the profile info.""" if "id" in info: return info["id"] return None - def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse: + def handle_login_failure(self, reason: str) -> HttpResponse: "Message user and redirect on error." LOGGER.warning("Authentication Failure", reason=reason) messages.error(self.request, _("Authentication Failed.")) - return redirect(self.get_error_redirect(source, reason)) + return redirect(self.get_error_redirect(self.source, reason)) - def handle_login_flow( - self, flow: Flow, *stages_to_append, **kwargs - ) -> HttpResponse: - """Prepare Authentication Plan, redirect user FlowExecutor""" - # Ensure redirect is carried through when user was trying to - # authorize application - final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( - NEXT_ARG_NAME, "authentik_core:if-admin" - ) - kwargs.update( - { - # Since we authenticate the user by their token, they have no backend set - PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", - PLAN_CONTEXT_SSO: True, - PLAN_CONTEXT_SOURCE: self.source, - PLAN_CONTEXT_REDIRECT: final_redirect, - } - ) - if not flow: - return HttpResponseBadRequest() - # We run the Flow planner here so we can pass the Pending user in the context - planner = FlowPlanner(flow) - plan = planner.plan(self.request, kwargs) - for stage in stages_to_append: - plan.append(stage) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "authentik_core:if-flow", - self.request.GET, - flow_slug=flow.slug, - ) - # pylint: disable=unused-argument - def handle_existing_user( +class OAuthSourceFlowManager(SourceFlowManager): + """Flow manager for oauth sources""" + + connection_type = UserOAuthSourceConnection + + def update_connection( self, - source: OAuthSource, - user: User, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> HttpResponse: - "Login user and redirect." - messages.success( - self.request, - _( - "Successfully authenticated with %(source)s!" - % {"source": self.source.name} - ), - ) - flow_kwargs = {PLAN_CONTEXT_PENDING_USER: user} - return self.handle_login_flow(source.authentication_flow, **flow_kwargs) - - def handle_existing_user_link( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> HttpResponse: - """Handler when the user was already authenticated and linked an external source - to their account.""" - # there's already a user logged in, just link them up - user = self.request.user - access.user = user - access.save() - UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) - Event.new( - EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source - ).from_http(self.request) - messages.success( - self.request, - _("Successfully linked %(source)s!" % {"source": self.source.name}), - ) - return redirect( - reverse( - "authentik_core:if-admin", - ) - + f"#/user;page-{self.source.slug}" - ) - - def handle_enroll( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: dict[str, Any], - ) -> HttpResponse: - """User was not authenticated and previous request was not authenticated.""" - messages.success( - self.request, - _( - "Successfully authenticated with %(source)s!" - % {"source": self.source.name} - ), - ) - - # We run the Flow planner here so we can pass the Pending user in the context - if not source.enrollment_flow: - LOGGER.warning("source has no enrollment flow", source=source) - return HttpResponseBadRequest() - return self.handle_login_flow( - source.enrollment_flow, - in_memory_stage(PostUserEnrollmentStage), - **{ - PLAN_CONTEXT_PROMPT: delete_none_keys( - self.get_user_enroll_context(source, access, info) - ), - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access, - }, - ) + connection: UserOAuthSourceConnection, + access_token: Optional[str] = None, + ) -> UserOAuthSourceConnection: + """Set the access_token on the connection""" + connection.access_token = access_token + return connection diff --git a/authentik/sources/plex/__init__.py b/authentik/sources/plex/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/sources/plex/api.py b/authentik/sources/plex/api.py new file mode 100644 index 000000000..2a923d383 --- /dev/null +++ b/authentik/sources/plex/api.py @@ -0,0 +1,75 @@ +"""Plex Source Serializer""" +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework.decorators import action +from rest_framework.fields import CharField +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from authentik.api.decorators import permission_required +from authentik.core.api.sources import SourceSerializer +from authentik.core.api.utils import PassiveSerializer +from authentik.flows.challenge import RedirectChallenge +from authentik.flows.views import to_stage_response +from authentik.sources.plex.models import PlexSource +from authentik.sources.plex.plex import PlexAuth + + +class PlexSourceSerializer(SourceSerializer): + """Plex Source Serializer""" + + class Meta: + model = PlexSource + fields = SourceSerializer.Meta.fields + ["client_id", "allowed_servers"] + + +class PlexTokenRedeemSerializer(PassiveSerializer): + """Serializer to redeem a plex token""" + + plex_token = CharField() + + +class PlexSourceViewSet(ModelViewSet): + """Plex source Viewset""" + + queryset = PlexSource.objects.all() + serializer_class = PlexSourceSerializer + lookup_field = "slug" + + @permission_required(None) + @swagger_auto_schema( + request_body=PlexTokenRedeemSerializer(), + responses={200: RedirectChallenge(), 404: "Token not found"}, + manual_parameters=[ + openapi.Parameter( + name="slug", + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + ) + ], + ) + @action( + methods=["POST"], + detail=False, + pagination_class=None, + filter_backends=[], + permission_classes=[AllowAny], + ) + def redeem_token(self, request: Request) -> Response: + """Redeem a plex token, check it's access to resources against what's allowed + for the source, and redirect to an authentication/enrollment flow.""" + source: PlexSource = get_object_or_404( + PlexSource, slug=request.query_params.get("slug", "") + ) + plex_token = request.data.get("plex_token", None) + if not plex_token: + raise Http404 + auth_api = PlexAuth(source, plex_token) + if not auth_api.check_server_overlap(): + raise Http404 + response = auth_api.get_user_url(request) + return to_stage_response(request, response) diff --git a/authentik/sources/plex/apps.py b/authentik/sources/plex/apps.py new file mode 100644 index 000000000..a8c89447b --- /dev/null +++ b/authentik/sources/plex/apps.py @@ -0,0 +1,10 @@ +"""authentik plex config""" +from django.apps import AppConfig + + +class AuthentikSourcePlexConfig(AppConfig): + """authentik source plex config""" + + name = "authentik.sources.plex" + label = "authentik_sources_plex" + verbose_name = "authentik Sources.Plex" diff --git a/authentik/sources/plex/migrations/0001_initial.py b/authentik/sources/plex/migrations/0001_initial.py new file mode 100644 index 000000000..c5b87959b --- /dev/null +++ b/authentik/sources/plex/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 3.2 on 2021-05-03 18:59 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0020_source_user_matching_mode"), + ] + + operations = [ + migrations.CreateModel( + name="PlexSource", + fields=[ + ( + "source_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.source", + ), + ), + ( + "client_id", + models.TextField( + default="yOuPQQvgNfBGreZZ38WoOY1d3qk3Xso2AuQHi6RG", + help_text="Client identifier used to talk to Plex.", + ), + ), + ( + "allowed_servers", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), + default=list, + help_text="Which servers a user has to be a member of to be granted access. Empty list allows every server.", + size=None, + ), + ), + ], + options={ + "verbose_name": "Plex Source", + "verbose_name_plural": "Plex Sources", + }, + bases=("authentik_core.source",), + ), + migrations.CreateModel( + name="PlexSourceConnection", + fields=[ + ( + "usersourceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.usersourceconnection", + ), + ), + ("plex_token", models.TextField()), + ("identifier", models.TextField()), + ], + options={ + "verbose_name": "User Plex Source Connection", + "verbose_name_plural": "User Plex Source Connections", + }, + bases=("authentik_core.usersourceconnection",), + ), + ] diff --git a/authentik/sources/plex/migrations/__init__.py b/authentik/sources/plex/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py new file mode 100644 index 000000000..f7d48a146 --- /dev/null +++ b/authentik/sources/plex/models.py @@ -0,0 +1,80 @@ +"""Plex source""" +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ +from rest_framework.fields import CharField +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.providers.oauth2.generators import generate_client_id + + +class PlexAuthenticationChallenge(Challenge): + """Challenge shown to the user in identification stage""" + + client_id = CharField() + slug = CharField() + + +class PlexSource(Source): + """Authenticate against plex.tv""" + + client_id = models.TextField( + default=generate_client_id(), + help_text=_("Client identifier used to talk to Plex."), + ) + allowed_servers = ArrayField( + models.TextField(), + default=list, + help_text=_( + ( + "Which servers a user has to be a member of to be granted access. " + "Empty list allows every server." + ) + ), + ) + + @property + def component(self) -> str: + return "ak-source-plex-form" + + @property + def serializer(self) -> BaseSerializer: + from authentik.sources.plex.api import PlexSourceSerializer + + return PlexSourceSerializer + + @property + def ui_login_button(self) -> UILoginButton: + return UILoginButton( + challenge=PlexAuthenticationChallenge( + { + "type": ChallengeTypes.NATIVE.value, + "component": "ak-flow-sources-plex", + "client_id": self.client_id, + "slug": self.slug, + } + ), + icon_url=static("authentik/sources/plex.svg"), + name=self.name, + ) + + class Meta: + + verbose_name = _("Plex Source") + verbose_name_plural = _("Plex Sources") + + +class PlexSourceConnection(UserSourceConnection): + """Connect user and plex source""" + + plex_token = models.TextField() + identifier = models.TextField() + + class Meta: + + verbose_name = _("User Plex Source Connection") + verbose_name_plural = _("User Plex Source Connections") diff --git a/authentik/sources/plex/plex.py b/authentik/sources/plex/plex.py new file mode 100644 index 000000000..dc9c52d16 --- /dev/null +++ b/authentik/sources/plex/plex.py @@ -0,0 +1,112 @@ +"""Plex Views""" +from urllib.parse import urlencode + +from django.http.request import HttpRequest +from django.http.response import Http404, HttpResponse +from requests import Session +from requests.exceptions import RequestException +from structlog.stdlib import get_logger + +from authentik import __version__ +from authentik.core.sources.flow_manager import SourceFlowManager +from authentik.sources.plex.models import PlexSource, PlexSourceConnection + +LOGGER = get_logger() +SESSION_ID_KEY = "PLEX_ID" +SESSION_CODE_KEY = "PLEX_CODE" + + +class PlexAuth: + """Plex authentication utilities""" + + _source: PlexSource + _token: str + + def __init__(self, source: PlexSource, token: str): + self._source = source + self._token = token + self._session = Session() + self._session.headers.update( + {"Accept": "application/json", "Content-Type": "application/json"} + ) + self._session.headers.update(self.headers) + + @property + def headers(self) -> dict[str, str]: + """Get common headers""" + return { + "X-Plex-Product": "authentik", + "X-Plex-Version": __version__, + "X-Plex-Device-Vendor": "BeryJu.org", + } + + def get_resources(self) -> list[dict]: + """Get all resources the plex-token has access to""" + qs = { + "X-Plex-Token": self._token, + "X-Plex-Client-Identifier": self._source.client_id, + } + response = self._session.get( + f"https://plex.tv/api/v2/resources?{urlencode(qs)}", + ) + response.raise_for_status() + return response.json() + + def get_user_info(self) -> tuple[dict, int]: + """Get user info of the plex token""" + qs = { + "X-Plex-Token": self._token, + "X-Plex-Client-Identifier": self._source.client_id, + } + response = self._session.get( + f"https://plex.tv/api/v2/user?{urlencode(qs)}", + ) + response.raise_for_status() + raw_user_info = response.json() + return { + "username": raw_user_info.get("username"), + "email": raw_user_info.get("email"), + "name": raw_user_info.get("title"), + }, raw_user_info.get("id") + + def check_server_overlap(self) -> bool: + """Check if the plex-token has any server overlap with our configured servers""" + try: + resources = self.get_resources() + except RequestException as exc: + LOGGER.warning("Unable to fetch user resources", exc=exc) + raise Http404 + else: + for resource in resources: + if resource["provides"] != "server": + continue + if resource["clientIdentifier"] in self._source.allowed_servers: + LOGGER.info( + "Plex allowed access from server", name=resource["name"] + ) + return True + return False + + def get_user_url(self, request: HttpRequest) -> HttpResponse: + """Get a URL to a flow executor for either enrollment or authentication""" + user_info, identifier = self.get_user_info() + sfm = PlexSourceFlowManager( + source=self._source, + request=request, + identifier=str(identifier), + enroll_info=user_info, + ) + return sfm.get_flow(plex_token=self._token) + + +class PlexSourceFlowManager(SourceFlowManager): + """Flow manager for plex sources""" + + connection_type = PlexSourceConnection + + def update_connection( + self, connection: PlexSourceConnection, plex_token: str + ) -> PlexSourceConnection: + """Set the access_token on the connection""" + connection.plex_token = plex_token + return connection diff --git a/authentik/sources/plex/tests.py b/authentik/sources/plex/tests.py new file mode 100644 index 000000000..eb2ab30ce --- /dev/null +++ b/authentik/sources/plex/tests.py @@ -0,0 +1,64 @@ +"""plex Source tests""" +from django.test import TestCase +from requests_mock import Mocker + +from authentik.providers.oauth2.generators import generate_client_secret +from authentik.sources.plex.models import PlexSource +from authentik.sources.plex.plex import PlexAuth + +USER_INFO_RESPONSE = { + "id": 1234123419, + "uuid": "qwerqewrqewrqwr", + "username": "username", + "title": "title", + "email": "foo@bar.baz", +} +RESOURCES_RESPONSE = [ + { + "name": "foo", + "clientIdentifier": "allowed", + "provides": "server", + }, + { + "name": "foo", + "clientIdentifier": "denied", + "provides": "server", + }, +] + + +class TestPlexSource(TestCase): + """plex Source tests""" + + def setUp(self): + self.source: PlexSource = PlexSource.objects.create( + name="test", + slug="test", + ) + + def test_get_user_info(self): + """Test get_user_info""" + token = generate_client_secret() + api = PlexAuth(self.source, token) + with Mocker() as mocker: + mocker.get("https://plex.tv/api/v2/user", json=USER_INFO_RESPONSE) + self.assertEqual( + api.get_user_info(), + ( + {"username": "username", "email": "foo@bar.baz", "name": "title"}, + 1234123419, + ), + ) + + def test_check_server_overlap(self): + """Test check_server_overlap""" + token = generate_client_secret() + api = PlexAuth(self.source, token) + with Mocker() as mocker: + mocker.get("https://plex.tv/api/v2/resources", json=RESOURCES_RESPONSE) + self.assertFalse(api.check_server_overlap()) + self.source.allowed_servers = ["allowed"] + self.source.save() + with Mocker() as mocker: + mocker.get("https://plex.tv/api/v2/resources", json=RESOURCES_RESPONSE) + self.assertTrue(api.check_server_overlap()) diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index d35685aac..bd7087173 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -10,6 +10,7 @@ from rest_framework.serializers import Serializer from authentik.core.models import Source from authentik.core.types import UILoginButton from authentik.crypto.models import CertificateKeyPair +from authentik.flows.challenge import ChallengeTypes, RedirectChallenge from authentik.flows.models import Flow from authentik.lib.utils.time import timedelta_string_validator from authentik.sources.saml.processors.constants import ( @@ -169,10 +170,16 @@ class SAMLSource(Source): @property def ui_login_button(self) -> UILoginButton: return UILoginButton( - name=self.name, - url=reverse( - "authentik_sources_saml:login", kwargs={"source_slug": self.slug} + challenge=RedirectChallenge( + instance={ + "type": ChallengeTypes.REDIRECT.value, + "to": reverse( + "authentik_sources_saml:login", + kwargs={"source_slug": self.slug}, + ), + } ), + name=self.name, ) def __str__(self): diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index f5b3bfc90..625546c0f 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -112,7 +112,9 @@ class IdentificationStageView(ChallengeStageView): for source in sources: ui_login_button = source.ui_login_button if ui_login_button: - ui_sources.append(asdict(ui_login_button)) + button = asdict(ui_login_button) + button["challenge"] = ui_login_button.challenge.data + ui_sources.append(button) challenge.initial_data["sources"] = ui_sources return challenge diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py index 1c8fe74ac..64c051ddc 100644 --- a/authentik/stages/identification/tests.py +++ b/authentik/stages/identification/tests.py @@ -117,7 +117,10 @@ class TestIdentificationStage(TestCase): { "icon_url": "/static/authentik/sources/.svg", "name": "test", - "url": "/source/oauth/login/test/", + "challenge": { + "to": "/source/oauth/login/test/", + "type": "redirect", + }, } ], }, @@ -158,9 +161,12 @@ class TestIdentificationStage(TestCase): "title": self.flow.title, "sources": [ { + "challenge": { + "to": "/source/oauth/login/test/", + "type": "redirect", + }, "icon_url": "/static/authentik/sources/.svg", "name": "test", - "url": "/source/oauth/login/test/", } ], }, diff --git a/swagger.yaml b/swagger.yaml index c2509ca1d..4e3173804 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -10213,6 +10213,238 @@ paths: description: A unique integer value identifying this User OAuth Source Connection. required: true type: integer + /sources/plex/: + get: + operationId: sources_plex_list + description: Plex source Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: Page Index + required: false + type: integer + - name: page_size + in: query + description: Page Size + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - results + - pagination + type: object + properties: + pagination: + required: + - next + - previous + - count + - current + - total_pages + - start_index + - end_index + 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 + results: + type: array + items: + $ref: '#/definitions/PlexSource' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - sources + post: + operationId: sources_plex_create + description: Plex source Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexSource' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - sources + parameters: [] + /sources/plex/redeem_token/: + post: + operationId: sources_plex_redeem_token + description: |- + Redeem a plex token, check it's access to resources against what's allowed + for the source, and redirect to an authentication/enrollment flow. + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexTokenRedeem' + - name: slug + in: query + type: string + responses: + '200': + description: '' + schema: + $ref: '#/definitions/RedirectChallenge' + '404': + description: Token not found + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + tags: + - sources + parameters: [] + /sources/plex/{slug}/: + get: + operationId: sources_plex_read + description: Plex source Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + put: + operationId: sources_plex_update + description: Plex source Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexSource' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + patch: + operationId: sources_plex_partial_update + description: Plex source Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/PlexSource' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/PlexSource' + '400': + description: Invalid input. + schema: + $ref: '#/definitions/ValidationError' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + delete: + operationId: sources_plex_delete + description: Plex source Viewset + parameters: [] + responses: + '204': + description: '' + '403': + description: Authentication credentials were invalid, absent or insufficient. + schema: + $ref: '#/definitions/GenericError' + '404': + description: Object does not exist or caller has insufficient permissions + to access it. + schema: + $ref: '#/definitions/APIException' + tags: + - sources + parameters: + - name: slug + in: path + description: Internal source name, used in URLs. + required: true + type: string + format: slug + pattern: ^[-a-zA-Z0-9_]+$ /sources/saml/: get: operationId: sources_saml_list @@ -16210,6 +16442,7 @@ definitions: - authentik.recovery - authentik.sources.ldap - authentik.sources.oauth + - authentik.sources.plex - authentik.sources.saml - authentik.stages.authenticator_static - authentik.stages.authenticator_totp @@ -17056,6 +17289,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny UserSetting: required: - object_uid @@ -17136,6 +17380,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny server_uri: title: Server URI type: string @@ -17316,6 +17571,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny provider_type: title: Provider type type: string @@ -17386,6 +17652,132 @@ definitions: type: string maxLength: 255 minLength: 1 + PlexSource: + required: + - name + - slug + type: object + properties: + pk: + title: Pbm uuid + type: string + format: uuid + readOnly: true + name: + title: Name + description: Source's display Name. + type: string + minLength: 1 + slug: + title: Slug + description: Internal source name, used in URLs. + type: string + format: slug + pattern: ^[-a-zA-Z0-9_]+$ + maxLength: 50 + minLength: 1 + enabled: + title: Enabled + type: boolean + authentication_flow: + title: Authentication flow + description: Flow to use when authenticating existing users. + type: string + format: uuid + x-nullable: true + enrollment_flow: + title: Enrollment flow + description: Flow to use when enrolling new users. + type: string + format: uuid + x-nullable: true + component: + title: Component + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true + policy_engine_mode: + title: Policy engine mode + type: string + enum: + - all + - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny + client_id: + title: Client id + description: Client identifier used to talk to Plex. + type: string + minLength: 1 + allowed_servers: + description: Which servers a user has to be a member of to be granted access. + Empty list allows every server. + type: array + items: + title: Allowed servers + type: string + minLength: 1 + PlexTokenRedeem: + required: + - plex_token + type: object + properties: + plex_token: + title: Plex token + type: string + minLength: 1 + RedirectChallenge: + required: + - type + - to + type: object + properties: + type: + title: Type + type: string + enum: + - native + - shell + - redirect + component: + title: Component + type: string + minLength: 1 + title: + title: Title + type: string + minLength: 1 + background: + title: Background + type: string + minLength: 1 + response_errors: + title: Response errors + type: object + additionalProperties: + type: array + items: + $ref: '#/definitions/ErrorDetail' + to: + title: To + type: string + minLength: 1 SAMLSource: required: - name @@ -17445,6 +17837,17 @@ definitions: enum: - all - any + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should be authenticated + or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny pre_authentication_flow: title: Pre authentication flow description: Flow used before authentication. @@ -18190,6 +18593,17 @@ definitions: enabled: title: Enabled type: boolean + user_matching_mode: + title: User matching mode + description: How the source determines if an existing user should + be authenticated or a new user enrolled. + type: string + enum: + - identifier + - email_link + - email_deny + - username_link + - username_deny authentication_flow: title: Authentication flow description: Flow to use when authenticating existing users. diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py index faeeea221..a26d9c368 100644 --- a/tests/e2e/test_source_oauth.py +++ b/tests/e2e/test_source_oauth.py @@ -147,11 +147,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -206,11 +206,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -245,11 +245,11 @@ class TestSourceOAuth2(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field @@ -338,17 +338,18 @@ class TestSourceOAuth1(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the login field self.wait.until(ec.presence_of_element_located((By.NAME, "username"))) self.driver.find_element(By.NAME, "username").send_keys("example-user") self.driver.find_element(By.NAME, "username").send_keys(Keys.ENTER) + sleep(2) # Wait until we're logged in self.wait.until( diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index 8fbfbfccd..7a409c484 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -140,11 +140,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the username field @@ -208,11 +208,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() sleep(1) @@ -289,11 +289,11 @@ class TestSourceSAML(SeleniumTestCase): wait.until( ec.presence_of_element_located( - (By.CLASS_NAME, "pf-c-login__main-footer-links-item-link") + (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") ) ) identification_stage.find_element( - By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" + By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" ).click() # Now we should be at the IDP, wait for the username field diff --git a/web/.eslintrc.json b/web/.eslintrc.json index 39bca2824..82e527d4f 100644 --- a/web/.eslintrc.json +++ b/web/.eslintrc.json @@ -6,7 +6,8 @@ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", - "plugin:lit/recommended" + "plugin:lit/recommended", + "plugin:custom-elements/recommended" ], "parser": "@typescript-eslint/parser", "parserOptions": { @@ -15,7 +16,8 @@ }, "plugins": [ "@typescript-eslint", - "lit" + "lit", + "custom-elements" ], "rules": { "indent": "off", diff --git a/web/azure-pipelines.yml b/web/azure-pipelines.yml index 4352991cf..4841452d9 100644 --- a/web/azure-pipelines.yml +++ b/web/azure-pipelines.yml @@ -18,7 +18,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: CmdLine@2 inputs: @@ -37,7 +37,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: DownloadPipelineArtifact@2 inputs: @@ -59,7 +59,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: DownloadPipelineArtifact@2 inputs: @@ -83,7 +83,7 @@ stages: steps: - task: NodeTool@0 inputs: - versionSpec: '12.x' + versionSpec: '14.x' displayName: 'Install Node.js' - task: DownloadPipelineArtifact@2 inputs: diff --git a/web/package-lock.json b/web/package-lock.json index 71f47e181..41d9f6731 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3723,6 +3723,14 @@ "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz", "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==" }, + "eslint-plugin-custom-elements": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-custom-elements/-/eslint-plugin-custom-elements-0.0.2.tgz", + "integrity": "sha512-lIRBhxh0M/1seyMzSPJwdfdNtlVSPArJ+erF2xqjPsd/6SdCuT43hCQNV2A2te3GqBWhgh/unXSVRO09c1kyPA==", + "requires": { + "eslint-rule-documentation": ">=1.0.0" + } + }, "eslint-plugin-lit": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-lit/-/eslint-plugin-lit-1.3.0.tgz", @@ -3733,6 +3741,11 @@ "requireindex": "^1.2.0" } }, + "eslint-rule-documentation": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/eslint-rule-documentation/-/eslint-rule-documentation-1.0.23.tgz", + "integrity": "sha512-pWReu3fkohwyvztx/oQWWgld2iad25TfUdi6wvhhaDPIQjHU/pyvlKgXFw1kX31SQK2Nq9MH+vRDWB0ZLy8fYw==" + }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/web/package.json b/web/package.json index 144efa4b1..08062e76e 100644 --- a/web/package.json +++ b/web/package.json @@ -67,6 +67,7 @@ "construct-style-sheets-polyfill": "^2.4.16", "eslint": "^7.25.0", "eslint-config-google": "^0.14.0", + "eslint-plugin-custom-elements": "0.0.2", "eslint-plugin-lit": "^1.3.0", "flowchart.js": "^1.15.0", "lit-element": "^2.5.0", diff --git a/web/src/authentik.css b/web/src/authentik.css index afcebb269..35b0caea0 100644 --- a/web/src/authentik.css +++ b/web/src/authentik.css @@ -272,7 +272,7 @@ body { .pf-c-login__main-header-desc { color: var(--ak-dark-foreground); } - .pf-c-login__main-footer-links-item-link > img { + .pf-c-login__main-footer-links-item img { filter: invert(1); } .pf-c-login__main-footer-band { diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts index 2e44b01e7..6338450ba 100644 --- a/web/src/flows/FlowExecutor.ts +++ b/web/src/flows/FlowExecutor.ts @@ -23,6 +23,7 @@ import "./stages/email/EmailStage"; 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"; @@ -44,6 +45,7 @@ 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 { @@ -223,6 +225,8 @@ export class FlowExecutor extends LitElement implements StageHost { return html``; case "ak-stage-authenticator-validate": return html``; + case "ak-flow-sources-plex": + return html``; default: break; } diff --git a/web/src/flows/sources/plex/API.ts b/web/src/flows/sources/plex/API.ts new file mode 100644 index 000000000..be7a46d59 --- /dev/null +++ b/web/src/flows/sources/plex/API.ts @@ -0,0 +1,95 @@ +import { VERSION } from "../../../constants"; + +export interface PlexPinResponse { + // Only has the fields we care about + authToken?: string; + code: string; + id: number; +} + +export interface PlexResource { + name: string; + provides: string; + clientIdentifier: string; +} + +export const DEFAULT_HEADERS = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Plex-Product": "authentik", + "X-Plex-Version": VERSION, + "X-Plex-Device-Vendor": "BeryJu.org", +}; + +export function popupCenterScreen(url: string, title: string, w: number, h: number): Window | null { + const top = (screen.height - h) / 4, left = (screen.width - w) / 2; + const popup = window.open(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`); + return popup; +} + +export class PlexAPIClient { + + token: string; + + constructor(token: string) { + this.token = token; + } + + static async getPin(clientIdentifier: string): Promise<{ authUrl: string, pin: PlexPinResponse }> { + const headers = { ...DEFAULT_HEADERS, ...{ + "X-Plex-Client-Identifier": clientIdentifier + }}; + const pinResponse = await fetch("https://plex.tv/api/v2/pins.json?strong=true", { + method: "POST", + headers: headers + }); + const pin: PlexPinResponse = await pinResponse.json(); + return { + authUrl: `https://app.plex.tv/auth#!?clientID=${encodeURIComponent(clientIdentifier)}&code=${pin.code}`, + pin: pin + }; + } + + static async pinStatus(clientIdentifier: string, id: number): Promise { + const headers = { ...DEFAULT_HEADERS, ...{ + "X-Plex-Client-Identifier": clientIdentifier + }}; + const pinResponse = await fetch(`https://plex.tv/api/v2/pins/${id}`, { + headers: headers + }); + const pin: PlexPinResponse = await pinResponse.json(); + return pin.authToken || ""; + } + + static async pinPoll(clientIdentifier: string, id: number): Promise { + const executePoll = async ( + resolve: (authToken: string) => void, + reject: (e: Error) => void + ) => { + try { + const response = await PlexAPIClient.pinStatus(clientIdentifier, id); + + if (response) { + resolve(response); + } else { + setTimeout(executePoll, 500, resolve, reject); + } + } catch (e) { + reject(e); + } + }; + + return new Promise(executePoll); + } + + async getServers(): Promise { + const resourcesResponse = await fetch(`https://plex.tv/api/v2/resources?X-Plex-Token=${this.token}&X-Plex-Client-Identifier=authentik`, { + headers: DEFAULT_HEADERS + }); + const resources: PlexResource[] = await resourcesResponse.json(); + return resources.filter(r => { + return r.provides === "server"; + }); + } + +} diff --git a/web/src/flows/sources/plex/PlexLoginInit.ts b/web/src/flows/sources/plex/PlexLoginInit.ts new file mode 100644 index 000000000..a968f8293 --- /dev/null +++ b/web/src/flows/sources/plex/PlexLoginInit.ts @@ -0,0 +1,69 @@ +import { t } from "@lingui/macro"; +import { Challenge } from "authentik-api"; +import PFLogin from "@patternfly/patternfly/components/Login/login.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import AKGlobal from "../../../authentik.css"; +import { CSSResult, customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { BaseStage } from "../../stages/base"; +import { PlexAPIClient, popupCenterScreen } from "./API"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { SourcesApi } from "authentik-api"; + +export interface PlexAuthenticationChallenge extends Challenge { + + client_id: string; + slug: string; + +} + +@customElement("ak-flow-sources-plex") +export class PlexLoginInit extends BaseStage { + + @property({ attribute: false }) + challenge?: PlexAuthenticationChallenge; + + static get styles(): CSSResult[] { + return [PFBase, PFLogin, PFForm, PFFormControl, PFButton, PFTitle, AKGlobal]; + } + + async firstUpdated(): Promise { + const authInfo = await PlexAPIClient.getPin(this.challenge?.client_id || ""); + const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); + PlexAPIClient.pinPoll(this.challenge?.client_id || "", authInfo.pin.id).then(token => { + authWindow?.close(); + new SourcesApi(DEFAULT_CONFIG).sourcesPlexRedeemToken({ + data: { + plexToken: token, + }, + slug: this.challenge?.slug || "", + }).then(r => { + window.location.assign(r.to); + }); + }); + } + + render(): TemplateResult { + return html` + + ${t`Authenticating with Plex...`} + + + + + + + + + `; + } + +} diff --git a/web/src/flows/stages/base.ts b/web/src/flows/stages/base.ts index 56ef98498..6815d652c 100644 --- a/web/src/flows/stages/base.ts +++ b/web/src/flows/stages/base.ts @@ -1,6 +1,8 @@ +import { Challenge } from "authentik-api"; import { LitElement } from "lit-element"; export interface StageHost { + challenge?: Challenge; submit(formData?: T): Promise; } diff --git a/web/src/flows/stages/identification/IdentificationStage.ts b/web/src/flows/stages/identification/IdentificationStage.ts index 8dc3035a6..a4e7ba371 100644 --- a/web/src/flows/stages/identification/IdentificationStage.ts +++ b/web/src/flows/stages/identification/IdentificationStage.ts @@ -35,7 +35,7 @@ export interface IdentificationChallenge extends Challenge { export interface UILoginButton { name: string; - url: string; + challenge: Challenge; icon_url?: string; } @@ -49,7 +49,11 @@ export class IdentificationStage extends BaseStage { return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat( css` /* login page's icons */ - .pf-c-login__main-footer-links-item-link img { + .pf-c-login__main-footer-links-item button { + background-color: transparent; + border: 0; + } + .pf-c-login__main-footer-links-item img { fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill); width: 100px; max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width); @@ -131,9 +135,12 @@ export class IdentificationStage extends BaseStage { icon = html``; } return html` - + { + if (!this.host) return; + this.host.challenge = source.challenge; + }}> ${icon} - + `; } diff --git a/web/src/locales/en.po b/web/src/locales/en.po index 40906aa58..6e753cb64 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -156,6 +156,10 @@ msgstr "Allow users to use Applications based on properties, enforce Password Cr msgid "Allowed count" msgstr "Allowed count" +#: src/pages/sources/plex/PlexSourceForm.ts:119 +msgid "Allowed servers" +msgstr "Allowed servers" + #: src/pages/sources/saml/SAMLSourceForm.ts:144 msgid "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done." msgstr "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done." @@ -277,11 +281,16 @@ msgstr "Attributes" msgid "Audience" msgstr "Audience" +#: src/flows/sources/plex/PlexLoginInit.ts:56 +msgid "Authenticating with Plex..." +msgstr "Authenticating with Plex..." + #: src/pages/flows/FlowForm.ts:55 msgid "Authentication" msgstr "Authentication" #: src/pages/sources/oauth/OAuthSourceForm.ts:189 +#: src/pages/sources/plex/PlexSourceForm.ts:149 #: src/pages/sources/saml/SAMLSourceForm.ts:245 msgid "Authentication flow" msgstr "Authentication flow" @@ -395,8 +404,8 @@ msgstr "Binding Type" msgid "Build hash: {0}" msgstr "Build hash: {0}" -#: src/pages/sources/SourcesListPage.ts:103 -#: src/pages/sources/SourcesListPage.ts:105 +#: src/pages/sources/SourcesListPage.ts:104 +#: src/pages/sources/SourcesListPage.ts:106 msgid "Built-in" msgstr "Built-in" @@ -544,6 +553,7 @@ msgstr "Click to copy token" #: src/pages/providers/oauth2/OAuth2ProviderForm.ts:107 #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:99 +#: src/pages/sources/plex/PlexSourceForm.ts:113 msgid "Client ID" msgstr "Client ID" @@ -744,8 +754,8 @@ msgstr "Copy Key" #: src/pages/providers/ProviderListPage.ts:116 #: src/pages/providers/RelatedApplicationButton.ts:27 #: src/pages/providers/RelatedApplicationButton.ts:35 -#: src/pages/sources/SourcesListPage.ts:113 -#: src/pages/sources/SourcesListPage.ts:122 +#: src/pages/sources/SourcesListPage.ts:114 +#: src/pages/sources/SourcesListPage.ts:123 #: src/pages/stages/StageListPage.ts:119 #: src/pages/stages/StageListPage.ts:128 #: src/pages/stages/invitation/InvitationListPage.ts:77 @@ -842,7 +852,7 @@ msgstr "Create provider" #: src/pages/policies/PolicyListPage.ts:136 #: src/pages/property-mappings/PropertyMappingListPage.ts:125 #: src/pages/providers/ProviderListPage.ts:119 -#: src/pages/sources/SourcesListPage.ts:125 +#: src/pages/sources/SourcesListPage.ts:126 #: src/pages/stages/StageListPage.ts:131 msgid "Create {0}" msgstr "Create {0}" @@ -898,7 +908,7 @@ msgstr "Define how notifications are sent to users, like Email or Webhook." #: src/pages/policies/PolicyListPage.ts:115 #: src/pages/property-mappings/PropertyMappingListPage.ts:104 #: src/pages/providers/ProviderListPage.ts:98 -#: src/pages/sources/SourcesListPage.ts:94 +#: src/pages/sources/SourcesListPage.ts:95 #: src/pages/stages/StageListPage.ts:110 #: src/pages/stages/invitation/InvitationListPage.ts:68 #: src/pages/stages/prompt/PromptListPage.ts:87 @@ -1008,7 +1018,7 @@ msgstr "Disable Static Tokens" msgid "Disable Time-based OTP" msgstr "Disable Time-based OTP" -#: src/pages/sources/SourcesListPage.ts:63 +#: src/pages/sources/SourcesListPage.ts:64 msgid "Disabled" msgstr "Disabled" @@ -1049,7 +1059,7 @@ msgstr "Each provider has a different issuer, based on the application slug." #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:128 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:127 #: src/pages/providers/saml/SAMLProviderViewPage.ts:121 -#: src/pages/sources/SourcesListPage.ts:82 +#: src/pages/sources/SourcesListPage.ts:83 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:105 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:125 #: src/pages/sources/saml/SAMLSourceViewPage.ts:111 @@ -1086,7 +1096,7 @@ msgstr "Edit User" msgid "Either no applications are defined, or you don't have access to any." msgstr "Either no applications are defined, or you don't have access to any." -#: src/flows/stages/identification/IdentificationStage.ts:138 +#: src/flows/stages/identification/IdentificationStage.ts:146 #: src/pages/events/TransportForm.ts:46 #: src/pages/stages/identification/IdentificationStageForm.ts:81 #: src/pages/user-settings/UserDetailsPage.ts:71 @@ -1099,7 +1109,7 @@ msgstr "Email" msgid "Email address" msgstr "Email address" -#: src/flows/stages/identification/IdentificationStage.ts:145 +#: src/flows/stages/identification/IdentificationStage.ts:153 msgid "Email or username" msgstr "Email or username" @@ -1136,6 +1146,7 @@ msgstr "Enable this if you don't want to use this provider as a proxy, and want #: src/pages/policies/PolicyBindingForm.ts:199 #: src/pages/sources/ldap/LDAPSourceForm.ts:69 #: src/pages/sources/oauth/OAuthSourceForm.ts:115 +#: src/pages/sources/plex/PlexSourceForm.ts:102 #: src/pages/sources/saml/SAMLSourceForm.ts:69 msgid "Enabled" msgstr "Enabled" @@ -1145,6 +1156,7 @@ msgid "Enrollment" msgstr "Enrollment" #: src/pages/sources/oauth/OAuthSourceForm.ts:210 +#: src/pages/sources/plex/PlexSourceForm.ts:170 #: src/pages/sources/saml/SAMLSourceForm.ts:266 #: src/pages/stages/identification/IdentificationStageForm.ts:106 msgid "Enrollment flow" @@ -1357,16 +1369,19 @@ msgid "Flow Overview" msgstr "Flow Overview" #: src/pages/sources/oauth/OAuthSourceForm.ts:185 +#: src/pages/sources/plex/PlexSourceForm.ts:145 #: src/pages/sources/saml/SAMLSourceForm.ts:220 msgid "Flow settings" msgstr "Flow settings" #: src/pages/sources/oauth/OAuthSourceForm.ts:207 +#: src/pages/sources/plex/PlexSourceForm.ts:167 #: src/pages/sources/saml/SAMLSourceForm.ts:263 msgid "Flow to use when authenticating existing users." msgstr "Flow to use when authenticating existing users." #: src/pages/sources/oauth/OAuthSourceForm.ts:228 +#: src/pages/sources/plex/PlexSourceForm.ts:188 #: src/pages/sources/saml/SAMLSourceForm.ts:284 msgid "Flow to use when enrolling new users." msgstr "Flow to use when enrolling new users." @@ -1410,7 +1425,7 @@ msgstr "Force the user to configure an authenticator" msgid "Forgot password?" msgstr "Forgot password?" -#: src/flows/stages/identification/IdentificationStage.ts:124 +#: src/flows/stages/identification/IdentificationStage.ts:132 msgid "Forgot username or password?" msgstr "Forgot username or password?" @@ -1510,6 +1525,7 @@ msgstr "Hide managed mappings" #: src/pages/providers/saml/SAMLProviderForm.ts:177 #: src/pages/sources/ldap/LDAPSourceForm.ts:167 #: src/pages/sources/ldap/LDAPSourceForm.ts:193 +#: src/pages/sources/plex/PlexSourceForm.ts:132 #: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts:114 #: src/pages/stages/identification/IdentificationStageForm.ts:85 #: src/pages/stages/password/PasswordStageForm.ts:86 @@ -1692,9 +1708,13 @@ msgstr "Let the user identify themselves with their username or Email address." msgid "Library" msgstr "Library" +#: src/pages/sources/plex/PlexSourceForm.ts:137 +msgid "Load servers" +msgstr "Load servers" + #: src/elements/table/Table.ts:120 -#: src/flows/FlowExecutor.ts:167 -#: src/flows/FlowExecutor.ts:213 +#: src/flows/FlowExecutor.ts:168 +#: src/flows/FlowExecutor.ts:216 #: src/flows/access_denied/FlowAccessDenied.ts:27 #: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts:43 #: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts:33 @@ -1705,7 +1725,7 @@ msgstr "Library" #: src/flows/stages/consent/ConsentStage.ts:28 #: src/flows/stages/dummy/DummyStage.ts:27 #: src/flows/stages/email/EmailStage.ts:26 -#: src/flows/stages/identification/IdentificationStage.ts:171 +#: src/flows/stages/identification/IdentificationStage.ts:179 #: src/flows/stages/password/PasswordStage.ts:31 #: src/flows/stages/prompt/PromptStage.ts:126 #: src/pages/applications/ApplicationViewPage.ts:43 @@ -1750,6 +1770,8 @@ msgstr "Loading" #: src/pages/sources/oauth/OAuthSourceForm.ts:177 #: src/pages/sources/oauth/OAuthSourceForm.ts:205 #: src/pages/sources/oauth/OAuthSourceForm.ts:226 +#: src/pages/sources/plex/PlexSourceForm.ts:165 +#: src/pages/sources/plex/PlexSourceForm.ts:186 #: src/pages/sources/saml/SAMLSourceForm.ts:126 #: src/pages/sources/saml/SAMLSourceForm.ts:240 #: src/pages/sources/saml/SAMLSourceForm.ts:261 @@ -1780,7 +1802,7 @@ msgstr "Log the currently pending user in." msgid "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP." msgstr "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP." -#: src/flows/stages/identification/IdentificationStage.ts:183 +#: src/flows/stages/identification/IdentificationStage.ts:191 msgid "Login to continue to {0}." msgstr "Login to continue to {0}." @@ -1913,11 +1935,12 @@ msgstr "Monitor" #: src/pages/providers/saml/SAMLProviderForm.ts:53 #: src/pages/providers/saml/SAMLProviderImportForm.ts:38 #: src/pages/providers/saml/SAMLProviderViewPage.ts:66 -#: src/pages/sources/SourcesListPage.ts:51 +#: src/pages/sources/SourcesListPage.ts:52 #: src/pages/sources/ldap/LDAPSourceForm.ts:54 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:64 #: src/pages/sources/oauth/OAuthSourceForm.ts:100 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:64 +#: src/pages/sources/plex/PlexSourceForm.ts:87 #: src/pages/sources/saml/SAMLSourceForm.ts:54 #: src/pages/sources/saml/SAMLSourceViewPage.ts:66 #: src/pages/stages/StageListPage.ts:65 @@ -1957,7 +1980,7 @@ msgstr "NameID Policy" msgid "NameID Property Mapping" msgstr "NameID Property Mapping" -#: src/flows/stages/identification/IdentificationStage.ts:119 +#: src/flows/stages/identification/IdentificationStage.ts:127 msgid "Need an account?" msgstr "Need an account?" @@ -2348,7 +2371,7 @@ msgstr "Post binding" msgid "Post binding (auto-submit)" msgstr "Post binding (auto-submit)" -#: src/flows/FlowExecutor.ts:255 +#: src/flows/FlowExecutor.ts:258 msgid "Powered by authentik" msgstr "Powered by authentik" @@ -2412,6 +2435,7 @@ msgstr "Property mappings used to user creation." #: src/pages/providers/proxy/ProxyProviderForm.ts:123 #: src/pages/providers/saml/SAMLProviderForm.ts:78 #: src/pages/sources/oauth/OAuthSourceForm.ts:122 +#: src/pages/sources/plex/PlexSourceForm.ts:109 #: src/pages/sources/saml/SAMLSourceForm.ts:76 msgid "Protocol settings" msgstr "Protocol settings" @@ -2602,7 +2626,7 @@ msgstr "Retry Task" msgid "Retry authentication" msgstr "Retry authentication" -#: src/flows/FlowExecutor.ts:145 +#: src/flows/FlowExecutor.ts:146 msgid "Return" msgstr "Return" @@ -2710,7 +2734,7 @@ msgstr "Select all rows" msgid "Select an identification method." msgstr "Select an identification method." -#: src/flows/stages/identification/IdentificationStage.ts:134 +#: src/flows/stages/identification/IdentificationStage.ts:142 msgid "Select one of the sources below to login." msgstr "Select one of the sources below to login." @@ -2722,6 +2746,10 @@ msgstr "Select users to add" msgid "Select which scopes can be used by the client. The client stil has to specify the scope to access the data." msgstr "Select which scopes can be used by the client. The client stil has to specify the scope to access the data." +#: src/pages/sources/plex/PlexSourceForm.ts:131 +msgid "Select which server a user has to be a member of to be allowed to authenticate." +msgstr "Select which server a user has to be a member of to be allowed to authenticate." + #: src/pages/events/RuleForm.ts:92 msgid "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI." msgstr "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI." @@ -2820,7 +2848,7 @@ msgstr "Show matched user" msgid "Shown as the Title in Flow pages." msgstr "Shown as the Title in Flow pages." -#: src/flows/stages/identification/IdentificationStage.ts:120 +#: src/flows/stages/identification/IdentificationStage.ts:128 msgid "Sign up." msgstr "Sign up." @@ -2854,16 +2882,17 @@ msgstr "Skip path regex" #: src/pages/flows/FlowForm.ts:94 #: src/pages/sources/ldap/LDAPSourceForm.ts:60 #: src/pages/sources/oauth/OAuthSourceForm.ts:106 +#: src/pages/sources/plex/PlexSourceForm.ts:93 #: src/pages/sources/saml/SAMLSourceForm.ts:60 msgid "Slug" msgstr "Slug" -#: src/flows/FlowExecutor.ts:138 +#: src/flows/FlowExecutor.ts:139 msgid "Something went wrong! Please try again later." msgstr "Something went wrong! Please try again later." #: src/pages/providers/ProviderListPage.ts:91 -#: src/pages/sources/SourcesListPage.ts:87 +#: src/pages/sources/SourcesListPage.ts:88 msgid "Source" msgstr "Source" @@ -2872,11 +2901,11 @@ msgid "Source {0}" msgstr "Source {0}" #: src/interfaces/AdminInterface.ts:20 -#: src/pages/sources/SourcesListPage.ts:30 +#: src/pages/sources/SourcesListPage.ts:31 msgid "Sources" msgstr "Sources" -#: src/pages/sources/SourcesListPage.ts:33 +#: src/pages/sources/SourcesListPage.ts:34 msgid "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins" msgstr "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins" @@ -3073,6 +3102,7 @@ msgstr "Successfully created service-connection." #: src/pages/sources/ldap/LDAPSourceForm.ts:47 #: src/pages/sources/oauth/OAuthSourceForm.ts:51 +#: src/pages/sources/plex/PlexSourceForm.ts:60 #: src/pages/sources/saml/SAMLSourceForm.ts:47 msgid "Successfully created source." msgstr "Successfully created source." @@ -3209,6 +3239,7 @@ msgstr "Successfully updated service-connection." #: src/pages/sources/ldap/LDAPSourceForm.ts:44 #: src/pages/sources/oauth/OAuthSourceForm.ts:48 +#: src/pages/sources/plex/PlexSourceForm.ts:57 #: src/pages/sources/saml/SAMLSourceForm.ts:44 msgid "Successfully updated source." msgstr "Successfully updated source." @@ -3464,7 +3495,7 @@ msgstr "Transports" #: src/pages/policies/PolicyListPage.ts:57 #: src/pages/property-mappings/PropertyMappingListPage.ts:55 #: src/pages/providers/ProviderListPage.ts:54 -#: src/pages/sources/SourcesListPage.ts:52 +#: src/pages/sources/SourcesListPage.ts:53 #: src/pages/stages/prompt/PromptForm.ts:97 #: src/pages/stages/prompt/PromptListPage.ts:48 msgid "Type" @@ -3543,7 +3574,7 @@ msgstr "Up-to-date!" #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:118 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:117 #: src/pages/providers/saml/SAMLProviderViewPage.ts:111 -#: src/pages/sources/SourcesListPage.ts:69 +#: src/pages/sources/SourcesListPage.ts:70 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:95 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:115 #: src/pages/sources/saml/SAMLSourceViewPage.ts:101 @@ -3646,7 +3677,7 @@ msgstr "Update details" #: src/pages/policies/PolicyListPage.ts:80 #: src/pages/property-mappings/PropertyMappingListPage.ts:69 #: src/pages/providers/ProviderListPage.ts:76 -#: src/pages/sources/SourcesListPage.ts:72 +#: src/pages/sources/SourcesListPage.ts:73 #: src/pages/stages/StageListPage.ts:88 #: src/pages/users/UserActiveForm.ts:41 msgid "Update {0}" @@ -3750,7 +3781,7 @@ msgstr "User/Group Attribute used for the user part of the HTTP-Basic Header. If msgid "Userinfo URL" msgstr "Userinfo URL" -#: src/flows/stages/identification/IdentificationStage.ts:142 +#: src/flows/stages/identification/IdentificationStage.ts:150 #: src/pages/stages/identification/IdentificationStageForm.ts:78 #: src/pages/user-settings/UserDetailsPage.ts:57 #: src/pages/users/UserForm.ts:47 @@ -3903,7 +3934,7 @@ msgstr "When selected, incoming assertion's Signatures will be validated against msgid "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged." msgstr "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged." -#: src/flows/FlowExecutor.ts:134 +#: src/flows/FlowExecutor.ts:135 msgid "Whoops!" msgstr "Whoops!" diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index 857a494bc..9b2270490 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -156,6 +156,10 @@ msgstr "" msgid "Allowed count" msgstr "" +#: src/pages/sources/plex/PlexSourceForm.ts:119 +msgid "Allowed servers" +msgstr "" + #: src/pages/sources/saml/SAMLSourceForm.ts:144 msgid "Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done." msgstr "" @@ -273,11 +277,16 @@ msgstr "" msgid "Audience" msgstr "" +#: src/flows/sources/plex/PlexLoginInit.ts:56 +msgid "Authenticating with Plex..." +msgstr "" + #: src/pages/flows/FlowForm.ts:55 msgid "Authentication" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:189 +#: src/pages/sources/plex/PlexSourceForm.ts:149 #: src/pages/sources/saml/SAMLSourceForm.ts:245 msgid "Authentication flow" msgstr "" @@ -391,8 +400,8 @@ msgstr "" msgid "Build hash: {0}" msgstr "" -#: src/pages/sources/SourcesListPage.ts:103 -#: src/pages/sources/SourcesListPage.ts:105 +#: src/pages/sources/SourcesListPage.ts:104 +#: src/pages/sources/SourcesListPage.ts:106 msgid "Built-in" msgstr "" @@ -538,6 +547,7 @@ msgstr "" #: src/pages/providers/oauth2/OAuth2ProviderForm.ts:107 #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:99 +#: src/pages/sources/plex/PlexSourceForm.ts:113 msgid "Client ID" msgstr "" @@ -738,8 +748,8 @@ msgstr "" #: src/pages/providers/ProviderListPage.ts:116 #: src/pages/providers/RelatedApplicationButton.ts:27 #: src/pages/providers/RelatedApplicationButton.ts:35 -#: src/pages/sources/SourcesListPage.ts:113 -#: src/pages/sources/SourcesListPage.ts:122 +#: src/pages/sources/SourcesListPage.ts:114 +#: src/pages/sources/SourcesListPage.ts:123 #: src/pages/stages/StageListPage.ts:119 #: src/pages/stages/StageListPage.ts:128 #: src/pages/stages/invitation/InvitationListPage.ts:77 @@ -836,7 +846,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:136 #: src/pages/property-mappings/PropertyMappingListPage.ts:125 #: src/pages/providers/ProviderListPage.ts:119 -#: src/pages/sources/SourcesListPage.ts:125 +#: src/pages/sources/SourcesListPage.ts:126 #: src/pages/stages/StageListPage.ts:131 msgid "Create {0}" msgstr "" @@ -892,7 +902,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:115 #: src/pages/property-mappings/PropertyMappingListPage.ts:104 #: src/pages/providers/ProviderListPage.ts:98 -#: src/pages/sources/SourcesListPage.ts:94 +#: src/pages/sources/SourcesListPage.ts:95 #: src/pages/stages/StageListPage.ts:110 #: src/pages/stages/invitation/InvitationListPage.ts:68 #: src/pages/stages/prompt/PromptListPage.ts:87 @@ -1000,7 +1010,7 @@ msgstr "" msgid "Disable Time-based OTP" msgstr "" -#: src/pages/sources/SourcesListPage.ts:63 +#: src/pages/sources/SourcesListPage.ts:64 msgid "Disabled" msgstr "" @@ -1041,7 +1051,7 @@ msgstr "" #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:128 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:127 #: src/pages/providers/saml/SAMLProviderViewPage.ts:121 -#: src/pages/sources/SourcesListPage.ts:82 +#: src/pages/sources/SourcesListPage.ts:83 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:105 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:125 #: src/pages/sources/saml/SAMLSourceViewPage.ts:111 @@ -1078,7 +1088,7 @@ msgstr "" msgid "Either no applications are defined, or you don't have access to any." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:138 +#: src/flows/stages/identification/IdentificationStage.ts:146 #: src/pages/events/TransportForm.ts:46 #: src/pages/stages/identification/IdentificationStageForm.ts:81 #: src/pages/user-settings/UserDetailsPage.ts:71 @@ -1091,7 +1101,7 @@ msgstr "" msgid "Email address" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:145 +#: src/flows/stages/identification/IdentificationStage.ts:153 msgid "Email or username" msgstr "" @@ -1128,6 +1138,7 @@ msgstr "" #: src/pages/policies/PolicyBindingForm.ts:199 #: src/pages/sources/ldap/LDAPSourceForm.ts:69 #: src/pages/sources/oauth/OAuthSourceForm.ts:115 +#: src/pages/sources/plex/PlexSourceForm.ts:102 #: src/pages/sources/saml/SAMLSourceForm.ts:69 msgid "Enabled" msgstr "" @@ -1137,6 +1148,7 @@ msgid "Enrollment" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:210 +#: src/pages/sources/plex/PlexSourceForm.ts:170 #: src/pages/sources/saml/SAMLSourceForm.ts:266 #: src/pages/stages/identification/IdentificationStageForm.ts:106 msgid "Enrollment flow" @@ -1349,16 +1361,19 @@ msgid "Flow Overview" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:185 +#: src/pages/sources/plex/PlexSourceForm.ts:145 #: src/pages/sources/saml/SAMLSourceForm.ts:220 msgid "Flow settings" msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:207 +#: src/pages/sources/plex/PlexSourceForm.ts:167 #: src/pages/sources/saml/SAMLSourceForm.ts:263 msgid "Flow to use when authenticating existing users." msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:228 +#: src/pages/sources/plex/PlexSourceForm.ts:188 #: src/pages/sources/saml/SAMLSourceForm.ts:284 msgid "Flow to use when enrolling new users." msgstr "" @@ -1402,7 +1417,7 @@ msgstr "" msgid "Forgot password?" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:124 +#: src/flows/stages/identification/IdentificationStage.ts:132 msgid "Forgot username or password?" msgstr "" @@ -1502,6 +1517,7 @@ msgstr "" #: src/pages/providers/saml/SAMLProviderForm.ts:177 #: src/pages/sources/ldap/LDAPSourceForm.ts:167 #: src/pages/sources/ldap/LDAPSourceForm.ts:193 +#: src/pages/sources/plex/PlexSourceForm.ts:132 #: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts:114 #: src/pages/stages/identification/IdentificationStageForm.ts:85 #: src/pages/stages/password/PasswordStageForm.ts:86 @@ -1684,9 +1700,13 @@ msgstr "" msgid "Library" msgstr "" +#: src/pages/sources/plex/PlexSourceForm.ts:137 +msgid "Load servers" +msgstr "" + #: src/elements/table/Table.ts:120 -#: src/flows/FlowExecutor.ts:167 -#: src/flows/FlowExecutor.ts:213 +#: src/flows/FlowExecutor.ts:168 +#: src/flows/FlowExecutor.ts:216 #: src/flows/access_denied/FlowAccessDenied.ts:27 #: src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts:43 #: src/flows/stages/authenticator_totp/AuthenticatorTOTPStage.ts:33 @@ -1697,7 +1717,7 @@ msgstr "" #: src/flows/stages/consent/ConsentStage.ts:28 #: src/flows/stages/dummy/DummyStage.ts:27 #: src/flows/stages/email/EmailStage.ts:26 -#: src/flows/stages/identification/IdentificationStage.ts:171 +#: src/flows/stages/identification/IdentificationStage.ts:179 #: src/flows/stages/password/PasswordStage.ts:31 #: src/flows/stages/prompt/PromptStage.ts:126 #: src/pages/applications/ApplicationViewPage.ts:43 @@ -1742,6 +1762,8 @@ msgstr "" #: src/pages/sources/oauth/OAuthSourceForm.ts:177 #: src/pages/sources/oauth/OAuthSourceForm.ts:205 #: src/pages/sources/oauth/OAuthSourceForm.ts:226 +#: src/pages/sources/plex/PlexSourceForm.ts:165 +#: src/pages/sources/plex/PlexSourceForm.ts:186 #: src/pages/sources/saml/SAMLSourceForm.ts:126 #: src/pages/sources/saml/SAMLSourceForm.ts:240 #: src/pages/sources/saml/SAMLSourceForm.ts:261 @@ -1772,7 +1794,7 @@ msgstr "" msgid "Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:183 +#: src/flows/stages/identification/IdentificationStage.ts:191 msgid "Login to continue to {0}." msgstr "" @@ -1905,11 +1927,12 @@ msgstr "" #: src/pages/providers/saml/SAMLProviderForm.ts:53 #: src/pages/providers/saml/SAMLProviderImportForm.ts:38 #: src/pages/providers/saml/SAMLProviderViewPage.ts:66 -#: src/pages/sources/SourcesListPage.ts:51 +#: src/pages/sources/SourcesListPage.ts:52 #: src/pages/sources/ldap/LDAPSourceForm.ts:54 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:64 #: src/pages/sources/oauth/OAuthSourceForm.ts:100 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:64 +#: src/pages/sources/plex/PlexSourceForm.ts:87 #: src/pages/sources/saml/SAMLSourceForm.ts:54 #: src/pages/sources/saml/SAMLSourceViewPage.ts:66 #: src/pages/stages/StageListPage.ts:65 @@ -1949,7 +1972,7 @@ msgstr "" msgid "NameID Property Mapping" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:119 +#: src/flows/stages/identification/IdentificationStage.ts:127 msgid "Need an account?" msgstr "" @@ -2340,7 +2363,7 @@ msgstr "" msgid "Post binding (auto-submit)" msgstr "" -#: src/flows/FlowExecutor.ts:255 +#: src/flows/FlowExecutor.ts:258 msgid "Powered by authentik" msgstr "" @@ -2404,6 +2427,7 @@ msgstr "" #: src/pages/providers/proxy/ProxyProviderForm.ts:123 #: src/pages/providers/saml/SAMLProviderForm.ts:78 #: src/pages/sources/oauth/OAuthSourceForm.ts:122 +#: src/pages/sources/plex/PlexSourceForm.ts:109 #: src/pages/sources/saml/SAMLSourceForm.ts:76 msgid "Protocol settings" msgstr "" @@ -2594,7 +2618,7 @@ msgstr "" msgid "Retry authentication" msgstr "" -#: src/flows/FlowExecutor.ts:145 +#: src/flows/FlowExecutor.ts:146 msgid "Return" msgstr "" @@ -2702,7 +2726,7 @@ msgstr "" msgid "Select an identification method." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:134 +#: src/flows/stages/identification/IdentificationStage.ts:142 msgid "Select one of the sources below to login." msgstr "" @@ -2714,6 +2738,10 @@ msgstr "" msgid "Select which scopes can be used by the client. The client stil has to specify the scope to access the data." msgstr "" +#: src/pages/sources/plex/PlexSourceForm.ts:131 +msgid "Select which server a user has to be a member of to be allowed to authenticate." +msgstr "" + #: src/pages/events/RuleForm.ts:92 msgid "Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI." msgstr "" @@ -2812,7 +2840,7 @@ msgstr "" msgid "Shown as the Title in Flow pages." msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:120 +#: src/flows/stages/identification/IdentificationStage.ts:128 msgid "Sign up." msgstr "" @@ -2846,16 +2874,17 @@ msgstr "" #: src/pages/flows/FlowForm.ts:94 #: src/pages/sources/ldap/LDAPSourceForm.ts:60 #: src/pages/sources/oauth/OAuthSourceForm.ts:106 +#: src/pages/sources/plex/PlexSourceForm.ts:93 #: src/pages/sources/saml/SAMLSourceForm.ts:60 msgid "Slug" msgstr "" -#: src/flows/FlowExecutor.ts:138 +#: src/flows/FlowExecutor.ts:139 msgid "Something went wrong! Please try again later." msgstr "" #: src/pages/providers/ProviderListPage.ts:91 -#: src/pages/sources/SourcesListPage.ts:87 +#: src/pages/sources/SourcesListPage.ts:88 msgid "Source" msgstr "" @@ -2864,11 +2893,11 @@ msgid "Source {0}" msgstr "" #: src/interfaces/AdminInterface.ts:20 -#: src/pages/sources/SourcesListPage.ts:30 +#: src/pages/sources/SourcesListPage.ts:31 msgid "Sources" msgstr "" -#: src/pages/sources/SourcesListPage.ts:33 +#: src/pages/sources/SourcesListPage.ts:34 msgid "Sources of identities, which can either be synced into authentik's database, like LDAP, or can be used by users to authenticate and enroll themselves, like OAuth and social logins" msgstr "" @@ -3065,6 +3094,7 @@ msgstr "" #: src/pages/sources/ldap/LDAPSourceForm.ts:47 #: src/pages/sources/oauth/OAuthSourceForm.ts:51 +#: src/pages/sources/plex/PlexSourceForm.ts:60 #: src/pages/sources/saml/SAMLSourceForm.ts:47 msgid "Successfully created source." msgstr "" @@ -3201,6 +3231,7 @@ msgstr "" #: src/pages/sources/ldap/LDAPSourceForm.ts:44 #: src/pages/sources/oauth/OAuthSourceForm.ts:48 +#: src/pages/sources/plex/PlexSourceForm.ts:57 #: src/pages/sources/saml/SAMLSourceForm.ts:44 msgid "Successfully updated source." msgstr "" @@ -3452,7 +3483,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:57 #: src/pages/property-mappings/PropertyMappingListPage.ts:55 #: src/pages/providers/ProviderListPage.ts:54 -#: src/pages/sources/SourcesListPage.ts:52 +#: src/pages/sources/SourcesListPage.ts:53 #: src/pages/stages/prompt/PromptForm.ts:97 #: src/pages/stages/prompt/PromptListPage.ts:48 msgid "Type" @@ -3531,7 +3562,7 @@ msgstr "" #: src/pages/providers/oauth2/OAuth2ProviderViewPage.ts:118 #: src/pages/providers/proxy/ProxyProviderViewPage.ts:117 #: src/pages/providers/saml/SAMLProviderViewPage.ts:111 -#: src/pages/sources/SourcesListPage.ts:69 +#: src/pages/sources/SourcesListPage.ts:70 #: src/pages/sources/ldap/LDAPSourceViewPage.ts:95 #: src/pages/sources/oauth/OAuthSourceViewPage.ts:115 #: src/pages/sources/saml/SAMLSourceViewPage.ts:101 @@ -3634,7 +3665,7 @@ msgstr "" #: src/pages/policies/PolicyListPage.ts:80 #: src/pages/property-mappings/PropertyMappingListPage.ts:69 #: src/pages/providers/ProviderListPage.ts:76 -#: src/pages/sources/SourcesListPage.ts:72 +#: src/pages/sources/SourcesListPage.ts:73 #: src/pages/stages/StageListPage.ts:88 #: src/pages/users/UserActiveForm.ts:41 msgid "Update {0}" @@ -3738,7 +3769,7 @@ msgstr "" msgid "Userinfo URL" msgstr "" -#: src/flows/stages/identification/IdentificationStage.ts:142 +#: src/flows/stages/identification/IdentificationStage.ts:150 #: src/pages/stages/identification/IdentificationStageForm.ts:78 #: src/pages/user-settings/UserDetailsPage.ts:57 #: src/pages/users/UserForm.ts:47 @@ -3891,7 +3922,7 @@ msgstr "" msgid "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged." msgstr "" -#: src/flows/FlowExecutor.ts:134 +#: src/flows/FlowExecutor.ts:135 msgid "Whoops!" msgstr "" diff --git a/web/src/pages/sources/SourcesListPage.ts b/web/src/pages/sources/SourcesListPage.ts index c88241a9a..e00d7443d 100644 --- a/web/src/pages/sources/SourcesListPage.ts +++ b/web/src/pages/sources/SourcesListPage.ts @@ -17,6 +17,7 @@ import { ifDefined } from "lit-html/directives/if-defined"; import "./ldap/LDAPSourceForm"; import "./saml/SAMLSourceForm"; import "./oauth/OAuthSourceForm"; +import "./plex/PlexSourceForm"; @customElement("ak-source-list") export class SourceListPage extends TablePage { diff --git a/web/src/pages/sources/plex/PlexSourceForm.ts b/web/src/pages/sources/plex/PlexSourceForm.ts new file mode 100644 index 000000000..10c7da501 --- /dev/null +++ b/web/src/pages/sources/plex/PlexSourceForm.ts @@ -0,0 +1,183 @@ +import { PlexSource, SourcesApi, FlowsApi, FlowDesignationEnum } from "authentik-api"; +import { t } from "@lingui/macro"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { Form } from "../../../elements/forms/Form"; +import "../../../elements/forms/FormGroup"; +import "../../../elements/forms/HorizontalFormElement"; +import { ifDefined } from "lit-html/directives/if-defined"; +import { until } from "lit-html/directives/until"; +import { first, randomString } from "../../../utils"; +import { PlexAPIClient, PlexResource, popupCenterScreen} from "../../../flows/sources/plex/API"; + + +@customElement("ak-source-plex-form") +export class PlexSourceForm extends Form { + + set sourceSlug(value: string) { + new SourcesApi(DEFAULT_CONFIG).sourcesPlexRead({ + slug: value, + }).then(source => { + this.source = source; + }); + } + + @property({attribute: false}) + source: PlexSource = { + clientId: randomString(40) + } as PlexSource; + + @property() + plexToken?: string; + + @property({attribute: false}) + plexResources?: PlexResource[]; + + getSuccessMessage(): string { + if (this.source) { + return t`Successfully updated source.`; + } else { + return t`Successfully created source.`; + } + } + + send = (data: PlexSource): Promise => { + if (this.source.slug) { + return new SourcesApi(DEFAULT_CONFIG).sourcesPlexUpdate({ + slug: this.source.slug, + data: data + }); + } else { + return new SourcesApi(DEFAULT_CONFIG).sourcesPlexCreate({ + data: data + }); + } + }; + + async doAuth(): Promise { + const authInfo = await PlexAPIClient.getPin(this.source?.clientId); + const authWindow = popupCenterScreen(authInfo.authUrl, "plex auth", 550, 700); + PlexAPIClient.pinPoll(this.source?.clientId || "", authInfo.pin.id).then(token => { + authWindow?.close(); + this.plexToken = token; + this.loadServers(); + }); + } + + async loadServers(): Promise { + if (!this.plexToken) { + return; + } + this.plexResources = await new PlexAPIClient(this.plexToken).getServers(); + } + + renderForm(): TemplateResult { + return html` + + + + + + + + + + + ${t`Enabled`} + + + + + + + ${t`Protocol settings`} + + + + + + + + ${this.plexResources?.map(r => { + const selected = Array.from(this.source?.allowedServers || []).some(server => { + return server == r.clientIdentifier; + }); + return html`${r.name}`; + })} + + ${t`Select which server a user has to be a member of to be allowed to authenticate.`} + ${t`Hold control/command to select multiple items.`} + + { + this.doAuth(); + }}> + ${t`Load servers`} + + + + + + + + ${t`Flow settings`} + + + + + ${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({ + ordering: "pk", + designation: FlowDesignationEnum.Authentication, + }).then(flows => { + return flows.results.map(flow => { + let selected = this.source?.authenticationFlow === flow.pk; + if (!this.source?.pk && !this.source?.authenticationFlow && flow.slug === "default-source-authentication") { + selected = true; + } + return html`${flow.name} (${flow.slug})`; + }); + }), html`${t`Loading...`}`)} + + ${t`Flow to use when authenticating existing users.`} + + + + ${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({ + ordering: "pk", + designation: FlowDesignationEnum.Enrollment, + }).then(flows => { + return flows.results.map(flow => { + let selected = this.source?.enrollmentFlow === flow.pk; + if (!this.source?.pk && !this.source?.enrollmentFlow && flow.slug === "default-source-enrollment") { + selected = true; + } + return html`${flow.name} (${flow.slug})`; + }); + }), html`${t`Loading...`}`)} + + ${t`Flow to use when enrolling new users.`} + + + + `; + } + +}
${t`Select which server a user has to be a member of to be allowed to authenticate.`}
${t`Hold control/command to select multiple items.`}
+ { + this.doAuth(); + }}> + ${t`Load servers`} + +
${t`Flow to use when authenticating existing users.`}
${t`Flow to use when enrolling new users.`}