stages: add WebAuthn stage (#550)

* core: add User.uid for globally unique user ID

* admin: fix ?next for Flow list

* stages: add initial webauthn implementation

* web: add ak-flow-submit event to submit flow stage

* web: show error message for webauthn registration

* admin: fix next param not redirecting correctly

* stages/webauthn: remove form

* stages/webauthn: add API

* web: update flow diagram on ak-refresh

* stages/webauthn: add initial authentication

* stages/webauthn: initial authentication implementation

* web: cleanup webauthn utils

* stages: rename otp_* to authenticator and move webauthn to authenticator

* docs: fix broken links

* stages/authenticator_*: fix template paths

* stages/authenticator_validate: add device classes

* stages/authenticator_webauthn: implement django_otp.devices

* stages/authenticator_*: update default stage names

* web: add button to create stage on flow page

* web: don't minify HTML, remove nbsp

* admin: fix typo in stage list

* stages/*: use common base class for stage serializer

* stages/authenticator_*: create default objects after rename

* tests/e2e: adjust stage order
master
Jens L 2021-02-17 20:49:58 +01:00 committed by GitHub
parent e020b8bf32
commit 8708e487ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
128 changed files with 2949 additions and 874 deletions

View File

@ -45,6 +45,7 @@ kubernetes = "*"
docker = "*"
xmlsec = "*"
geoip2 = "*"
webauthn = "*"
[requires]
python_version = "3.9"

114
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "7151710a45e6ca0bd25335b14be005aa5179eb91de361de93686022c9b71c3d1"
"sha256": "933685b75680e3a06d2f523239d848b14d1507d385de42401863f4fb6345366c"
},
"pipfile-spec": 6,
"requires": {
@ -56,6 +56,7 @@
"sha256:f326b3c1bbfda5b9308252ee0dcb30b612ee92b0e105d4abec70335fab5b1245",
"sha256:f411cb22115cb15452d099fec0ee636b06cf81bfb40ed9c02d30c8dc2bc2e3d1"
],
"markers": "python_version >= '3.6'",
"version": "==3.7.3"
},
"aioredis": {
@ -70,6 +71,7 @@
"sha256:1e759a7f202d910939de6eca45c23a107f6b71111f41d1282c648e9ac3d21901",
"sha256:affdd263d8b8eb3c98170b78bf83867cdb6a14901d586e00ddb65bfe2f0c4e60"
],
"markers": "python_version >= '3.6'",
"version": "==5.0.5"
},
"asgiref": {
@ -77,6 +79,7 @@
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
],
"markers": "python_version >= '3.5'",
"version": "==3.3.1"
},
"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:41a3a3f89cde48643baf4e105d9491c566295f9abee951379e59121784044b8b",
"sha256:7e6b1bf95196b733978bab2d54a7ab8899c16ce11be369dc58422c07b7eea726"
],
"markers": "python_version >= '3.6'",
"version": "==21.2.1"
},
"automat": {
@ -116,7 +122,8 @@
},
"boto3": {
"hashes": [
"sha256:877f204dabe1bfa21aa9cfaacc72bd4b70a897d0fdcea799afa5c4743b6fc7ac"
"sha256:877f204dabe1bfa21aa9cfaacc72bd4b70a897d0fdcea799afa5c4743b6fc7ac",
"sha256:3a8412020a59509e783755b5c9b910a4fc7f6b6f2b9473e7cd1e07b67672e0d1"
],
"index": "pypi",
"version": "==1.17.9"
@ -126,6 +133,7 @@
"sha256:c8614c230e7a8e042a8c07d47caea50ad21cb51415289bd34fa6d0382beddad7",
"sha256:d725840b881be62fc52e8e24a6ada651128cf7f1ed1639b87322a7a213ffdbad"
],
"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.9"
},
"cachetools": {
@ -133,8 +141,15 @@
"sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2",
"sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"
],
"markers": "python_version ~= '3.5'",
"version": "==4.2.1"
},
"cbor2": {
"hashes": [
"sha256:a33aa2e5534fd74401ac95686886e655e3b2ce6383b3f958199b6e70a87c94bf"
],
"version": "==5.2.0"
},
"celery": {
"hashes": [
"sha256:5e8d364e058554e83bbb116e8377d90c79be254785f357cb2cec026e79febe13",
@ -220,6 +235,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": {
@ -288,6 +304,7 @@
"sha256:0052c9887600c57054a5867d4b0240159fa009faa3bcf6a1627271d9cdcb005a",
"sha256:c22b692707f514de9013651ecb687f2abe4f35cf6fe292ece634e9f1737bc7e3"
],
"markers": "python_version >= '3.6'",
"version": "==3.0.1"
},
"defusedxml": {
@ -428,6 +445,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": {
@ -443,6 +461,7 @@
"sha256:1b461d079b5650efe492a7814e95c536ffa9e7a96e39a6d16189c1604f18554f",
"sha256:8ce6862cf4e9252de10045f05fa80393fde831da9c2b45c39288edeee3cde7f2"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==1.26.1"
},
"gunicorn": {
@ -458,6 +477,7 @@
"sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6",
"sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"
],
"markers": "python_version >= '3.6'",
"version": "==0.12.0"
},
"hiredis": {
@ -509,6 +529,7 @@
"sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390",
"sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.0"
},
"httptools": {
@ -554,6 +575,7 @@
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
"sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
],
"markers": "python_version >= '3.5'",
"version": "==0.5.1"
},
"itypes": {
@ -568,6 +590,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": {
@ -575,6 +598,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": {
@ -589,6 +613,7 @@
"sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006",
"sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c"
],
"markers": "python_version >= '3.6'",
"version": "==5.0.2"
},
"kubernetes": {
@ -601,8 +626,11 @@
},
"ldap3": {
"hashes": [
"sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91",
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57"
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57",
"sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056",
"sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59",
"sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c",
"sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91"
],
"index": "pypi",
"version": "==2.9"
@ -705,12 +733,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": {
@ -786,6 +816,7 @@
"sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281",
"sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"
],
"markers": "python_version >= '3.6'",
"version": "==5.1.0"
},
"oauthlib": {
@ -793,6 +824,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": {
@ -815,6 +847,7 @@
"sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974",
"sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==3.0.16"
},
"psycopg2-binary": {
@ -860,15 +893,37 @@
},
"pyasn1": {
"hashes": [
"sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
"sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
"sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
"sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
"sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
"sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"
"sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
"sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
"sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
"sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3",
"sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"
],
"version": "==0.4.8"
},
"pyasn1-modules": {
"hashes": [
"sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45",
"sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e",
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"
"sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199",
"sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811",
"sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0",
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74",
"sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d",
"sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8",
"sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd",
"sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405",
"sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb",
"sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed",
"sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"
],
"version": "==0.2.8"
},
@ -877,6 +932,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": {
@ -948,6 +1004,7 @@
"sha256:f933ecf4cb736c7af60a6a533db2bf569717f2318b265f92907acff1db43bc34",
"sha256:fc9c55dc1ed57db76595f2d19a479fc1c3a1be2c9da8de798a93d286c5f65f38"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==3.10.1"
},
"pyhamcrest": {
@ -955,6 +1012,7 @@
"sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316",
"sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29"
],
"markers": "python_version >= '3.5'",
"version": "==2.0.2"
},
"pyjwkest": {
@ -976,12 +1034,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": {
@ -989,6 +1049,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": {
@ -1045,6 +1106,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": {
@ -1052,11 +1114,13 @@
"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:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc",
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
],
"index": "pypi",
@ -1105,6 +1169,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": {
@ -1112,6 +1177,7 @@
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
],
"markers": "python_version >= '3.5'",
"version": "==0.4.1"
},
"structlog": {
@ -1159,6 +1225,7 @@
"sha256:f058bd0168271de4dcdc39845b52dd0a4a2fecf5f1246335f13f5e96eaebb467",
"sha256:f3c19e5bd42bbe4bf345704ad7c326c74d3fd7a1b3844987853bef180be638d4"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==20.3.0"
},
"txaio": {
@ -1166,6 +1233,7 @@
"sha256:1488d31d564a116538cc1265ac3f7979fb6223bb5a9e9f1479436ee2c17d8549",
"sha256:a8676d6c68aea1f0e2548c4afdb8e6253873af3bc2659bb5bcd9f39dff7ff90f"
],
"markers": "python_version >= '3.6'",
"version": "==20.12.1"
},
"typing-extensions": {
@ -1181,6 +1249,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": {
@ -1225,6 +1294,7 @@
"sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30",
"sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"
],
"markers": "python_version >= '3.6'",
"version": "==5.0.0"
},
"watchgod": {
@ -1241,6 +1311,14 @@
],
"version": "==0.2.5"
},
"webauthn": {
"hashes": [
"sha256:238391b2e2cc60fb51a2cd2d2d6be149920b9af6184651353d9f95856617a9e7",
"sha256:8ad9072ff1d6169f3be30d4dc8733ea563dd266962397bc58b40f674a6af74ac"
],
"index": "pypi",
"version": "==0.4.7"
},
"websocket-client": {
"hashes": [
"sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549",
@ -1334,6 +1412,7 @@
"sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a",
"sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"
],
"markers": "python_version >= '3.6'",
"version": "==1.6.3"
},
"zope.interface": {
@ -1391,6 +1470,7 @@
"sha256:f37d45fab14ffef9d33a0dc3bc59ce0c5313e2253323312d47739192da94f5fd",
"sha256:f44906f70205d456d503105023041f1e63aece7623b31c390a0103db4de17537"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==5.2.0"
}
},
@ -1407,6 +1487,7 @@
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
],
"markers": "python_version >= '3.5'",
"version": "==3.3.1"
},
"astroid": {
@ -1414,6 +1495,7 @@
"sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1",
"sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38"
],
"markers": "python_version >= '3.5'",
"version": "==2.4.1"
},
"attrs": {
@ -1421,6 +1503,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"
},
"autopep8": {
@ -1451,6 +1534,7 @@
"sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410",
"sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"
],
"markers": "python_version >= '3.5'",
"version": "==1.0.1"
},
"bumpversion": {
@ -1466,6 +1550,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
},
"colorama": {
@ -1559,6 +1644,7 @@
"sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
"sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.8.4"
},
"flake8-polyfill": {
@ -1573,6 +1659,7 @@
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
"sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"
],
"markers": "python_version >= '3.4'",
"version": "==4.0.5"
},
"gitpython": {
@ -1580,6 +1667,7 @@
"sha256:8621a7e777e276a5ec838b59280ba5272dd144a18169c36c903d8b38b99f750a",
"sha256:c5347c81d232d9b8e7f47b68a83e5dc92e7952127133c5f2df9133f2c75a1b29"
],
"markers": "python_version >= '3.4'",
"version": "==3.1.13"
},
"iniconfig": {
@ -1594,6 +1682,7 @@
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
"sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==4.3.21"
},
"lazy-object-proxy": {
@ -1620,6 +1709,7 @@
"sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
"sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.4.3"
},
"mccabe": {
@ -1656,6 +1746,7 @@
"sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9",
"sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"
],
"markers": "python_version >= '2.6'",
"version": "==5.5.1"
},
"pep8-naming": {
@ -1670,6 +1761,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"
},
"prospector": {
@ -1684,6 +1776,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"
},
"pycodestyle": {
@ -1691,6 +1784,7 @@
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.6.0"
},
"pydocstyle": {
@ -1698,6 +1792,7 @@
"sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
"sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
],
"markers": "python_version >= '3.5'",
"version": "==5.1.1"
},
"pyflakes": {
@ -1705,6 +1800,7 @@
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.2.0"
},
"pylint": {
@ -1747,6 +1843,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": {
@ -1870,6 +1967,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": {
@ -1877,6 +1975,7 @@
"sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714",
"sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.5"
},
"snowballstemmer": {
@ -1891,6 +1990,7 @@
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
],
"markers": "python_version >= '3.5'",
"version": "==0.4.1"
},
"stevedore": {
@ -1898,6 +1998,7 @@
"sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee",
"sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"
],
"markers": "python_version >= '3.6'",
"version": "==3.3.0"
},
"toml": {
@ -1905,6 +2006,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": {

View File

@ -68,7 +68,7 @@
<td role="cell">
<ul>
{% for flow in stage.flow_set.all %}
<li>{{ flow.slug }}<</li>
<li>{{ flow.slug }}</li>
{% empty %}
<li>-</li>
{% endfor %}

View File

@ -57,15 +57,18 @@ from authentik.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProvide
from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
from authentik.sources.oauth.api import OAuthSourceViewSet
from authentik.sources.saml.api import SAMLSourceViewSet
from authentik.stages.authenticator_static.api import AuthenticatorStaticStageViewSet
from authentik.stages.authenticator_totp.api import AuthenticatorTOTPStageViewSet
from authentik.stages.authenticator_validate.api import (
AuthenticatorValidateStageViewSet,
)
from authentik.stages.authenticator_webauthn.api import AuthenticateWebAuthnStageViewSet
from authentik.stages.captcha.api import CaptchaStageViewSet
from authentik.stages.consent.api import ConsentStageViewSet
from authentik.stages.dummy.api import DummyStageViewSet
from authentik.stages.email.api import EmailStageViewSet
from authentik.stages.identification.api import IdentificationStageViewSet
from authentik.stages.invitation.api import InvitationStageViewSet, InvitationViewSet
from authentik.stages.otp_static.api import OTPStaticStageViewSet
from authentik.stages.otp_time.api import OTPTimeStageViewSet
from authentik.stages.otp_validate.api import OTPValidateStageViewSet
from authentik.stages.password.api import PasswordStageViewSet
from authentik.stages.prompt.api import PromptStageViewSet, PromptViewSet
from authentik.stages.user_delete.api import UserDeleteStageViewSet
@ -134,15 +137,16 @@ router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
router.register("propertymappings/scope", ScopeMappingViewSet)
router.register("stages/all", StageViewSet)
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
router.register("stages/authenticator/webauthn", AuthenticateWebAuthnStageViewSet)
router.register("stages/captcha", CaptchaStageViewSet)
router.register("stages/consent", ConsentStageViewSet)
router.register("stages/email", EmailStageViewSet)
router.register("stages/identification", IdentificationStageViewSet)
router.register("stages/invitation", InvitationStageViewSet)
router.register("stages/invitation/invitations", InvitationViewSet)
router.register("stages/otp_static", OTPStaticStageViewSet)
router.register("stages/otp_time", OTPTimeStageViewSet)
router.register("stages/otp_validate", OTPValidateStageViewSet)
router.register("stages/password", PasswordStageViewSet)
router.register("stages/prompt/prompts", PromptViewSet)
router.register("stages/prompt/stages", PromptStageViewSet)

View File

@ -3,7 +3,7 @@ from dataclasses import dataclass
from django.core.cache import cache
from django.db.models import Model
from django.shortcuts import get_object_or_404
from django.shortcuts import get_object_or_404, reverse
from drf_yasg2.utils import swagger_auto_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
@ -18,8 +18,11 @@ from rest_framework.serializers import (
)
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.flows.models import Flow, FlowStageBinding, Stage
from authentik.flows.planner import cache_key
from authentik.lib.templatetags.authentik_utils import verbose_name
from authentik.lib.utils.reflection import all_subclasses
class FlowSerializer(ModelSerializer):
@ -154,24 +157,19 @@ class FlowViewSet(ModelViewSet):
return Response({"diagram": diagram})
class StageSerializer(ModelSerializer):
class StageSerializer(ModelSerializer, MetaNameSerializer):
"""Stage Serializer"""
__type__ = SerializerMethodField(method_name="get_type")
verbose_name = SerializerMethodField(method_name="get_verbose_name")
object_type = SerializerMethodField()
def get_type(self, obj: Stage) -> str:
def get_object_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("stage", "")
def get_verbose_name(self, obj: Stage) -> str:
"""Get verbose name for UI"""
return obj._meta.verbose_name
class Meta:
model = Stage
fields = ["pk", "name", "__type__", "verbose_name"]
fields = ["pk", "name", "object_type", "verbose_name", "verbose_name_plural"]
class StageViewSet(ReadOnlyModelViewSet):
@ -183,6 +181,23 @@ class StageViewSet(ReadOnlyModelViewSet):
def get_queryset(self):
return Stage.objects.select_subclasses()
@swagger_auto_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False)
def types(self, request: Request) -> Response:
"""Get all creatable stage types"""
data = []
for subclass in all_subclasses(self.queryset.model, False):
data.append(
{
"name": verbose_name(subclass),
"description": subclass.__doc__,
"link": reverse("authentik_admin:stage-create")
+ f"?type={subclass.__name__}",
}
)
data = sorted(data, key=lambda x: x["name"])
return Response(TypeCreateSerializer(data, many=True).data)
class FlowStageBindingSerializer(ModelSerializer):
"""FlowStageBinding Serializer"""

View File

@ -50,21 +50,21 @@ def create_default_authentication_flow(
target=flow,
stage=identification_stage,
defaults={
"order": 0,
"order": 10,
},
)
FlowStageBinding.objects.using(db_alias).update_or_create(
target=flow,
stage=password_stage,
defaults={
"order": 1,
"order": 20,
},
)
FlowStageBinding.objects.using(db_alias).update_or_create(
target=flow,
stage=login_stage,
defaults={
"order": 2,
"order": 100,
},
)

View File

@ -37,7 +37,7 @@ class TestFlowsAPI(APITestCase):
def test_api_serializer(self):
"""Test that stage serializer returns the correct type"""
obj = DummyStage()
self.assertEqual(StageSerializer().get_type(obj), "dummy")
self.assertEqual(StageSerializer().get_object_type(obj), "dummy")
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
def test_api_viewset(self):

View File

@ -1,9 +1,10 @@
"""transfer common classes"""
from dataclasses import asdict, dataclass, field, is_dataclass
from json.encoder import JSONEncoder
from typing import Any, Dict, List
from uuid import UUID
from django.core.serializers.json import DjangoJSONEncoder
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
@ -11,7 +12,17 @@ from authentik.lib.sentry import SentryIgnoredException
def get_attrs(obj: SerializerModel) -> Dict[str, Any]:
"""Get object's attributes via their serializer, and covert it to a normal dict"""
data = dict(obj.serializer(obj).data)
to_remove = ("policies", "stages", "pk", "background", "group", "user")
to_remove = (
"policies",
"stages",
"pk",
"background",
"group",
"user",
"verbose_name",
"verbose_name_plural",
"object_type",
)
for to_remove_name in to_remove:
if to_remove_name in data:
data.pop(to_remove_name)
@ -53,7 +64,7 @@ class FlowBundle:
entries: List[FlowBundleEntry] = field(default_factory=list)
class DataclassEncoder(JSONEncoder):
class DataclassEncoder(DjangoJSONEncoder):
"""Convert FlowBundleEntry to json"""
def default(self, o):

View File

@ -98,4 +98,5 @@ class FlowExporter:
def export_to_string(self) -> str:
"""Call export and convert it to json"""
return dumps(self.export(), cls=DataclassEncoder)
bundle = self.export()
return dumps(bundle, cls=DataclassEncoder)

View File

@ -60,8 +60,11 @@ class Migration(migrations.Migration):
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.stages.otp_static", "authentik OTP.Static"),
("authentik.stages.otp_time", "authentik OTP.Time"),
("authentik.stages.otp_validate", "authentik OTP.Validate"),
("authentik.stages.authenticator_totp", "authentik OTP.Time"),
(
"authentik.stages.authenticator_validate",
"authentik OTP.Validate",
),
("authentik.stages.password", "authentik Stages.Password"),
("authentik.core", "authentik Core"),
],

View File

@ -67,8 +67,14 @@ class Migration(migrations.Migration):
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.stages.otp_static", "authentik Stages.OTP.Static"),
("authentik.stages.otp_time", "authentik Stages.OTP.Time"),
("authentik.stages.otp_validate", "authentik Stages.OTP.Validate"),
(
"authentik.stages.authenticator_totp",
"authentik Stages.OTP.Time",
),
(
"authentik.stages.authenticator_validate",
"authentik Stages.OTP.Validate",
),
("authentik.stages.password", "authentik Stages.Password"),
("authentik.core", "authentik Core"),
],

View File

@ -60,8 +60,14 @@ class Migration(migrations.Migration):
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.stages.otp_static", "authentik Stages.OTP.Static"),
("authentik.stages.otp_time", "authentik Stages.OTP.Time"),
("authentik.stages.otp_validate", "authentik Stages.OTP.Validate"),
(
"authentik.stages.authenticator_totp",
"authentik Stages.OTP.Time",
),
(
"authentik.stages.authenticator_validate",
"authentik Stages.OTP.Validate",
),
("authentik.stages.password", "authentik Stages.Password"),
("authentik.managed", "authentik Managed"),
("authentik.core", "authentik Core"),

View File

@ -0,0 +1,83 @@
# Generated by Django 3.1.6 on 2021-02-13 16:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0007_auto_20210209_1657"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="app",
field=models.TextField(
blank=True,
choices=[
("authentik.admin", "authentik Admin"),
("authentik.api", "authentik API"),
("authentik.events", "authentik Events"),
("authentik.crypto", "authentik Crypto"),
("authentik.flows", "authentik Flows"),
("authentik.outposts", "authentik Outpost"),
("authentik.lib", "authentik lib"),
("authentik.policies", "authentik Policies"),
("authentik.policies.dummy", "authentik Policies.Dummy"),
(
"authentik.policies.event_matcher",
"authentik Policies.Event Matcher",
),
("authentik.policies.expiry", "authentik Policies.Expiry"),
("authentik.policies.expression", "authentik Policies.Expression"),
(
"authentik.policies.group_membership",
"authentik Policies.Group Membership",
),
("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"),
("authentik.policies.password", "authentik Policies.Password"),
("authentik.policies.reputation", "authentik Policies.Reputation"),
("authentik.providers.proxy", "authentik Providers.Proxy"),
("authentik.providers.oauth2", "authentik Providers.OAuth2"),
("authentik.providers.saml", "authentik Providers.SAML"),
("authentik.recovery", "authentik Recovery"),
("authentik.sources.ldap", "authentik Sources.LDAP"),
("authentik.sources.oauth", "authentik Sources.OAuth"),
("authentik.sources.saml", "authentik Sources.SAML"),
("authentik.stages.captcha", "authentik Stages.Captcha"),
("authentik.stages.consent", "authentik Stages.Consent"),
("authentik.stages.dummy", "authentik Stages.Dummy"),
("authentik.stages.email", "authentik Stages.Email"),
("authentik.stages.prompt", "authentik Stages.Prompt"),
(
"authentik.stages.identification",
"authentik Stages.Identification",
),
("authentik.stages.invitation", "authentik Stages.User Invitation"),
("authentik.stages.user_delete", "authentik Stages.User Delete"),
("authentik.stages.user_login", "authentik Stages.User Login"),
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.stages.otp_static", "authentik Stages.OTP.Static"),
(
"authentik.stages.authenticator_totp",
"authentik Stages.OTP.Time",
),
(
"authentik.stages.authenticator_validate",
"authentik Stages.OTP.Validate",
),
("authentik.stages.password", "authentik Stages.Password"),
(
"authentik.stages.authenticator_webauthn",
"authentik Stages.WebAuthn",
),
("authentik.managed", "authentik Managed"),
("authentik.core", "authentik Core"),
],
default="",
help_text="Match events created by selected application. When left empty, all applications are matched.",
),
),
]

View File

@ -0,0 +1,86 @@
# Generated by Django 3.1.6 on 2021-02-15 21:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0008_auto_20210213_1640"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="app",
field=models.TextField(
blank=True,
choices=[
("authentik.admin", "authentik Admin"),
("authentik.api", "authentik API"),
("authentik.events", "authentik Events"),
("authentik.crypto", "authentik Crypto"),
("authentik.flows", "authentik Flows"),
("authentik.outposts", "authentik Outpost"),
("authentik.lib", "authentik lib"),
("authentik.policies", "authentik Policies"),
("authentik.policies.dummy", "authentik Policies.Dummy"),
(
"authentik.policies.event_matcher",
"authentik Policies.Event Matcher",
),
("authentik.policies.expiry", "authentik Policies.Expiry"),
("authentik.policies.expression", "authentik Policies.Expression"),
(
"authentik.policies.group_membership",
"authentik Policies.Group Membership",
),
("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"),
("authentik.policies.password", "authentik Policies.Password"),
("authentik.policies.reputation", "authentik Policies.Reputation"),
("authentik.providers.proxy", "authentik Providers.Proxy"),
("authentik.providers.oauth2", "authentik Providers.OAuth2"),
("authentik.providers.saml", "authentik Providers.SAML"),
("authentik.recovery", "authentik Recovery"),
("authentik.sources.ldap", "authentik Sources.LDAP"),
("authentik.sources.oauth", "authentik Sources.OAuth"),
("authentik.sources.saml", "authentik Sources.SAML"),
("authentik.stages.captcha", "authentik Stages.Captcha"),
("authentik.stages.consent", "authentik Stages.Consent"),
("authentik.stages.dummy", "authentik Stages.Dummy"),
("authentik.stages.email", "authentik Stages.Email"),
("authentik.stages.prompt", "authentik Stages.Prompt"),
(
"authentik.stages.identification",
"authentik Stages.Identification",
),
("authentik.stages.invitation", "authentik Stages.User Invitation"),
("authentik.stages.user_delete", "authentik Stages.User Delete"),
("authentik.stages.user_login", "authentik Stages.User Login"),
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
(
"authentik.stages.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.password", "authentik Stages.Password"),
("authentik.managed", "authentik Managed"),
("authentik.core", "authentik Core"),
],
default="",
help_text="Match events created by selected application. When left empty, all applications are matched.",
),
),
]

View File

@ -119,9 +119,10 @@ INSTALLED_APPS = [
"authentik.stages.user_login.apps.AuthentikStageUserLoginConfig",
"authentik.stages.user_logout.apps.AuthentikStageUserLogoutConfig",
"authentik.stages.user_write.apps.AuthentikStageUserWriteConfig",
"authentik.stages.otp_static.apps.AuthentikStageOTPStaticConfig",
"authentik.stages.otp_time.apps.AuthentikStageOTPTimeConfig",
"authentik.stages.otp_validate.apps.AuthentikStageOTPValidateConfig",
"authentik.stages.authenticator_static.apps.AuthentikStageAuthenticatorStaticConfig",
"authentik.stages.authenticator_totp.apps.AuthentikStageAuthenticatorTOTPConfig",
"authentik.stages.authenticator_validate.apps.AuthentikStageAuthenticatorValidateConfig",
"authentik.stages.authenticator_webauthn.apps.AuthentikStageAuthenticatorWebAuthnConfig",
"authentik.stages.password.apps.AuthentikStagePasswordConfig",
"rest_framework",
"django_filters",

View File

@ -0,0 +1,21 @@
"""AuthenticatorStaticStage API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
class AuthenticatorStaticStageSerializer(StageSerializer):
"""AuthenticatorStaticStage Serializer"""
class Meta:
model = AuthenticatorStaticStage
fields = StageSerializer.Meta.fields + ["configure_flow", "token_count"]
class AuthenticatorStaticStageViewSet(ModelViewSet):
"""AuthenticatorStaticStage Viewset"""
queryset = AuthenticatorStaticStage.objects.all()
serializer_class = AuthenticatorStaticStageSerializer

View File

@ -0,0 +1,11 @@
"""Authenticator Static stage"""
from django.apps import AppConfig
class AuthentikStageAuthenticatorStaticConfig(AppConfig):
"""Authenticator Static stage"""
name = "authentik.stages.authenticator_static"
label = "authentik_stages_authenticator_static"
verbose_name = "authentik Stages.Authenticator.Static"
mountpoint = "-/user/authenticator/static/"

View File

@ -1,8 +1,8 @@
"""OTP Static forms"""
"""Static Authenticator forms"""
from django import forms
from django.utils.safestring import mark_safe
from authentik.stages.otp_static.models import OTPStaticStage
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
class StaticTokenWidget(forms.widgets.Widget):
@ -26,12 +26,12 @@ class SetupForm(forms.Form):
self.fields["tokens"].initial = tokens
class OTPStaticStageForm(forms.ModelForm):
"""OTP Static Stage setup form"""
class AuthenticatorStaticStageForm(forms.ModelForm):
"""Static Authenticator Stage setup form"""
class Meta:
model = OTPStaticStage
model = AuthenticatorStaticStage
fields = ["name", "configure_flow", "token_count"]
widgets = {

View File

@ -30,8 +30,8 @@ class Migration(migrations.Migration):
("token_count", models.IntegerField(default=6)),
],
options={
"verbose_name": "OTP Static Setup Stage",
"verbose_name_plural": "OTP Static Setup Stages",
"verbose_name": "Static Authenticator Setup Stage",
"verbose_name_plural": "Static Authenticator Setup Stages",
},
bases=("authentik_flows.stage",),
),

View File

@ -8,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0013_auto_20200924_1605"),
("authentik_stages_otp_static", "0001_initial"),
("authentik_stages_authenticator_static", "0001_initial"),
]
operations = [

View File

@ -0,0 +1,15 @@
# Generated by Django 3.1.1 on 2020-09-25 14:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_static",
"0002_otpstaticstage_configure_flow",
),
]
operations = []

View File

@ -0,0 +1,25 @@
# Generated by Django 3.1.6 on 2021-02-16 08:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0016_auto_20201202_1307"),
("authentik_stages_authenticator_static", "0003_default_setup_flow"),
]
operations = [
migrations.RenameModel(
old_name="OTPStaticStage",
new_name="AuthenticatorStaticStage",
),
migrations.AlterModelOptions(
name="authenticatorstaticstage",
options={
"verbose_name": "Static Authenticator Stage",
"verbose_name_plural": "Static Authenticator Stages",
},
),
]

View File

@ -0,0 +1,55 @@
# Generated by Django 3.1.1 on 2020-09-25 14:32
from django.apps.registry import Apps
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from authentik.flows.models import FlowDesignation
def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Flow = apps.get_model("authentik_flows", "Flow")
FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
AuthenticatorStaticStage = apps.get_model(
"authentik_stages_authenticator_static", "AuthenticatorStaticStage"
)
db_alias = schema_editor.connection.alias
flow, _ = Flow.objects.using(db_alias).update_or_create(
slug="default-authenticator-static-setup",
designation=FlowDesignation.STAGE_CONFIGURATION,
defaults={
"name": "default-authenticator-static-setup",
"title": "Setup Static OTP Tokens",
},
)
stage, _ = AuthenticatorStaticStage.objects.using(db_alias).update_or_create(
name="default-authenticator-static-setup", defaults={"token_count": 6}
)
FlowStageBinding.objects.using(db_alias).update_or_create(
target=flow, stage=stage, defaults={"order": 0}
)
for stage in AuthenticatorStaticStage.objects.using(db_alias).filter(
configure_flow=None
):
stage.configure_flow = flow
stage.save()
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_static",
"0004_auto_20210216_0838",
),
]
operations = [
migrations.RunPython(create_default_setup_flow),
]

View File

@ -0,0 +1,56 @@
"""Static Authenticator models"""
from typing import Optional, Type
from django.db import models
from django.forms import ModelForm
from django.shortcuts import reverse
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import ConfigurableStage, Stage
class AuthenticatorStaticStage(ConfigurableStage, Stage):
"""Generate static tokens for the user as a backup."""
token_count = models.IntegerField(default=6)
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.authenticator_static.api import (
AuthenticatorStaticStageSerializer,
)
return AuthenticatorStaticStageSerializer
@property
def type(self) -> Type[View]:
from authentik.stages.authenticator_static.stage import (
AuthenticatorStaticStageView,
)
return AuthenticatorStaticStageView
@property
def form(self) -> Type[ModelForm]:
from authentik.stages.authenticator_static.forms import (
AuthenticatorStaticStageForm,
)
return AuthenticatorStaticStageForm
@property
def ui_user_settings(self) -> Optional[str]:
return reverse(
"authentik_stages_authenticator_static:user-settings",
kwargs={"stage_uuid": self.stage_uuid},
)
def __str__(self) -> str:
return f"Static Authenticator Stage {self.name}"
class Meta:
verbose_name = _("Static Authenticator Stage")
verbose_name_plural = _("Static Authenticator Stages")

View File

@ -1,4 +1,4 @@
"""OTP Static settings"""
"""Static Authenticator settings"""
INSTALLED_APPS = [
"django_otp.plugins.otp_static",

View File

@ -8,15 +8,15 @@ from structlog.stdlib import get_logger
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.stages.otp_static.forms import SetupForm
from authentik.stages.otp_static.models import OTPStaticStage
from authentik.stages.authenticator_static.forms import SetupForm
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
LOGGER = get_logger()
SESSION_STATIC_DEVICE = "static_device"
SESSION_STATIC_TOKENS = "static_device_tokens"
class OTPStaticStageView(FormView, StageView):
class AuthenticatorStaticStageView(FormView, StageView):
"""Static OTP Setup stage"""
form_class = SetupForm
@ -38,7 +38,7 @@ class OTPStaticStageView(FormView, StageView):
if StaticDevice.objects.filter(user=user).exists():
return self.executor.stage_ok()
stage: OTPStaticStage = self.executor.current_stage
stage: AuthenticatorStaticStage = self.executor.current_stage
if SESSION_STATIC_DEVICE not in self.request.session:
device = StaticDevice(user=user, confirmed=True)

View File

@ -22,10 +22,10 @@
</ul>
{% if not state %}
{% if stage.configure_flow %}
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next={% url 'authentik_core:user-settings' %}" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Static Tokens" %}</a>
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23{% url 'authentik_core:user-settings' %}" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Static Tokens" %}</a>
{% endif %}
{% else %}
<a href="{% url 'authentik_stages_otp_static:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Static Tokens" %}</a>
<a href="{% url 'authentik_stages_authenticator_static:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Static Tokens" %}</a>
{% endif %}
</div>
</div>

View File

@ -1,7 +1,7 @@
"""OTP static urls"""
"""Static Authenticator urls"""
from django.urls import path
from authentik.stages.otp_static.views import DisableView, UserSettingsView
from authentik.stages.authenticator_static.views import DisableView, UserSettingsView
urlpatterns = [
path(

View File

@ -1,4 +1,4 @@
"""otp Static view Tokens"""
"""Static Authenticator view Tokens"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse
@ -8,17 +8,19 @@ from django.views.generic import TemplateView
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from authentik.events.models import Event
from authentik.stages.otp_static.models import OTPStaticStage
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control OTP"""
template_name = "stages/otp_static/user_settings.html"
template_name = "stages/authenticator_static/user_settings.html"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
stage = get_object_or_404(OTPStaticStage, pk=self.kwargs["stage_uuid"])
stage = get_object_or_404(
AuthenticatorStaticStage, pk=self.kwargs["stage_uuid"]
)
kwargs["stage"] = stage
static_devices = StaticDevice.objects.filter(
user=self.request.user, confirmed=True

View File

@ -0,0 +1,21 @@
"""AuthenticatorTOTPStage API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
class AuthenticatorTOTPStageSerializer(StageSerializer):
"""AuthenticatorTOTPStage Serializer"""
class Meta:
model = AuthenticatorTOTPStage
fields = StageSerializer.Meta.fields + ["configure_flow", "digits"]
class AuthenticatorTOTPStageViewSet(ModelViewSet):
"""AuthenticatorTOTPStage Viewset"""
queryset = AuthenticatorTOTPStage.objects.all()
serializer_class = AuthenticatorTOTPStageSerializer

View File

@ -0,0 +1,11 @@
"""OTP Time"""
from django.apps import AppConfig
class AuthentikStageAuthenticatorTOTPConfig(AppConfig):
"""TOTP App config"""
name = "authentik.stages.authenticator_totp"
label = "authentik_stages_authenticator_totp"
verbose_name = "authentik Stages.Authenticator.TOTP"
mountpoint = "-/user/authenticator/totp/"

View File

@ -4,7 +4,7 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_otp.models import Device
from authentik.stages.otp_time.models import OTPTimeStage
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
class PictureWidget(forms.widgets.Widget):
@ -49,12 +49,12 @@ class SetupForm(forms.Form):
return self.cleaned_data.get("code")
class OTPTimeStageForm(forms.ModelForm):
class AuthenticatorTOTPStageForm(forms.ModelForm):
"""OTP Time-based Stage setup form"""
class Meta:
model = OTPTimeStage
model = AuthenticatorTOTPStage
fields = ["name", "configure_flow", "digits"]
widgets = {

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_otp_time", "0001_initial"),
("authentik_stages_authenticator_totp", "0001_initial"),
]
operations = [

View File

@ -8,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0013_auto_20200924_1605"),
("authentik_stages_otp_time", "0002_auto_20200701_1900"),
("authentik_stages_authenticator_totp", "0002_auto_20200701_1900"),
]
operations = [

View File

@ -0,0 +1,15 @@
# Generated by Django 3.1.1 on 2020-09-25 15:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_totp",
"0003_otptimestage_configure_flow",
),
]
operations = []

View File

@ -0,0 +1,25 @@
# Generated by Django 3.1.6 on 2021-02-16 08:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0016_auto_20201202_1307"),
("authentik_stages_authenticator_totp", "0004_default_setup_flow"),
]
operations = [
migrations.RenameModel(
old_name="OTPTimeStage",
new_name="AuthenticatorTOTPStage",
),
migrations.AlterModelOptions(
name="authenticatortotpstage",
options={
"verbose_name": "TOTP Authenticator Setup Stage",
"verbose_name_plural": "TOTP Authenticator Setup Stages",
},
),
]

View File

@ -5,35 +5,39 @@ from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from authentik.flows.models import FlowDesignation
from authentik.stages.otp_time.models import TOTPDigits
from authentik.stages.authenticator_totp.models import TOTPDigits
def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Flow = apps.get_model("authentik_flows", "Flow")
FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
OTPTimeStage = apps.get_model("authentik_stages_otp_time", "OTPTimeStage")
AuthenticatorTOTPStage = apps.get_model(
"authentik_stages_authenticator_totp", "AuthenticatorTOTPStage"
)
db_alias = schema_editor.connection.alias
flow, _ = Flow.objects.using(db_alias).update_or_create(
slug="default-otp-time-configure",
slug="default-authenticator-totp-setup",
designation=FlowDesignation.STAGE_CONFIGURATION,
defaults={
"name": "default-otp-time-configure",
"name": "default-authenticator-totp-setup",
"title": "Setup Two-Factor authentication",
},
)
stage, _ = OTPTimeStage.objects.using(db_alias).update_or_create(
name="default-otp-time-configure", defaults={"digits": TOTPDigits.SIX}
stage, _ = AuthenticatorTOTPStage.objects.using(db_alias).update_or_create(
name="default-authenticator-totp-setup", defaults={"digits": TOTPDigits.SIX}
)
FlowStageBinding.objects.using(db_alias).update_or_create(
target=flow, stage=stage, defaults={"order": 0}
)
for stage in OTPTimeStage.objects.using(db_alias).filter(configure_flow=None):
for stage in AuthenticatorTOTPStage.objects.using(db_alias).filter(
configure_flow=None
):
stage.configure_flow = flow
stage.save()
@ -41,7 +45,10 @@ def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_otp_time", "0003_otptimestage_configure_flow"),
(
"authentik_stages_authenticator_totp",
"0005_auto_20210216_0838",
),
]
operations = [

View File

@ -18,40 +18,42 @@ class TOTPDigits(models.IntegerChoices):
EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator")
class OTPTimeStage(ConfigurableStage, Stage):
class AuthenticatorTOTPStage(ConfigurableStage, Stage):
"""Enroll a user's device into Time-based OTP."""
digits = models.IntegerField(choices=TOTPDigits.choices)
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.otp_time.api import OTPTimeStageSerializer
from authentik.stages.authenticator_totp.api import (
AuthenticatorTOTPStageSerializer,
)
return OTPTimeStageSerializer
return AuthenticatorTOTPStageSerializer
@property
def type(self) -> Type[View]:
from authentik.stages.otp_time.stage import OTPTimeStageView
from authentik.stages.authenticator_totp.stage import AuthenticatorTOTPStageView
return OTPTimeStageView
return AuthenticatorTOTPStageView
@property
def form(self) -> Type[ModelForm]:
from authentik.stages.otp_time.forms import OTPTimeStageForm
from authentik.stages.authenticator_totp.forms import AuthenticatorTOTPStageForm
return OTPTimeStageForm
return AuthenticatorTOTPStageForm
@property
def ui_user_settings(self) -> Optional[str]:
return reverse(
"authentik_stages_otp_time:user-settings",
"authentik_stages_authenticator_totp:user-settings",
kwargs={"stage_uuid": self.stage_uuid},
)
def __str__(self) -> str:
return f"OTP Time (TOTP) Stage {self.name}"
return f"TOTP Authenticator Setup Stage {self.name}"
class Meta:
verbose_name = _("OTP Time (TOTP) Setup Stage")
verbose_name_plural = _("OTP Time (TOTP) Setup Stages")
verbose_name = _("TOTP Authenticator Setup Stage")
verbose_name_plural = _("TOTP Authenticator Setup Stages")

View File

@ -12,14 +12,14 @@ from structlog.stdlib import get_logger
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.stages.otp_time.forms import SetupForm
from authentik.stages.otp_time.models import OTPTimeStage
from authentik.stages.authenticator_totp.forms import SetupForm
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
LOGGER = get_logger()
SESSION_TOTP_DEVICE = "totp_device"
class OTPTimeStageView(FormView, StageView):
class AuthenticatorTOTPStageView(FormView, StageView):
"""OTP totp Setup stage"""
form_class = SetupForm
@ -50,7 +50,7 @@ class OTPTimeStageView(FormView, StageView):
if TOTPDevice.objects.filter(user=user).exists():
return self.executor.stage_ok()
stage: OTPTimeStage = self.executor.current_stage
stage: AuthenticatorTOTPStage = self.executor.current_stage
if SESSION_TOTP_DEVICE not in self.request.session:
device = TOTPDevice(user=user, confirmed=True, digits=stage.digits)

View File

@ -18,10 +18,10 @@
<p>
{% if not state %}
{% if stage.configure_flow %}
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next={% url 'authentik_core:user-settings' %}" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Time-based OTP" %}</a>
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23{% url 'authentik_core:user-settings' %}" class="ak-root-link pf-c-button pf-m-primary">{% trans "Enable Time-based OTP" %}</a>
{% endif %}
{% else %}
<a href="{% url 'authentik_stages_otp_time:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Time-based OTP" %}</a>
<a href="{% url 'authentik_stages_authenticator_totp:disable' stage_uuid=stage.stage_uuid %}" class="ak-root-pf-c-button pf-m-danger">{% trans "Disable Time-based OTP" %}</a>
{% endif %}
</p>
</div>

View File

@ -1,7 +1,7 @@
"""OTP Time urls"""
from django.urls import path
from authentik.stages.otp_time.views import DisableView, UserSettingsView
from authentik.stages.authenticator_totp.views import DisableView, UserSettingsView
urlpatterns = [
path(

View File

@ -8,17 +8,17 @@ from django.views.generic import TemplateView
from django_otp.plugins.otp_totp.models import TOTPDevice
from authentik.events.models import Event
from authentik.stages.otp_time.models import OTPTimeStage
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control OTP"""
template_name = "stages/otp_time/user_settings.html"
template_name = "stages/authenticator_totp/user_settings.html"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
stage = get_object_or_404(OTPTimeStage, pk=self.kwargs["stage_uuid"])
stage = get_object_or_404(AuthenticatorTOTPStage, pk=self.kwargs["stage_uuid"])
kwargs["stage"] = stage
totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True)

View File

@ -0,0 +1,24 @@
"""AuthenticatorValidateStage API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
class AuthenticatorValidateStageSerializer(StageSerializer):
"""AuthenticatorValidateStage Serializer"""
class Meta:
model = AuthenticatorValidateStage
fields = StageSerializer.Meta.fields + [
"not_configured_action",
"device_classes",
]
class AuthenticatorValidateStageViewSet(ModelViewSet):
"""AuthenticatorValidateStage Viewset"""
queryset = AuthenticatorValidateStage.objects.all()
serializer_class = AuthenticatorValidateStageSerializer

View File

@ -0,0 +1,10 @@
"""Authenticator Validation Stage"""
from django.apps import AppConfig
class AuthentikStageAuthenticatorValidateConfig(AppConfig):
"""Authenticator Validation Stage"""
name = "authentik.stages.authenticator_validate"
label = "authentik_stages_authenticator_validate"
verbose_name = "authentik Stages.Authenticator.Validate"

View File

@ -4,7 +4,10 @@ from django.utils.translation import gettext_lazy as _
from django_otp import match_token
from authentik.core.models import User
from authentik.stages.otp_validate.models import OTPValidateStage
from authentik.stages.authenticator_validate.models import (
AuthenticatorValidateStage,
DeviceClasses,
)
class ValidationForm(forms.Form):
@ -36,14 +39,15 @@ class ValidationForm(forms.Form):
return code
class OTPValidateStageForm(forms.ModelForm):
class AuthenticatorValidateStageForm(forms.ModelForm):
"""OTP Validate stage forms"""
class Meta:
model = OTPValidateStage
fields = ["name"]
model = AuthenticatorValidateStage
fields = ["name", "device_classes"]
widgets = {
"name": forms.TextInput(),
"device_classes": forms.SelectMultiple(choices=DeviceClasses.choices),
}

View File

@ -0,0 +1,25 @@
# Generated by Django 3.1.6 on 2021-02-16 08:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0016_auto_20201202_1307"),
("authentik_stages_authenticator_validate", "0001_initial"),
]
operations = [
migrations.RenameModel(
old_name="OTPValidateStage",
new_name="AuthenticatorValidateStage",
),
migrations.AlterModelOptions(
name="authenticatorvalidatestage",
options={
"verbose_name": "Authenticator Validation Stage",
"verbose_name_plural": "Authenticator Validation Stages",
},
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.1.6 on 2021-02-16 13:03
import django.contrib.postgres.fields
from django.db import migrations, models
import authentik.stages.authenticator_validate.models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_authenticator_validate", "0002_auto_20210216_0838"),
]
operations = [
migrations.AddField(
model_name="authenticatorvalidatestage",
name="device_classes",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
default=authentik.stages.authenticator_validate.models.default_device_classes,
help_text="Device classes which can be used to authenticate",
size=None,
),
),
]

View File

@ -0,0 +1,74 @@
"""Authenticator Validation Stage"""
from typing import Type
from django.contrib.postgres.fields.array import ArrayField
from django.db import models
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import NotConfiguredAction, Stage
class DeviceClasses(models.TextChoices):
"""Device classes this stage can validate"""
STATIC = "static"
TOTP = "totp", _("TOTP")
WEBAUTHN = "webauthn", _("WebAuthn")
def default_device_classes() -> list:
"""By default, accept all device classes"""
return [
DeviceClasses.STATIC,
DeviceClasses.TOTP,
DeviceClasses.WEBAUTHN,
]
class AuthenticatorValidateStage(Stage):
"""Validate user's configured OTP Device."""
not_configured_action = models.TextField(
choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP
)
device_classes = ArrayField(
models.TextField(),
help_text=_("Device classes which can be used to authenticate"),
default=default_device_classes,
)
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.authenticator_validate.api import (
AuthenticatorValidateStageSerializer,
)
return AuthenticatorValidateStageSerializer
@property
def type(self) -> Type[View]:
from authentik.stages.authenticator_validate.stage import (
AuthenticatorValidateStageView,
)
return AuthenticatorValidateStageView
@property
def form(self) -> Type[ModelForm]:
from authentik.stages.authenticator_validate.forms import (
AuthenticatorValidateStageForm,
)
return AuthenticatorValidateStageForm
def __str__(self) -> str:
return f"Authenticator Validation Stage {self.name}"
class Meta:
verbose_name = _("Authenticator Validation Stage")
verbose_name_plural = _("Authenticator Validation Stages")

View File

@ -9,13 +9,13 @@ from structlog.stdlib import get_logger
from authentik.flows.models import NotConfiguredAction
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.stages.otp_validate.forms import ValidationForm
from authentik.stages.otp_validate.models import OTPValidateStage
from authentik.stages.authenticator_validate.forms import ValidationForm
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
LOGGER = get_logger()
class OTPValidateStageView(FormView, StageView):
class AuthenticatorValidateStageView(FormView, StageView):
"""OTP Validation"""
form_class = ValidationForm
@ -31,11 +31,11 @@ class OTPValidateStageView(FormView, StageView):
LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok()
has_devices = user_has_device(user)
stage: OTPValidateStage = self.executor.current_stage
stage: AuthenticatorValidateStage = self.executor.current_stage
if not has_devices:
if stage.not_configured_action == NotConfiguredAction.SKIP:
LOGGER.debug("OTP not configured, skipping stage")
LOGGER.debug("Authenticator not configured, skipping stage")
return self.executor.stage_ok()
return super().get(request, *args, **kwargs)

View File

@ -0,0 +1,21 @@
"""AuthenticateWebAuthnStage API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage
class AuthenticateWebAuthnStageSerializer(StageSerializer):
"""AuthenticateWebAuthnStage Serializer"""
class Meta:
model = AuthenticateWebAuthnStage
fields = StageSerializer.Meta.fields
class AuthenticateWebAuthnStageViewSet(ModelViewSet):
"""AuthenticateWebAuthnStage Viewset"""
queryset = AuthenticateWebAuthnStage.objects.all()
serializer_class = AuthenticateWebAuthnStageSerializer

View File

@ -0,0 +1,11 @@
"""authentik webauthn app config"""
from django.apps import AppConfig
class AuthentikStageAuthenticatorWebAuthnConfig(AppConfig):
"""authentik webauthn config"""
name = "authentik.stages.authenticator_webauthn"
label = "authentik_stages_authenticator_webauthn"
verbose_name = "authentik Stages.Authenticator.WebAuthn"
mountpoint = "-/user/authenticator/webauthn/"

View File

@ -0,0 +1,17 @@
"""Webauthn stage forms"""
from django import forms
from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage
class AuthenticateWebAuthnStageForm(forms.ModelForm):
"""OTP Time-based Stage setup form"""
class Meta:
model = AuthenticateWebAuthnStage
fields = ["name"]
widgets = {
"name": forms.TextInput(),
}

View File

@ -0,0 +1,81 @@
# Generated by Django 3.1.6 on 2021-02-17 10:48
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("authentik_flows", "0016_auto_20201202_1307"),
]
operations = [
migrations.CreateModel(
name="WebAuthnDevice",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.TextField(max_length=200)),
("credential_id", models.CharField(max_length=300, unique=True)),
("public_key", models.TextField()),
("sign_count", models.IntegerField(default=0)),
("rp_id", models.CharField(max_length=253)),
("created_on", models.DateTimeField(auto_now_add=True)),
(
"last_used_on",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="AuthenticateWebAuthnStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
(
"configure_flow",
models.ForeignKey(
blank=True,
help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "WebAuthn Authenticator Setup Stage",
"verbose_name_plural": "WebAuthn Authenticator Setup Stages",
},
bases=("authentik_flows.stage", models.Model),
),
]

View File

@ -11,28 +11,32 @@ def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
Flow = apps.get_model("authentik_flows", "Flow")
FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
OTPStaticStage = apps.get_model("authentik_stages_otp_static", "OTPStaticStage")
AuthenticateWebAuthnStage = apps.get_model(
"authentik_stages_authenticator_webauthn", "AuthenticateWebAuthnStage"
)
db_alias = schema_editor.connection.alias
flow, _ = Flow.objects.using(db_alias).update_or_create(
slug="default-otp-static-configure",
slug="default-authenticator-webuahtn-setup",
designation=FlowDesignation.STAGE_CONFIGURATION,
defaults={
"name": "default-otp-static-configure",
"name": "default-authenticator-webuahtn-setup",
"title": "Setup Static OTP Tokens",
},
)
stage, _ = OTPStaticStage.objects.using(db_alias).update_or_create(
name="default-otp-static-configure", defaults={"token_count": 6}
stage, _ = AuthenticateWebAuthnStage.objects.using(db_alias).update_or_create(
name="default-authenticator-webuahtn-setup"
)
FlowStageBinding.objects.using(db_alias).update_or_create(
target=flow, stage=stage, defaults={"order": 0}
)
for stage in OTPStaticStage.objects.using(db_alias).filter(configure_flow=None):
for stage in AuthenticateWebAuthnStage.objects.using(db_alias).filter(
configure_flow=None
):
stage.configure_flow = flow
stage.save()
@ -40,7 +44,10 @@ def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_otp_static", "0002_otpstaticstage_configure_flow"),
(
"authentik_stages_authenticator_webauthn",
"0001_initial",
),
]
operations = [

View File

@ -0,0 +1,80 @@
"""WebAuthn stage"""
from typing import Optional, Type
from django.contrib.auth import get_user_model
from django.db import models
from django.forms import ModelForm
from django.shortcuts import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import ConfigurableStage, Stage
class AuthenticateWebAuthnStage(ConfigurableStage, Stage):
"""WebAuthn stage"""
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.authenticator_webauthn.api import (
AuthenticateWebAuthnStageSerializer,
)
return AuthenticateWebAuthnStageSerializer
@property
def type(self) -> Type[View]:
from authentik.stages.authenticator_webauthn.stage import (
AuthenticateWebAuthnStageView,
)
return AuthenticateWebAuthnStageView
@property
def form(self) -> Type[ModelForm]:
from authentik.stages.authenticator_webauthn.forms import (
AuthenticateWebAuthnStageForm,
)
return AuthenticateWebAuthnStageForm
@property
def ui_user_settings(self) -> Optional[str]:
return reverse(
"authentik_stages_authenticator_webauthn:user-settings",
kwargs={"stage_uuid": self.stage_uuid},
)
def __str__(self) -> str:
return f"WebAuthn Authenticator Setup Stage {self.name}"
class Meta:
verbose_name = _("WebAuthn Authenticator Setup Stage")
verbose_name_plural = _("WebAuthn Authenticator Setup Stages")
class WebAuthnDevice(models.Model):
"""WebAuthn Device for a single user"""
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
name = models.TextField(max_length=200)
credential_id = models.CharField(max_length=300, unique=True)
public_key = models.TextField()
sign_count = models.IntegerField(default=0)
rp_id = models.CharField(max_length=253)
created_on = models.DateTimeField(auto_now_add=True)
last_used_on = models.DateTimeField(default=now)
def set_sign_count(self, sign_count: int) -> None:
"""Set the sign_count and update the last_used_on datetime."""
self.sign_count = sign_count
self.last_used_on = now()
self.save()
def __str__(self):
return self.name or str(self.user)

View File

@ -0,0 +1,44 @@
"""WebAuthn stage"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.generic import FormView
from structlog.stdlib import get_logger
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
LOGGER = get_logger()
SESSION_KEY_WEBAUTHN_AUTHENTICATED = (
"authentik_stages_authenticator_webauthn_authenticated"
)
class AuthenticateWebAuthnStageView(FormView, StageView):
"""WebAuthn stage"""
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user:
LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok()
devices = WebAuthnDevice.objects.filter(user=user)
# If the current user is logged in already, or the pending user
# has no devices, show setup
if self.request.user == user:
# Because the user is already authenticated, skip the later check
self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True
return render(request, "stages/authenticator_webauthn/setup.html")
if not devices.exists():
return self.executor.stage_ok()
self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = False
return render(request, "stages/authenticator_webauthn/auth.html")
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Since the client can't directly indicate when a stage is done,
we use the post handler for this"""
if request.session.pop(SESSION_KEY_WEBAUTHN_AUTHENTICATED, False):
return self.executor.stage_ok()
return self.executor.stage_invalid()

View File

@ -0,0 +1,15 @@
{% load i18n %}
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% trans 'WebAuthn' %}
</h1>
</header>
<div class="pf-c-login__main-body">
{% block card %}
<div class="pf-c-form">
<ak-stage-webauthn-auth>
</ak-stage-webauthn-auth>
</div>
{% endblock %}
</div>

View File

@ -0,0 +1,16 @@
{% load i18n %}
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% trans 'Configure WebAuthn' %}
</h1>
</header>
<div class="pf-c-login__main-body">
{% block card %}
<div class="pf-c-form">
<ak-stage-webauthn-register>
</ak-stage-webauthn-register>
</div>
{% endblock %}
</div>

View File

@ -0,0 +1,33 @@
{% load i18n %}
{% load humanize %}
<div class="pf-c-card">
<div class="pf-c-card__header pf-c-title pf-m-md">
{% trans "WebAuthn Devices" %}
</div>
<div class="pf-c-card__body">
<ul class="pf-c-data-list" role="list">
{% for device in devices %}
<li class="pf-c-data-list__item" aria-labelledby="data-list-basic-item-1">
<div class="pf-c-data-list__item-row">
<div class="pf-c-data-list__item-content">
<div class="pf-c-data-list__cell">{{ device.name|default:"-" }}</div>
<div class="pf-c-data-list__cell">
{% blocktrans with created_on=device.created_on|naturaltime %}
Created {{ created_on }}
{% endblocktrans %}
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
<div class="pf-c-card__footer">
{% if stage.configure_flow %}
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23{% url 'authentik_core:user-settings' %}"
class="ak-root-link pf-c-button pf-m-primary">{% trans "Configure WebAuthn" %}
</a>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,38 @@
"""WebAuthn urls"""
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from authentik.stages.authenticator_webauthn.views import (
BeginActivateView,
BeginAssertion,
UserSettingsView,
VerifyAssertion,
VerifyCredentialInfo,
)
# TODO: Move to API views so we don't need csrf_exempt
urlpatterns = [
path(
"begin-activate/",
csrf_exempt(BeginActivateView.as_view()),
name="activate-begin",
),
path(
"begin-assertion/",
csrf_exempt(BeginAssertion.as_view()),
name="assertion-begin",
),
path(
"verify-credential-info/",
csrf_exempt(VerifyCredentialInfo.as_view()),
name="credential-info-verify",
),
path(
"verify-assertion/",
csrf_exempt(VerifyAssertion.as_view()),
name="assertion-verify",
),
path(
"<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings"
),
]

View File

@ -0,0 +1,23 @@
"""webauthn utils"""
import base64
import os
CHALLENGE_DEFAULT_BYTE_LEN = 32
def generate_challenge(challenge_len=CHALLENGE_DEFAULT_BYTE_LEN):
"""Generate a challenge of challenge_len bytes, Base64-encoded.
We use URL-safe base64, but we *don't* strip the padding, so that
the browser can decode it without too much hassle.
Note that if we are doing byte comparisons with the challenge in collectedClientData
later on, that value will not have padding, so we must remove the padding
before storing the value in the session.
"""
# If we know Python 3.6 or greater is available, we could replace this with one
# call to secrets.token_urlsafe
challenge_bytes = os.urandom(challenge_len)
challenge_base64 = base64.urlsafe_b64encode(challenge_bytes)
# Python 2/3 compatibility: b64encode returns bytes only in newer Python versions
if not isinstance(challenge_base64, str):
challenge_base64 = challenge_base64.decode("utf-8")
return challenge_base64

View File

@ -0,0 +1,241 @@
"""webauthn views"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404
from django.views import View
from django.views.generic import TemplateView
from structlog.stdlib import get_logger
from webauthn import (
WebAuthnAssertionOptions,
WebAuthnAssertionResponse,
WebAuthnMakeCredentialOptions,
WebAuthnRegistrationResponse,
WebAuthnUser,
)
from webauthn.webauthn import (
AuthenticationRejectedException,
RegistrationRejectedException,
WebAuthnUserDataMissing,
)
from authentik.core.models import User
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views import SESSION_KEY_PLAN
from authentik.lib.templatetags.authentik_utils import avatar
from authentik.stages.authenticator_webauthn.models import (
AuthenticateWebAuthnStage,
WebAuthnDevice,
)
from authentik.stages.authenticator_webauthn.stage import (
SESSION_KEY_WEBAUTHN_AUTHENTICATED,
)
from authentik.stages.authenticator_webauthn.utils import generate_challenge
LOGGER = get_logger()
RP_ID = "localhost"
RP_NAME = "authentik"
ORIGIN = "http://localhost:8000"
class FlowUserRequiredView(View):
"""Base class for views which can only be called in the context of a flow."""
user: User
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
plan = request.session.get(SESSION_KEY_PLAN, None)
if not plan:
return HttpResponseBadRequest()
self.user = plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not self.user:
return HttpResponseBadRequest()
return super().dispatch(request, *args, **kwargs)
class BeginActivateView(FlowUserRequiredView):
"""Initial device registration view"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Initial device registration view"""
# clear session variables prior to starting a new registration
request.session.pop("challenge", None)
challenge = generate_challenge(32)
# We strip the saved challenge of padding, so that we can do a byte
# comparison on the URL-safe-without-padding challenge we get back
# from the browser.
# We will still pass the padded version down to the browser so that the JS
# can decode the challenge into binary without too much trouble.
request.session["challenge"] = challenge.rstrip("=")
make_credential_options = WebAuthnMakeCredentialOptions(
challenge,
RP_NAME,
RP_ID,
self.user.uid,
self.user.username,
self.user.name,
avatar(self.user),
)
return JsonResponse(make_credential_options.registration_dict)
class VerifyCredentialInfo(FlowUserRequiredView):
"""Finish device registration"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Finish device registration"""
challenge = request.session["challenge"]
registration_response = request.POST
trusted_attestation_cert_required = True
self_attestation_permitted = True
none_attestation_permitted = True
webauthn_registration_response = WebAuthnRegistrationResponse(
RP_ID,
ORIGIN,
registration_response,
challenge,
trusted_attestation_cert_required=trusted_attestation_cert_required,
self_attestation_permitted=self_attestation_permitted,
none_attestation_permitted=none_attestation_permitted,
uv_required=False,
) # User Verification
try:
webauthn_credential = webauthn_registration_response.verify()
except RegistrationRejectedException as exc:
LOGGER.warning("registration failed", exc=exc)
return JsonResponse({"fail": "Registration failed. Error: {}".format(exc)})
# Step 17.
#
# Check that the credentialId is not yet registered to any other user.
# If registration is requested for a credential that is already registered
# to a different user, the Relying Party SHOULD fail this registration
# ceremony, or it MAY decide to accept the registration, e.g. while deleting
# the older registration.
credential_id_exists = WebAuthnDevice.objects.filter(
credential_id=webauthn_credential.credential_id
).first()
if credential_id_exists:
return JsonResponse({"fail": "Credential ID already exists."}, status=401)
webauthn_credential.credential_id = str(
webauthn_credential.credential_id, "utf-8"
)
webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8")
existing_device = WebAuthnDevice.objects.filter(
credential_id=webauthn_credential.credential_id
).first()
if not existing_device:
user = WebAuthnDevice.objects.create(
user=self.user,
public_key=webauthn_credential.public_key,
credential_id=webauthn_credential.credential_id,
sign_count=webauthn_credential.sign_count,
rp_id=RP_ID,
)
else:
return JsonResponse({"fail": "User already exists."}, status=401)
LOGGER.debug("Successfully registered.", user=user)
return JsonResponse({"success": "User successfully registered."})
class BeginAssertion(FlowUserRequiredView):
"""Send the client a challenge that we'll check later"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Send the client a challenge that we'll check later"""
request.session.pop("challenge", None)
challenge = generate_challenge(32)
# We strip the padding from the challenge stored in the session
# for the reasons outlined in the comment in webauthn_begin_activate.
request.session["challenge"] = challenge.rstrip("=")
devices = WebAuthnDevice.objects.filter(user=self.user)
if not devices.exists():
return HttpResponseBadRequest()
device: WebAuthnDevice = devices.first()
webauthn_user = WebAuthnUser(
self.user.uid,
self.user.username,
self.user.name,
avatar(self.user),
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
return JsonResponse(webauthn_assertion_options.assertion_dict)
class VerifyAssertion(FlowUserRequiredView):
"""Verify assertion result that we've sent to the client"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Verify assertion result that we've sent to the client"""
challenge = request.session.get("challenge")
assertion_response = request.POST
credential_id = assertion_response.get("id")
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device:
return JsonResponse({"fail": "Device does not exist."}, status=401)
webauthn_user = WebAuthnUser(
self.user.uid,
self.user.username,
self.user.name,
avatar(self.user),
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_response = WebAuthnAssertionResponse(
webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False
) # User Verification
try:
sign_count = webauthn_assertion_response.verify()
except (
AuthenticationRejectedException,
WebAuthnUserDataMissing,
RegistrationRejectedException,
) as exc:
return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)})
device.set_sign_count(sign_count)
request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True
return JsonResponse(
{"success": "Successfully authenticated as {}".format(self.user.username)}
)
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control WebAuthn devices"""
template_name = "stages/authenticator_webauthn/user_settings.html"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["devices"] = WebAuthnDevice.objects.filter(user=self.request.user)
stage = get_object_or_404(
AuthenticateWebAuthnStage, pk=self.kwargs["stage_uuid"]
)
kwargs["stage"] = stage
return kwargs

View File

@ -1,17 +1,17 @@
"""CaptchaStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.captcha.models import CaptchaStage
class CaptchaStageSerializer(ModelSerializer):
class CaptchaStageSerializer(StageSerializer):
"""CaptchaStage Serializer"""
class Meta:
model = CaptchaStage
fields = ["pk", "name", "public_key", "private_key"]
fields = StageSerializer.Meta.fields + ["public_key", "private_key"]
class CaptchaStageViewSet(ModelViewSet):

View File

@ -1,17 +1,17 @@
"""ConsentStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.consent.models import ConsentStage
class ConsentStageSerializer(ModelSerializer):
class ConsentStageSerializer(StageSerializer):
"""ConsentStage Serializer"""
class Meta:
model = ConsentStage
fields = ["pk", "name", "mode", "consent_expire_in"]
fields = StageSerializer.Meta.fields + ["mode", "consent_expire_in"]
class ConsentStageViewSet(ModelViewSet):

View File

@ -1,17 +1,17 @@
"""DummyStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.dummy.models import DummyStage
class DummyStageSerializer(ModelSerializer):
class DummyStageSerializer(StageSerializer):
"""DummyStage Serializer"""
class Meta:
model = DummyStage
fields = ["pk", "name"]
fields = StageSerializer.Meta.fields
class DummyStageViewSet(ModelViewSet):

View File

@ -1,23 +1,21 @@
"""EmailStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.email.models import EmailStage, get_template_choices
class EmailStageSerializer(ModelSerializer):
class EmailStageSerializer(StageSerializer):
"""EmailStage Serializer"""
def __init__(self, *args, **kwrags):
super().__init__(*args, **kwrags)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["template"].choices = get_template_choices()
class Meta:
model = EmailStage
fields = [
"pk",
"name",
fields = StageSerializer.Meta.fields + [
"use_global_settings",
"host",
"port",

View File

@ -1,19 +1,17 @@
"""Identification Stage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.identification.models import IdentificationStage
class IdentificationStageSerializer(ModelSerializer):
class IdentificationStageSerializer(StageSerializer):
"""IdentificationStage Serializer"""
class Meta:
model = IdentificationStage
fields = [
"pk",
"name",
fields = StageSerializer.Meta.fields + [
"user_fields",
"case_insensitive_matching",
"show_matched_user",

View File

@ -2,18 +2,17 @@
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.invitation.models import Invitation, InvitationStage
class InvitationStageSerializer(ModelSerializer):
class InvitationStageSerializer(StageSerializer):
"""InvitationStage Serializer"""
class Meta:
model = InvitationStage
fields = [
"pk",
"name",
fields = StageSerializer.Meta.fields + [
"continue_flow_without_invitation",
]

View File

@ -1,21 +0,0 @@
"""OTPStaticStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.stages.otp_static.models import OTPStaticStage
class OTPStaticStageSerializer(ModelSerializer):
"""OTPStaticStage Serializer"""
class Meta:
model = OTPStaticStage
fields = ["pk", "name", "configure_flow", "token_count"]
class OTPStaticStageViewSet(ModelViewSet):
"""OTPStaticStage Viewset"""
queryset = OTPStaticStage.objects.all()
serializer_class = OTPStaticStageSerializer

View File

@ -1,11 +0,0 @@
"""OTP Static stage"""
from django.apps import AppConfig
class AuthentikStageOTPStaticConfig(AppConfig):
"""OTP Static stage"""
name = "authentik.stages.otp_static"
label = "authentik_stages_otp_static"
verbose_name = "authentik Stages.OTP.Static"
mountpoint = "-/user/otp/static/"

View File

@ -1,50 +0,0 @@
"""OTP Static models"""
from typing import Optional, Type
from django.db import models
from django.forms import ModelForm
from django.shortcuts import reverse
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import ConfigurableStage, Stage
class OTPStaticStage(ConfigurableStage, Stage):
"""Generate static tokens for the user as a backup."""
token_count = models.IntegerField(default=6)
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.otp_static.api import OTPStaticStageSerializer
return OTPStaticStageSerializer
@property
def type(self) -> Type[View]:
from authentik.stages.otp_static.stage import OTPStaticStageView
return OTPStaticStageView
@property
def form(self) -> Type[ModelForm]:
from authentik.stages.otp_static.forms import OTPStaticStageForm
return OTPStaticStageForm
@property
def ui_user_settings(self) -> Optional[str]:
return reverse(
"authentik_stages_otp_static:user-settings",
kwargs={"stage_uuid": self.stage_uuid},
)
def __str__(self) -> str:
return f"OTP Static Stage {self.name}"
class Meta:
verbose_name = _("OTP Static Setup Stage")
verbose_name_plural = _("OTP Static Setup Stages")

View File

@ -1,21 +0,0 @@
"""OTPTimeStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.stages.otp_time.models import OTPTimeStage
class OTPTimeStageSerializer(ModelSerializer):
"""OTPTimeStage Serializer"""
class Meta:
model = OTPTimeStage
fields = ["pk", "name", "configure_flow", "digits"]
class OTPTimeStageViewSet(ModelViewSet):
"""OTPTimeStage Viewset"""
queryset = OTPTimeStage.objects.all()
serializer_class = OTPTimeStageSerializer

View File

@ -1,11 +0,0 @@
"""OTP Time"""
from django.apps import AppConfig
class AuthentikStageOTPTimeConfig(AppConfig):
"""OTP time App config"""
name = "authentik.stages.otp_time"
label = "authentik_stages_otp_time"
verbose_name = "authentik Stages.OTP.Time"
mountpoint = "-/user/otp/time/"

View File

@ -1,24 +0,0 @@
"""OTPValidateStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.stages.otp_validate.models import OTPValidateStage
class OTPValidateStageSerializer(ModelSerializer):
"""OTPValidateStage Serializer"""
class Meta:
model = OTPValidateStage
fields = [
"pk",
"name",
]
class OTPValidateStageViewSet(ModelViewSet):
"""OTPValidateStage Viewset"""
queryset = OTPValidateStage.objects.all()
serializer_class = OTPValidateStageSerializer

View File

@ -1,10 +0,0 @@
"""OTP Validation Stage"""
from django.apps import AppConfig
class AuthentikStageOTPValidateConfig(AppConfig):
"""OTP Validation Stage"""
name = "authentik.stages.otp_validate"
label = "authentik_stages_otp_validate"
verbose_name = "authentik Stages.OTP.Validate"

View File

@ -1,44 +0,0 @@
"""OTP Validation Stage"""
from typing import Type
from django.db import models
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import NotConfiguredAction, Stage
class OTPValidateStage(Stage):
"""Validate user's configured OTP Device."""
not_configured_action = models.TextField(
choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP
)
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.otp_validate.api import OTPValidateStageSerializer
return OTPValidateStageSerializer
@property
def type(self) -> Type[View]:
from authentik.stages.otp_validate.stage import OTPValidateStageView
return OTPValidateStageView
@property
def form(self) -> Type[ModelForm]:
from authentik.stages.otp_validate.forms import OTPValidateStageForm
return OTPValidateStageForm
def __str__(self) -> str:
return f"OTP Validation Stage {self.name}"
class Meta:
verbose_name = _("OTP Validation Stage")
verbose_name_plural = _("OTP Validation Stages")

View File

@ -1,19 +1,17 @@
"""PasswordStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.password.models import PasswordStage
class PasswordStageSerializer(ModelSerializer):
class PasswordStageSerializer(StageSerializer):
"""PasswordStage Serializer"""
class Meta:
model = PasswordStage
fields = [
"pk",
"name",
fields = StageSerializer.Meta.fields + [
"backends",
"configure_flow",
"failed_attempts_before_cancel",

View File

@ -3,10 +3,11 @@ from rest_framework.serializers import CharField, ModelSerializer
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.prompt.models import Prompt, PromptStage
class PromptStageSerializer(ModelSerializer):
class PromptStageSerializer(StageSerializer):
"""PromptStage Serializer"""
name = CharField(validators=[UniqueValidator(queryset=PromptStage.objects.all())])
@ -14,9 +15,7 @@ class PromptStageSerializer(ModelSerializer):
class Meta:
model = PromptStage
fields = [
"pk",
"name",
fields = StageSerializer.Meta.fields + [
"fields",
"validation_policies",
]

View File

@ -1,20 +1,17 @@
"""User Delete Stage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.user_delete.models import UserDeleteStage
class UserDeleteStageSerializer(ModelSerializer):
class UserDeleteStageSerializer(StageSerializer):
"""UserDeleteStage Serializer"""
class Meta:
model = UserDeleteStage
fields = [
"pk",
"name",
]
fields = StageSerializer.Meta.fields
class UserDeleteStageViewSet(ModelViewSet):

View File

@ -1,19 +1,17 @@
"""Login Stage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.user_login.models import UserLoginStage
class UserLoginStageSerializer(ModelSerializer):
class UserLoginStageSerializer(StageSerializer):
"""UserLoginStage Serializer"""
class Meta:
model = UserLoginStage
fields = [
"pk",
"name",
fields = StageSerializer.Meta.fields + [
"session_duration",
]

View File

@ -1,20 +1,17 @@
"""Logout Stage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.user_logout.models import UserLogoutStage
class UserLogoutStageSerializer(ModelSerializer):
class UserLogoutStageSerializer(StageSerializer):
"""UserLogoutStage Serializer"""
class Meta:
model = UserLogoutStage
fields = [
"pk",
"name",
]
fields = StageSerializer.Meta.fields
class UserLogoutStageViewSet(ModelViewSet):

View File

@ -1,20 +1,17 @@
"""User Write Stage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.flows.api import StageSerializer
from authentik.stages.user_write.models import UserWriteStage
class UserWriteStageSerializer(ModelSerializer):
class UserWriteStageSerializer(StageSerializer):
"""UserWriteStage Serializer"""
class Meta:
model = UserWriteStage
fields = [
"pk",
"name",
]
fields = StageSerializer.Meta.fields
class UserWriteStageViewSet(ModelViewSet):

View File

@ -0,0 +1,29 @@
# flake8: noqa
from lifecycle.migrate import BaseMigration
SQL_STATEMENT = """BEGIN TRANSACTION;
ALTER TABLE authentik_stages_otp_static_otpstaticstage RENAME TO authentik_stages_authenticator_static_otpstaticstage;
UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_static', 'authentik_stages_authenticator_static');
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_static', 'authentik_stages_authenticator_static');
ALTER TABLE authentik_stages_otp_time_otptimestage RENAME TO authentik_stages_authenticator_totp_otptimestage;
UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_time', 'authentik_stages_authenticator_totp');
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_time', 'authentik_stages_authenticator_totp');
ALTER TABLE authentik_stages_otp_validate_otpvalidatestage RENAME TO authentik_stages_authenticator_validate_otpvalidatestage;
UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_validate', 'authentik_stages_authenticator_validate');
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_validate', 'authentik_stages_authenticator_validate');
END TRANSACTION;"""
class Migration(BaseMigration):
def needs_migration(self) -> bool:
self.cur.execute(
"select * from information_schema.tables where table_name = 'authentik_stages_otp_static_otpstaticstage';"
)
return bool(self.cur.rowcount)
def run(self):
self.cur.execute(SQL_STATEMENT)
self.con.commit()

File diff suppressed because it is too large Load Diff

View File

@ -13,18 +13,18 @@ from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from authentik.flows.models import Flow, FlowStageBinding
from authentik.stages.otp_static.models import OTPStaticStage
from authentik.stages.otp_time.models import OTPTimeStage
from authentik.stages.otp_validate.models import OTPValidateStage
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
from tests.e2e.utils import USER, SeleniumTestCase, retry
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsOTP(SeleniumTestCase):
class TestFlowsAuthenticator(SeleniumTestCase):
"""test flow with otp stages"""
@retry()
def test_otp_validate(self):
def test_totp_validate(self):
"""test flow with otp stages"""
sleep(1)
# Setup TOTP Device
@ -32,10 +32,8 @@ class TestFlowsOTP(SeleniumTestCase):
device = TOTPDevice.objects.create(user=user, confirmed=True, digits=6)
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
# Move the user_login stage to order 3
FlowStageBinding.objects.filter(target=flow, order=2).update(order=3)
FlowStageBinding.objects.create(
target=flow, order=2, stage=OTPValidateStage.objects.create()
target=flow, order=30, stage=AuthenticatorValidateStage.objects.create()
)
self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/")
@ -53,7 +51,7 @@ class TestFlowsOTP(SeleniumTestCase):
self.assert_user(USER())
@retry()
def test_otp_totp_setup(self):
def test_totp_setup(self):
"""test TOTP Setup stage"""
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
@ -69,7 +67,7 @@ class TestFlowsOTP(SeleniumTestCase):
self.driver.get(
self.url(
"authentik_flows:configure",
stage_uuid=OTPTimeStage.objects.first().stage_uuid,
stage_uuid=AuthenticatorTOTPStage.objects.first().stage_uuid,
)
)
@ -96,7 +94,7 @@ class TestFlowsOTP(SeleniumTestCase):
self.assertTrue(TOTPDevice.objects.filter(user=USER(), confirmed=True).exists())
@retry()
def test_otp_static_setup(self):
def test_static_setup(self):
"""test Static OTP Setup stage"""
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
@ -112,7 +110,7 @@ class TestFlowsOTP(SeleniumTestCase):
self.driver.get(
self.url(
"authentik_flows:configure",
stage_uuid=OTPStaticStage.objects.first().stage_uuid,
stage_uuid=AuthenticatorStaticStage.objects.first().stage_uuid,
)
)

5
web/package-lock.json generated
View File

@ -725,6 +725,11 @@
}
}
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",

View File

@ -16,6 +16,7 @@
"@sentry/tracing": "^6.1.0",
"@types/chart.js": "^2.9.30",
"@types/codemirror": "0.0.108",
"base64-js": "^1.5.1",
"chart.js": "^2.9.4",
"codemirror": "^5.59.2",
"construct-style-sheets-polyfill": "^2.4.16",

View File

@ -1,6 +1,5 @@
import resolve from "rollup-plugin-node-resolve";
import commonjs from "rollup-plugin-commonjs";
import minifyHTML from "rollup-plugin-minify-html-literals";
import { terser } from "rollup-plugin-terser";
import sourcemaps from "rollup-plugin-sourcemaps";
import typescript from "@rollup/plugin-typescript";
@ -38,7 +37,6 @@ export default [
resolve({ browser: true }),
commonjs(),
sourcemaps(),
minifyHTML(),
terser(),
copy({
targets: [...resources],

Some files were not shown because too many files have changed in this diff Show More