Merge pull request #564 from BeryJu/stage-challenge

This commit is contained in:
Jens L 2021-02-28 01:30:52 +01:00 committed by GitHub
commit 269e6c4f38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
198 changed files with 3992 additions and 2636 deletions

View file

@ -1,20 +1,15 @@
all: lint-fix lint coverage gen
test-full:
coverage run manage.py test --failfast -v 3 .
coverage html
coverage report
test-integration:
k3d cluster create || exit 0
k3d kubeconfig write -o ~/.kube/config --overwrite
coverage run manage.py test --failfast -v 3 tests/integration
coverage run manage.py test -v 3 tests/integration
test-e2e:
coverage run manage.py test --failfast -v 3 tests/e2e
coverage run manage.py test -v 3 tests/e2e
coverage:
coverage run manage.py test --failfast -v 3 authentik
coverage run manage.py test -v 3 authentik
coverage html
coverage report

25
Pipfile
View file

@ -6,6 +6,9 @@ verify_ssl = true
[packages]
boto3 = "*"
celery = "*"
channels = "*"
channels-redis = "*"
dacite = "*"
defusedxml = "*"
django = "*"
django-cors-middleware = "*"
@ -15,37 +18,32 @@ django-guardian = "*"
django-model-utils = "*"
django-otp = "*"
django-prometheus = "*"
django-recaptcha = "*"
django-redis = "*"
djangorestframework = "*"
django-storages = "*"
djangorestframework = "*"
djangorestframework-guardian = "*"
docker = "*"
drf_yasg2 = "*"
facebook-sdk = "*"
geoip2 = "*"
gunicorn = "*"
kubernetes = "*"
ldap3 = "*"
lxml = "*"
packaging = "*"
psycopg2-binary = "*"
pycryptodome = "*"
pyjwkest = "*"
uvicorn = {extras = ["standard"],version = "*"}
gunicorn = "*"
pyyaml = "*"
qrcode = "*"
requests-oauthlib = "*"
sentry-sdk = "*"
service_identity = "*"
structlog = "*"
swagger-spec-validator = "*"
urllib3 = {extras = ["secure"],version = "*"}
dacite = "*"
channels = "*"
channels-redis = "*"
kubernetes = "*"
docker = "*"
xmlsec = "*"
geoip2 = "*"
uvicorn = {extras = ["standard"],version = "*"}
webauthn = "*"
xmlsec = "*"
[requires]
python_version = "3.9"
@ -57,8 +55,7 @@ black = "==20.8b1"
bumpversion = "*"
colorama = "*"
coverage = "*"
django-debug-toolbar = "*"
pylint = "*"
pylint = "<=2.6.0"
pylint-django = "*"
selenium = "*"
prospector = "*"

147
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "933685b75680e3a06d2f523239d848b14d1507d385de42401863f4fb6345366c"
"sha256": "88f986d5c35e42ee1890acc2510b99507d268f8d6b0d3ab5abe8d37c53706379"
},
"pipfile-spec": 6,
"requires": {
@ -56,6 +56,7 @@
"sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0",
"sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2"
],
"markers": "python_version >= '3.6'",
"version": "==3.7.4"
},
"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:884f79c50fdc55ade2c315946a9caa145e8b10075eee9d2c2594ea5e8f5226aa",
"sha256:bf7a9d302a34d0f719d43c57f65ca1f2f5c982dd6ea0c11e1e190ef6f43710fe"
],
"markers": "python_version >= '3.7'",
"version": "==21.2.2"
},
"automat": {
@ -127,6 +133,7 @@
"sha256:48350c0524fafcc6f1cf792a80080eeaf282c4ceed016e9296f1ebfda7c34fb3",
"sha256:dd95871cf8a418ab730a219f2bfc301c98f2d9d0a294e43f51715bdd4aedd4cd"
],
"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.16"
},
"cachetools": {
@ -134,6 +141,7 @@
"sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2",
"sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"
],
"markers": "python_version ~= '3.5'",
"version": "==4.2.1"
},
"cbor2": {
@ -227,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": {
@ -300,6 +309,7 @@
"sha256:0052c9887600c57054a5867d4b0240159fa009faa3bcf6a1627271d9cdcb005a",
"sha256:c22b692707f514de9013651ecb687f2abe4f35cf6fe292ece634e9f1737bc7e3"
],
"markers": "python_version >= '3.6'",
"version": "==3.0.1"
},
"defusedxml": {
@ -373,13 +383,6 @@
"index": "pypi",
"version": "==2.1.0"
},
"django-recaptcha": {
"hashes": [
"sha256:567784963fd5400feaf92e8951d8dbbbdb4b4c48a76e225d4baa63a2c9d2cd8c"
],
"index": "pypi",
"version": "==2.0.6"
},
"django-redis": {
"hashes": [
"sha256:1133b26b75baa3664164c3f44b9d5d133d1b8de45d94d79f38d1adc5b1d502e5",
@ -440,6 +443,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": {
@ -455,6 +459,7 @@
"sha256:d3640ea61ee025d5af00e3ffd82ba0a06dd99724adaf50bdd52f49daf29f3f65",
"sha256:da5218cbf33b8461d7661d6b4ad91c12c0107e2767904d5e3ae6408031d5463e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==1.27.0"
},
"gunicorn": {
@ -470,6 +475,7 @@
"sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6",
"sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"
],
"markers": "python_version >= '3.6'",
"version": "==0.12.0"
},
"hiredis": {
@ -521,6 +527,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": {
@ -566,6 +573,7 @@
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
"sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
],
"markers": "python_version >= '3.5'",
"version": "==0.5.1"
},
"itypes": {
@ -580,6 +588,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": {
@ -587,6 +596,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": {
@ -601,6 +611,7 @@
"sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006",
"sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c"
],
"markers": "python_version >= '3.6'",
"version": "==5.0.2"
},
"kubernetes": {
@ -613,8 +624,11 @@
},
"ldap3": {
"hashes": [
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57",
"sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59",
"sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91",
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57"
"sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c",
"sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056"
],
"index": "pypi",
"version": "==2.9"
@ -717,12 +731,14 @@
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1"
},
"maxminddb": {
"hashes": [
"sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc"
],
"markers": "python_version >= '3.6'",
"version": "==2.0.3"
},
"msgpack": {
@ -798,6 +814,7 @@
"sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281",
"sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"
],
"markers": "python_version >= '3.6'",
"version": "==5.1.0"
},
"oauthlib": {
@ -805,6 +822,7 @@
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.1.0"
},
"packaging": {
@ -827,6 +845,7 @@
"sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974",
"sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==3.0.16"
},
"psycopg2-binary": {
@ -872,15 +891,37 @@
},
"pyasn1": {
"hashes": [
"sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
"sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
"sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
"sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
"sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
"sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
"sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
"sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
"sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
"sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"
"sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"
],
"version": "==0.4.8"
},
"pyasn1-modules": {
"hashes": [
"sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e",
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"
"sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb",
"sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45",
"sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405",
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74",
"sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d",
"sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8",
"sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed",
"sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4",
"sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199",
"sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd",
"sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811",
"sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0",
"sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"
],
"version": "==0.2.8"
},
@ -889,6 +930,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": {
@ -960,6 +1002,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": {
@ -967,6 +1010,7 @@
"sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316",
"sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29"
],
"markers": "python_version >= '3.5'",
"version": "==2.0.2"
},
"pyjwkest": {
@ -988,12 +1032,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": {
@ -1001,6 +1047,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": {
@ -1044,19 +1091,12 @@
"index": "pypi",
"version": "==5.4.1"
},
"qrcode": {
"hashes": [
"sha256:3996ee560fc39532910603704c82980ff6d4d5d629f9c3f25f34174ce8606cf5",
"sha256:505253854f607f2abf4d16092c61d4e9d511a3b4392e60bff957a68592b04369"
],
"index": "pypi",
"version": "==6.1"
},
"redis": {
"hashes": [
"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": {
@ -1064,10 +1104,12 @@
"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:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc",
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
],
@ -1117,6 +1159,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": {
@ -1124,6 +1167,7 @@
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
],
"markers": "python_version >= '3.5'",
"version": "==0.4.1"
},
"structlog": {
@ -1171,6 +1215,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": {
@ -1178,6 +1223,7 @@
"sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8",
"sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb"
],
"markers": "python_version >= '3.6'",
"version": "==21.2.1"
},
"typing-extensions": {
@ -1193,6 +1239,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": {
@ -1237,6 +1284,7 @@
"sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30",
"sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"
],
"markers": "python_version >= '3.6'",
"version": "==5.0.0"
},
"watchgod": {
@ -1354,6 +1402,7 @@
"sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a",
"sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"
],
"markers": "python_version >= '3.6'",
"version": "==1.6.3"
},
"zope.interface": {
@ -1411,6 +1460,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"
}
},
@ -1422,18 +1472,12 @@
],
"version": "==1.4.4"
},
"asgiref": {
"hashes": [
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
],
"version": "==3.3.1"
},
"astroid": {
"hashes": [
"sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1",
"sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38"
],
"markers": "python_version >= '3.5'",
"version": "==2.4.1"
},
"attrs": {
@ -1441,6 +1485,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": {
@ -1471,6 +1516,7 @@
"sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410",
"sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"
],
"markers": "python_version >= '3.5'",
"version": "==1.0.1"
},
"bumpversion": {
@ -1486,6 +1532,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": {
@ -1551,22 +1598,6 @@
"index": "pypi",
"version": "==5.4"
},
"django": {
"hashes": [
"sha256:32ce792ee9b6a0cbbec340123e229ac9f765dff8c2a4ae9247a14b2ba3a365a7",
"sha256:baf099db36ad31f970775d0be5587cc58a6256a6771a44eb795b554d45f211b8"
],
"index": "pypi",
"version": "==3.1.7"
},
"django-debug-toolbar": {
"hashes": [
"sha256:84e2607d900dbd571df0a2acf380b47c088efb787dce9805aefeb407341961d2",
"sha256:9e5a25d0c965f7e686f6a8ba23613ca9ca30184daa26487706d4829f5cfb697a"
],
"index": "pypi",
"version": "==3.2"
},
"dodgy": {
"hashes": [
"sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a",
@ -1579,6 +1610,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": {
@ -1593,6 +1625,7 @@
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
"sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"
],
"markers": "python_version >= '3.4'",
"version": "==4.0.5"
},
"gitpython": {
@ -1600,6 +1633,7 @@
"sha256:8621a7e777e276a5ec838b59280ba5272dd144a18169c36c903d8b38b99f750a",
"sha256:c5347c81d232d9b8e7f47b68a83e5dc92e7952127133c5f2df9133f2c75a1b29"
],
"markers": "python_version >= '3.4'",
"version": "==3.1.13"
},
"iniconfig": {
@ -1614,6 +1648,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": {
@ -1640,6 +1675,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": {
@ -1676,6 +1712,7 @@
"sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9",
"sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"
],
"markers": "python_version >= '2.6'",
"version": "==5.5.1"
},
"pep8-naming": {
@ -1690,6 +1727,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": {
@ -1704,6 +1742,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": {
@ -1711,6 +1750,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": {
@ -1718,6 +1758,7 @@
"sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
"sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
],
"markers": "python_version >= '3.5'",
"version": "==5.1.1"
},
"pyflakes": {
@ -1725,6 +1766,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": {
@ -1767,6 +1809,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": {
@ -1785,13 +1828,6 @@
"index": "pypi",
"version": "==4.1.0"
},
"pytz": {
"hashes": [
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
],
"version": "==2021.1"
},
"pyyaml": {
"hashes": [
"sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
@ -1890,6 +1926,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": {
@ -1897,6 +1934,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": {
@ -1906,18 +1944,12 @@
],
"version": "==2.1.0"
},
"sqlparse": {
"hashes": [
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
],
"version": "==0.4.1"
},
"stevedore": {
"hashes": [
"sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee",
"sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"
],
"markers": "python_version >= '3.6'",
"version": "==3.3.0"
},
"toml": {
@ -1925,6 +1957,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

@ -1,8 +1,8 @@
"""test admin api"""
from json import loads
from django.shortcuts import reverse
from django.test import TestCase
from django.urls import reverse
from authentik import __version__
from authentik.core.models import Group, User

View file

@ -3,8 +3,8 @@ from importlib import import_module
from typing import Callable
from django.forms import ModelForm
from django.shortcuts import reverse
from django.test import Client, TestCase
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from authentik.admin.urls import urlpatterns

View file

@ -5,6 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http.response import HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from django.views.generic.edit import FormView
@ -33,7 +34,7 @@ class CertificateKeyPairCreateView(
permission_required = "authentik_crypto.add_certificatekeypair"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Certificate-Key Pair")
@ -50,7 +51,7 @@ class CertificateKeyPairGenerateView(
permission_required = "authentik_crypto.add_certificatekeypair"
template_name = "administration/certificatekeypair/generate.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully generated Certificate-Key Pair")
def form_valid(self, form: CertificateKeyPairGenerateForm) -> HttpResponse:
@ -77,7 +78,7 @@ class CertificateKeyPairUpdateView(
permission_required = "authentik_crypto.change_certificatekeypair"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated Certificate-Key Pair")
@ -90,5 +91,5 @@ class CertificateKeyPairDeleteView(
permission_required = "authentik_crypto.delete_certificatekeypair"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Certificate-Key Pair")

View file

@ -6,6 +6,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import DetailView, FormView, UpdateView
from guardian.mixins import PermissionRequiredMixin
@ -36,7 +37,7 @@ class FlowCreateView(
permission_required = "authentik_flows.add_flow"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Flow")
@ -53,7 +54,7 @@ class FlowUpdateView(
permission_required = "authentik_flows.change_flow"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated Flow")
@ -64,7 +65,7 @@ class FlowDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageV
permission_required = "authentik_flows.delete_flow"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Flow")
@ -104,7 +105,7 @@ class FlowImportView(LoginRequiredMixin, FormView):
form_class = FlowImportForm
template_name = "administration/flow/import.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
def dispatch(self, request, *args, **kwargs):
if not request.user.is_superuser:

View file

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
@ -27,7 +28,7 @@ class GroupCreateView(
permission_required = "authentik_core.add_group"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Group")
@ -44,7 +45,7 @@ class GroupUpdateView(
permission_required = "authentik_core.change_group"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated Group")
@ -55,5 +56,5 @@ class GroupDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage
permission_required = "authentik_flows.delete_group"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Group")

View file

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from guardian.mixins import PermissionRequiredMixin
@ -27,7 +28,7 @@ class OutpostServiceConnectionCreateView(
permission_required = "authentik_outposts.add_outpostserviceconnection"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Outpost Service Connection")
@ -43,7 +44,7 @@ class OutpostServiceConnectionUpdateView(
permission_required = "authentik_outposts.change_outpostserviceconnection"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated Outpost Service Connection")
@ -56,5 +57,5 @@ class OutpostServiceConnectionDeleteView(
permission_required = "authentik_outposts.delete_outpostserviceconnection"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Outpost Service Connection")

View file

@ -7,6 +7,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView
from django.views.generic.detail import DetailView
@ -34,7 +35,7 @@ class PolicyCreateView(
permission_required = "authentik_policies.add_policy"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Policy")
@ -50,7 +51,7 @@ class PolicyUpdateView(
permission_required = "authentik_policies.change_policy"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated Policy")
@ -61,7 +62,7 @@ class PolicyDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessag
permission_required = "authentik_policies.delete_policy"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Policy")

View file

@ -7,6 +7,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import Max
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
@ -30,7 +31,7 @@ class PolicyBindingCreateView(
form_class = PolicyBindingForm
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created PolicyBinding")
def get_initial(self) -> dict[str, Any]:
@ -63,7 +64,7 @@ class PolicyBindingUpdateView(
form_class = PolicyBindingForm
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated PolicyBinding")
@ -76,5 +77,5 @@ class PolicyBindingDeleteView(
permission_required = "authentik_policies.delete_policybinding"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted PolicyBinding")

View file

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from guardian.mixins import PermissionRequiredMixin
@ -27,7 +28,7 @@ class StageCreateView(
template_name = "generic/create.html"
permission_required = "authentik_flows.add_stage"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Stage")
@ -42,7 +43,7 @@ class StageUpdateView(
model = Stage
permission_required = "authentik_flows.update_application"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated Stage")
@ -52,5 +53,5 @@ class StageDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage
model = Stage
template_name = "generic/delete.html"
permission_required = "authentik_flows.delete_stage"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Stage")

View file

@ -7,6 +7,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import Max
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
@ -30,7 +31,7 @@ class StageBindingCreateView(
form_class = FlowStageBindingForm
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created StageBinding")
def get_initial(self) -> dict[str, Any]:
@ -61,7 +62,7 @@ class StageBindingUpdateView(
form_class = FlowStageBindingForm
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated StageBinding")
@ -74,5 +75,5 @@ class StageBindingDeleteView(
permission_required = "authentik_flows.delete_flowstagebinding"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted FlowStageBinding")

View file

@ -5,6 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from guardian.mixins import PermissionRequiredMixin
@ -27,7 +28,7 @@ class InvitationCreateView(
permission_required = "authentik_stages_invitation.add_invitation"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Invitation")
def form_valid(self, form):
@ -46,5 +47,5 @@ class InvitationDeleteView(
permission_required = "authentik_stages_invitation.delete_invitation"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Invitation")

View file

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
@ -27,7 +28,7 @@ class PromptCreateView(
permission_required = "authentik_stages_prompt.add_prompt"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created Prompt")
@ -44,7 +45,7 @@ class PromptUpdateView(
permission_required = "authentik_stages_prompt.change_prompt"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated Prompt")
@ -55,5 +56,5 @@ class PromptDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessag
permission_required = "authentik_stages_prompt.delete_prompt"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Prompt")

View file

@ -1,5 +1,6 @@
"""authentik Token administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from guardian.mixins import PermissionRequiredMixin
@ -14,5 +15,5 @@ class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage
permission_required = "authentik_core.delete_token"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted Token")

View file

@ -7,7 +7,8 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseRedirect
from django.shortcuts import redirect, reverse
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views.generic import DetailView, UpdateView
@ -32,7 +33,7 @@ class UserCreateView(
permission_required = "authentik_core.add_user"
template_name = "generic/create.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully created User")
@ -51,7 +52,7 @@ class UserUpdateView(
# By default the object's name is user which is used by other checks
context_object_name = "object"
template_name = "generic/update.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully updated User")
@ -64,7 +65,7 @@ class UserDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageV
# By default the object's name is user which is used by other checks
context_object_name = "object"
template_name = "generic/delete.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully deleted User")
@ -79,7 +80,7 @@ class UserDisableView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessage
# By default the object's name is user which is used by other checks
context_object_name = "object"
template_name = "administration/user/disable.html"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully disabled User")
def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
@ -100,7 +101,7 @@ class UserEnableView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
# By default the object's name is user which is used by other checks
context_object_name = "object"
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
success_message = _("Successfully enabled User")
def get(self, request: HttpRequest, *args, **kwargs):
@ -124,7 +125,7 @@ class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailV
)
querystring = urlencode({"token": token.key})
link = request.build_absolute_uri(
reverse("authentik_flows:default-recovery") + f"?{querystring}"
reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}"
)
messages.success(
request, _("Password reset link: <pre>%(link)s</pre>" % {"link": link})

View file

@ -4,6 +4,7 @@ from typing import Any
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404
from django.urls import reverse_lazy
from django.views.generic import DeleteView, UpdateView
from authentik.lib.utils.reflection import all_subclasses
@ -13,7 +14,7 @@ from authentik.lib.views import CreateAssignPermView
class DeleteMessageView(SuccessMessageMixin, DeleteView):
"""DeleteView which shows `self.success_message` on successful deletion"""
success_url = "/"
success_url = reverse_lazy("authentik_core:shell")
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)

View file

@ -26,6 +26,7 @@ from authentik.events.api.notification_transport import NotificationTransportVie
from authentik.flows.api.bindings import FlowStageBindingViewSet
from authentik.flows.api.flows import FlowViewSet
from authentik.flows.api.stages import StageViewSet
from authentik.flows.views import FlowExecutorView
from authentik.outposts.api.outpost_service_connections import (
DockerServiceConnectionViewSet,
KubernetesServiceConnectionViewSet,
@ -175,4 +176,9 @@ urlpatterns = [
name="schema-swagger-ui",
),
path("redoc/", SchemaView.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
path(
"flows/executor/<slug:flow_slug>/",
FlowExecutorView.as_view(),
name="flow-executor",
),
] + router.urls

View file

@ -1,5 +1,5 @@
"""PropertyMapping API Views"""
from django.shortcuts import reverse
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.request import Request

View file

@ -1,5 +1,5 @@
"""Provider API Views"""
from django.shortcuts import reverse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action

View file

@ -1,5 +1,5 @@
"""Source API Views"""
from django.shortcuts import reverse
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.request import Request

View file

@ -2,28 +2,20 @@
from drf_yasg2.utils import swagger_auto_schema
from guardian.utils import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
BooleanField,
ModelSerializer,
SerializerMethodField,
)
from rest_framework.serializers import BooleanField, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.models import User
from authentik.lib.templatetags.authentik_utils import avatar
class UserSerializer(ModelSerializer):
"""User Serializer"""
is_superuser = BooleanField(read_only=True)
avatar = SerializerMethodField()
def get_avatar(self, user: User) -> str:
"""Add user's avatar as URL"""
return avatar(user)
avatar = CharField(read_only=True)
class Meta:

View file

@ -1,7 +1,8 @@
"""authentik core models"""
from datetime import timedelta
from hashlib import sha256
from hashlib import md5, sha256
from typing import Any, Optional, Type
from urllib.parse import urlencode
from uuid import uuid4
from django.conf import settings
@ -11,7 +12,9 @@ from django.db import models
from django.db.models import Q, QuerySet
from django.forms import ModelForm
from django.http import HttpRequest
from django.templatetags.static import static
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from guardian.mixins import GuardianUserMixin
@ -23,6 +26,7 @@ from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel
@ -31,6 +35,9 @@ LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
def default_token_duration():
"""Default duration a Token is valid"""
@ -126,6 +133,25 @@ class User(GuardianUserMixin, AbstractUser):
"""Generate a globall unique UID, based on the user ID and the hashed secret key"""
return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
@property
def avatar(self) -> str:
"""Get avatar, depending on authentik.avatar setting"""
mode = CONFIG.raw.get("authentik").get("avatars")
if mode == "none":
return DEFAULT_AVATAR
if mode == "gravatar":
parameters = [
("s", "158"),
("r", "g"),
]
# gravatar uses md5 for their URLs, so md5 can't be avoided
mail_hash = md5(self.email.encode("utf-8")).hexdigest() # nosec
gravatar_url = (
f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
)
return escape(gravatar_url)
raise ValueError(f"Invalid avatar mode {mode}")
class Meta:
permissions = (

View file

@ -16,7 +16,6 @@
<link rel="stylesheet" type="text/css" href="{% static 'dist/fontawesome.min.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
<script src="{% url 'javascript-catalog' %}?v={{ ak_version }}"></script>
<script src="{% static 'dist/main.js' %}?v={{ ak_version }}" type="module"></script>
{% block head %}
{% endblock %}
</head>

View file

@ -1,31 +0,0 @@
{% extends "login/base.html" %}
{% load authentik_utils %}
{% load i18n %}
{% block title %}
{{ title }}
{% endblock %}
{% block card %}
<form method="POST" action="{{ url }}" autosubmit>
{% csrf_token %}
{% for key, value in attrs.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<div class="pf-c-form__group pf-u-display-flex pf-u-justify-content-center">
<div class="pf-c-form__group-control">
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<div class="pf-c-form__actions">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
</div>
</div>
</form>
{% endblock %}

View file

@ -1,53 +0,0 @@
{% load i18n %}
<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="pf-icon pf-icon-applications"></i>
{% trans 'Applications' %}
</h1>
</div>
</section>
<section class="pf-c-page__main-section">
{% if applications %}
<div class="pf-l-gallery pf-m-gutter">
{% for app in applications %}
<a href="{{ app.get_launch_url }}" class="pf-c-card pf-m-hoverable pf-m-compact ak-root-link">
<div class="pf-c-card__header">
{% if app.meta_icon %}
<img class="app-icon pf-c-avatar" src="{{ app.meta_icon.url }}" alt="{% trans 'Application Icon' %}">
{% else %}
<i class="pf-icon pf-icon-arrow"></i>
{% endif %}
</div>
<div class="pf-c-card__title">
<p id="card-1-check-label">{{ app.name }}</p>
<div class="pf-c-content">
<small>{{ app.meta_publisher }}</small>
</div>
</div>
<div class="pf-c-card__body">
{% trans app.meta_description|truncatewords:35 %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="pf-c-empty-state pf-m-full-height">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">{% trans 'No Applications available.' %}</h1>
<div class="pf-c-empty-state__body">
{% trans "Either no applications are defined, or you don't have access to any." %}
</div>
{% if perms.authentik_core.add_application %}
<a href="{% url 'authentik_admin:application-create' %}" class="pf-c-button pf-m-primary" type="button">
{% trans 'Create Application' %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</section>
</main>

View file

@ -28,10 +28,8 @@
{% for source in sources %}
<li class="pf-c-login__main-footer-links-item">
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
{% if source.icon_path %}
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
{% elif source.icon_url %}
<img src="icon_url" alt="{{ source.name }}">
{% if source.icon_url %}
<img src="{{ source.icon_url }}" alt="{{ source.name }}">
{% else %}
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
{% endif %}

View file

@ -1,19 +0,0 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% block card %}
<form method="POST" class="pf-c-form">
{% block above_form %}
{% endblock %}
{% include 'partials/form.html' %}
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
{% endblock %}

View file

@ -1,18 +0,0 @@
{% extends 'login/form.html' %}
{% load i18n %}
{% load authentik_utils %}
{% block above_form %}
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">
<img class="pf-c-avatar" src="{% avatar user %}" alt="">
{{ user.username }}
</div>
<div class="right">
<a href="{% url 'authentik_flows:cancel' %}">{% trans 'Not you?' %}</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,24 +0,0 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% load authentik_utils %}
{% block title %}
{% trans title %}
{% endblock %}
{% block head %}
<meta http-equiv="refresh" content="0; url={{ target_url }}" />
{% endblock %}
{% block card %}
<header class="login-pf-header">
<h1>{% trans title %}</h1>
</header>
<form>
<div class="form-group">
<div class="spinner spinner-lg"></div>
</div>
</form>
{% endblock %}

View file

@ -3,7 +3,7 @@
{% csrf_token %}
{% if form.non_field_errors %}
<div class="pf-c-form__group has-error">
<div class="pf-c-form__group">
<p class="pf-c-form__helper-text pf-m-error">
{{ form.non_field_errors }}
</p>
@ -13,7 +13,7 @@
{% if field.field.widget|fieldtype == 'HiddenInput' %}
{{ field }}
{% else %}
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
<div class="pf-c-form__group">
{% if field.field.widget|fieldtype == 'RadioSelect' %}
<label class="pf-c-form__label" {% if field.field.required %}class="required" {% endif %}
for="{{ field.name }}-{{ forloop.counter0 }}">

View file

@ -1,42 +0,0 @@
{% load i18n %}
{% load authentik_utils %}
<div class="pf-c-toolbar__item pf-m-pagination ">
<div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
{% blocktrans with start_index=page_obj.start_index end_index=page_obj.end_index total_items=paginator.count %}
{{ start_index }} - {{ end_index }} of {{ total_items }}
{% endblocktrans %}
</span>
</div>
</div>
<nav class="pf-c-pagination__nav" aria-label="Pagination">
<div class="pf-c-pagination__nav-control pf-m-prev">
<a class="pf-c-button pf-m-plain"
{% if page_obj.has_previous %}
href="{{ request.path }}?{% query_transform page=page_obj.previous_page_number %}"
{% else %}
disabled
{% endif %}
aria-label="{% trans 'Go to previous page' %}">
<i class="fas fa-angle-left" aria-hidden="true"></i>
</a>
</div>
<div class="pf-c-pagination__nav-control pf-m-next">
<a class="pf-c-button pf-m-plain"
{% if page_obj.has_next %}
href="{{ request.path }}?{% query_transform page=page_obj.next_page_number %}"
{% else %}
disabled
{% endif %}
aria-label="{% trans 'Go to next page' %}">
<i class="fas fa-angle-right" aria-hidden="true"></i>
</a>
</div>
</nav>
</div>
</div>
</div>

View file

@ -1,13 +0,0 @@
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<form class="pf-c-input-group" method="GET">
{# include page data for pagination #}
<input type="hidden" name="page" value="{{ page_obj.number }}">
<input class="pf-c-form-control" name="search" type="search" placeholder="Search..." value="{{ request.GET.search }}">
<button class="pf-c-button pf-m-control" type="submit">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
</form>
</div>
</div>

View file

@ -1,5 +1,11 @@
{% extends "base/skeleton.html" %}
{% load static %}
{% block head %}
<script src="{% static 'dist/main.js' %}?v={{ ak_version }}" type="module"></script>
{% endblock %}
{% block body %}
<ak-interface-admin></ak-interface-admin>
{% endblock %}

View file

@ -1,100 +0,0 @@
{% load i18n %}
<div class="pf-c-card">
<div class="pf-c-card__header pf-c-title pf-m-md">
<p>{% trans "Tokens can be used to access authentik's API." %}</p>
</div>
{% if object_list %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
{% include 'partials/toolbar_search.html' %}
<div class="pf-c-toolbar__bulk-select">
<ak-modal-button href="{% url 'authentik_core:user-tokens-create' %}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
{% trans 'Create' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</div>
{% include 'partials/pagination.html' %}
</div>
</div>
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
<thead>
<tr role="row">
<th role="columnheader" scope="col">{% trans 'Identifier' %}</th>
<th role="columnheader" scope="col">{% trans 'Expires?' %}</th>
<th role="columnheader" scope="col">{% trans 'Expiry Date' %}</th>
<th role="columnheader" scope="col">{% trans 'Description' %}</th>
<th role="cell"></th>
</tr>
</thead>
<tbody role="rowgroup">
{% for token in object_list %}
<tr role="row">
<th role="columnheader">
<div>{{ token.identifier }}</div>
</th>
<td role="cell">
<span>
{{ token.expiring|yesno:"Yes,No" }}
</span>
</td>
<td role="cell">
<span>
{% if not token.expiring %}
-
{% else %}
{{ token.expires }}
{% endif %}
</span>
</td>
<td role="cell">
<span>
{{ token.description }}
</span>
</td>
<td>
<ak-modal-button href="{% url 'authentik_core:user-tokens-update' identifier=token.identifier %}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
{% trans 'Edit' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-modal-button href="{% url 'authentik_core:user-tokens-delete' identifier=token.identifier %}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
{% trans 'Delete' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-token-copy-button identifier="{{ token.identifier }}">
{% trans 'Copy token' %}
</ak-token-copy-button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pf-c-pagination pf-m-bottom">
{% include 'partials/pagination.html' %}
</div>
{% else %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">
{% trans 'No Tokens.' %}
</h1>
<div class="pf-c-empty-state__body">
{% trans 'Currently no tokens exist. Click the button below to create one.' %}
</div>
<ak-modal-button href="{% url 'authentik_core:user-tokens-create' %}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
{% trans 'Create' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</div>
</div>
{% endif %}
</div>

View file

@ -1,6 +1,6 @@
"""impersonation tests"""
from django.shortcuts import reverse
from django.test.testcases import TestCase
from django.urls import reverse
from authentik.core.models import User

View file

@ -2,8 +2,8 @@
import string
from random import SystemRandom
from django.shortcuts import reverse
from django.test import TestCase
from django.urls import reverse
from authentik.core.models import User
@ -28,9 +28,3 @@ class TestOverviewViews(TestCase):
self.assertEqual(
self.client.get(reverse("authentik_core:shell")).status_code, 200
)
def test_overview(self):
"""Test overview"""
self.assertEqual(
self.client.get(reverse("authentik_core:overview")).status_code, 200
)

View file

@ -2,8 +2,8 @@
import string
from random import SystemRandom
from django.shortcuts import reverse
from django.test import TestCase
from django.urls import reverse
from authentik.core.models import User

View file

@ -2,6 +2,10 @@
from dataclasses import dataclass
from typing import Optional
from django.db.models.base import Model
from rest_framework.fields import CharField
from rest_framework.serializers import Serializer
@dataclass
class UILoginButton:
@ -13,8 +17,19 @@ class UILoginButton:
# URL Which Button points to
url: str
# Icon name, ran through django's static
icon_path: Optional[str] = None
# Icon URL, used as-is
icon_url: Optional[str] = None
class UILoginButtonSerializer(Serializer):
"""Serializer for Login buttons of sources"""
name = CharField()
url = CharField()
icon_url = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()

View file

@ -1,7 +1,7 @@
"""authentik URL Configuration"""
from django.urls import path
from authentik.core.views import impersonate, library, shell, user
from authentik.core.views import impersonate, shell, user
urlpatterns = [
path("", shell.ShellView.as_view(), name="shell"),
@ -23,8 +23,6 @@ urlpatterns = [
user.TokenDeleteView.as_view(),
name="user-tokens-delete",
),
# Libray
path("library", library.LibraryView.as_view(), name="overview"),
# Impersonation
path(
"-/impersonation/<int:user_id>/",

View file

@ -1,23 +0,0 @@
"""authentik library view"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from authentik.core.models import Application
from authentik.policies.engine import PolicyEngine
class LibraryView(LoginRequiredMixin, TemplateView):
"""Overview for logged in user, incase user opens authentik directly
and is not being forwarded"""
template_name = "library.html"
def get_context_data(self, **kwargs):
kwargs["applications"] = []
for application in Application.objects.all().order_by("name"):
engine = PolicyEngine(application, self.request.user, self.request)
engine.build()
if engine.passing:
kwargs["applications"].append(application)
return super().get_context_data(**kwargs)

View file

@ -1,6 +1,6 @@
"""Event API tests"""
from django.shortcuts import reverse
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User

View file

@ -1,6 +1,6 @@
"""Event Middleware tests"""
from django.shortcuts import reverse
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Application, User

View file

@ -1,5 +1,5 @@
"""Flow Stage API Views"""
from django.shortcuts import reverse
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.request import Request

View file

@ -0,0 +1,109 @@
"""Challenge helpers"""
from enum import Enum
from typing import TYPE_CHECKING, Optional
from django.db.models.base import Model
from django.http import JsonResponse
from rest_framework.fields import ChoiceField, DictField
from rest_framework.serializers import CharField, Serializer
from authentik.flows.transfer.common import DataclassEncoder
if TYPE_CHECKING:
from authentik.flows.stage import StageView
class ChallengeTypes(Enum):
"""Currently defined challenge types"""
native = "native"
shell = "shell"
redirect = "redirect"
class ErrorDetailSerializer(Serializer):
"""Serializer for rest_framework's error messages"""
string = CharField()
code = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class Challenge(Serializer):
"""Challenge that gets sent to the client based on which stage
is currently active"""
type = ChoiceField(choices=list(ChallengeTypes))
component = CharField(required=False)
title = CharField(required=False)
response_errors = DictField(
child=ErrorDetailSerializer(many=True), allow_empty=False, required=False
)
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class RedirectChallenge(Challenge):
"""Challenge type to redirect the client"""
to = CharField()
class ShellChallenge(Challenge):
"""Legacy challenge type to render HTML as-is"""
body = CharField()
class WithUserInfoChallenge(Challenge):
"""Challenge base which shows some user info"""
pending_user = CharField()
pending_user_avatar = CharField()
class PermissionSerializer(Serializer):
"""Permission used for consent"""
name = CharField()
id = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class ChallengeResponse(Serializer):
"""Base class for all challenge responses"""
stage: Optional["StageView"]
def __init__(self, instance, data, **kwargs):
self.stage = kwargs.pop("stage", None)
super().__init__(instance=instance, data=data, **kwargs)
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class HttpChallengeResponse(JsonResponse):
"""Subclass of JsonResponse that uses the `DataclassEncoder`"""
def __init__(self, challenge, **kwargs) -> None:
# pyright: reportGeneralTypeIssues=false
super().__init__(challenge.data, encoder=DataclassEncoder, **kwargs)

View file

@ -5,7 +5,7 @@ from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from authentik.flows.models import FlowDesignation
from authentik.stages.identification.models import Templates, UserFields
from authentik.stages.identification.models import UserFields
def create_default_authentication_flow(
@ -26,7 +26,6 @@ def create_default_authentication_flow(
name="default-authentication-identification",
defaults={
"user_fields": [UserFields.E_MAIL, UserFields.USERNAME],
"template": Templates.DEFAULT_LOGIN,
},
)

View file

@ -23,6 +23,7 @@ class NotConfiguredAction(models.TextChoices):
"""Decides how the FlowExecutor should proceed when a stage isn't configured"""
SKIP = "skip"
DENY = "deny"
# CONFIGURE = "configure"

View file

@ -1,45 +1,116 @@
"""authentik stage Base view"""
from collections import namedtuple
from typing import Any
from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.http.request import QueryDict
from django.http.response import HttpResponse
from django.views.generic.base import View
from structlog.stdlib import get_logger
from authentik.core.models import DEFAULT_AVATAR, User
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
HttpChallengeResponse,
WithUserInfoChallenge,
)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views import FlowExecutorView
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
FakeUser = namedtuple("User", ["username", "email"])
LOGGER = get_logger()
class StageView(TemplateView):
class StageView(View):
"""Abstract Stage, inherits TemplateView but can be combined with FormView"""
template_name = "login/form_with_user.html"
executor: FlowExecutorView
request: HttpRequest = None
def __init__(self, executor: FlowExecutorView):
def __init__(self, executor: FlowExecutorView, **kwargs):
self.executor = executor
super().__init__(**kwargs)
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["title"] = self.executor.flow.title
# Either show the matched User object or show what the user entered,
# based on what the earlier stage (mostly IdentificationStage) set.
# _USER_IDENTIFIER overrides the first User, as PENDING_USER is used for
# other things besides the form display
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
def get_pending_user(self) -> User:
"""Either show the matched User object or show what the user entered,
based on what the earlier stage (mostly IdentificationStage) set.
_USER_IDENTIFIER overrides the first User, as PENDING_USER is used for
other things besides the form display.
If no user is pending, returns request.user"""
if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context:
kwargs["user"] = FakeUser(
return User(
username=self.executor.plan.context.get(
PLAN_CONTEXT_PENDING_USER_IDENTIFIER
),
email="",
)
kwargs["primary_action"] = _("Continue")
return super().get_context_data(**kwargs)
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
return self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
return self.request.user
class ChallengeStageView(StageView):
"""Stage view which response with a challenge"""
response_class = ChallengeResponse
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
"""Return the response class type"""
return self.response_class(None, data=data, stage=self)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Return a challenge for the frontend to solve"""
challenge = self._get_challenge(*args, **kwargs)
if not challenge.is_valid():
LOGGER.warning(challenge.errors)
return HttpChallengeResponse(challenge)
# pylint: disable=unused-argument
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Handle challenge response"""
challenge: ChallengeResponse = self.get_response_instance(data=request.POST)
if not challenge.is_valid():
return self.challenge_invalid(challenge)
return self.challenge_valid(challenge)
def _get_challenge(self, *args, **kwargs) -> Challenge:
challenge = self.get_challenge(*args, **kwargs)
if "title" not in challenge.initial_data:
challenge.initial_data["title"] = self.executor.flow.title
if isinstance(challenge, WithUserInfoChallenge):
# If there's a pending user, update the `username` field
# this field is only used by password managers.
# If there's no user set, an error is raised later.
if user := self.get_pending_user():
challenge.initial_data["pending_user"] = user.username
challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR
if not isinstance(user, AnonymousUser):
challenge.initial_data["pending_user_avatar"] = user.avatar
return challenge
def get_challenge(self, *args, **kwargs) -> Challenge:
"""Return the challenge that the client should solve"""
raise NotImplementedError
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
"""Callback when the challenge has the correct format"""
raise NotImplementedError
def challenge_invalid(self, response: ChallengeResponse) -> HttpResponse:
"""Callback when the challenge has the incorrect format"""
challenge_response = self._get_challenge()
full_errors = {}
for field, errors in response.errors.items():
for error in errors:
full_errors.setdefault(field, [])
full_errors[field].append(
{
"string": str(error),
"code": error.code,
}
)
challenge_response.initial_data["response_errors"] = full_errors
if not challenge_response.is_valid():
LOGGER.warning(challenge_response.errors)
return HttpChallengeResponse(challenge_response)

View file

@ -6,27 +6,15 @@
{% block head %}
{{ block.super }}
<style>
.ak-loading,
.pf-c-login__main >iframe {
display: flex;
height: 100%;
width: 100%;
justify-content: center;
align-items: center;
}
.ak-hidden {
display: none
}
.pf-c-background-image::before {
background-image: url("{{ background_url }}");
background-position: center;
}
</style>
<script src="{% static 'dist/flow.js' %}?v={{ ak_version }}" type="module"></script>
{% endblock %}
{% block main_container %}
<ak-flow-shell-card
class="pf-c-login__main"
flowBodyUrl="{{ exec_url }}">
</ak-flow-shell-card>
<ak-flow-executor class="pf-c-login__main" flowSlug="{{ flow_slug }}">
</ak-flow-executor>
{% endblock %}

View file

@ -1,5 +1,5 @@
"""API flow tests"""
from django.shortcuts import reverse
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User

View file

@ -4,8 +4,8 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.cache import cache
from django.http import HttpRequest
from django.shortcuts import reverse
from django.test import RequestFactory, TestCase
from django.urls import reverse
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import User
@ -43,7 +43,7 @@ class TestFlowPlanner(TestCase):
designation=FlowDesignation.AUTHENTICATION,
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -63,7 +63,7 @@ class TestFlowPlanner(TestCase):
designation=FlowDesignation.AUTHENTICATION,
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -83,7 +83,7 @@ class TestFlowPlanner(TestCase):
target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -112,7 +112,7 @@ class TestFlowPlanner(TestCase):
user = User.objects.create(username="test-user")
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = user
planner = FlowPlanner(flow)
@ -136,7 +136,7 @@ class TestFlowPlanner(TestCase):
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -167,7 +167,7 @@ class TestFlowPlanner(TestCase):
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()

View file

@ -2,12 +2,13 @@
from unittest.mock import MagicMock, PropertyMock, patch
from django.http import HttpRequest, HttpResponse
from django.shortcuts import reverse
from django.test import TestCase
from django.test.client import RequestFactory
from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import User
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
@ -62,9 +63,7 @@ class TestFlowExecutor(TestCase):
cancel_mock = MagicMock()
with patch("authentik.flows.views.FlowExecutorView.cancel", cancel_mock):
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 302)
self.assertEqual(cancel_mock.call_count, 2)
@ -87,7 +86,7 @@ class TestFlowExecutor(TestCase):
CONFIG.update_from_dict({"domain": "testserver"})
response = self.client.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, AccessDeniedResponse)
@ -107,7 +106,7 @@ class TestFlowExecutor(TestCase):
CONFIG.update_from_dict({"domain": "testserver"})
response = self.client.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("authentik_core:shell"))
@ -126,7 +125,7 @@ class TestFlowExecutor(TestCase):
CONFIG.update_from_dict({"domain": "testserver"})
dest = "/unique-string"
url = reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug})
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("authentik_core:shell"))
@ -146,7 +145,7 @@ class TestFlowExecutor(TestCase):
)
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First Request, start planning, renders form
response = self.client.get(exec_url)
@ -196,7 +195,7 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
@ -250,7 +249,7 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
@ -284,7 +283,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{"type": "redirect", "to": reverse("authentik_core:shell")},
{"to": reverse("authentik_core:shell"), "type": "redirect"},
)
def test_reevaluate_keep(self):
@ -317,7 +316,7 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
@ -361,7 +360,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{"type": "redirect", "to": reverse("authentik_core:shell")},
{"to": reverse("authentik_core:shell"), "type": "redirect"},
)
def test_reevaluate_remove_consecutive(self):
@ -401,12 +400,19 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
self.assertIn("dummy1", force_str(response.content))
self.assertJSONEqual(
force_str(response.content),
{
"type": ChallengeTypes.native.value,
"component": "",
"title": binding.stage.name,
},
)
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
@ -429,7 +435,14 @@ class TestFlowExecutor(TestCase):
# but it won't save it, hence we cant' check the plan
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
self.assertIn("dummy4", force_str(response.content))
self.assertJSONEqual(
force_str(response.content),
{
"type": ChallengeTypes.native.value,
"component": "",
"title": binding4.stage.name,
},
)
# fourth request, this confirms the last stage (dummy4)
# We do this request without the patch, so the policy results in false
@ -437,7 +450,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{"type": "redirect", "to": reverse("authentik_core:shell")},
{"to": reverse("authentik_core:shell"), "type": "redirect"},
)
def test_stageview_user_identifier(self):
@ -455,7 +468,7 @@ class TestFlowExecutor(TestCase):
user = User.objects.create(username="test-user")
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = user
planner = FlowPlanner(flow)
@ -468,4 +481,4 @@ class TestFlowExecutor(TestCase):
executor.flow = flow
stage_view = StageView(executor)
self.assertEqual(ident, stage_view.get_context_data()["user"].username)
self.assertEqual(ident, stage_view.get_pending_user().username)

View file

@ -1,6 +1,6 @@
"""flow views tests"""
from django.shortcuts import reverse
from django.test import Client, TestCase
from django.urls import reverse
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.planner import FlowPlan

View file

@ -1,5 +1,6 @@
"""transfer common classes"""
from dataclasses import asdict, dataclass, field, is_dataclass
from enum import Enum
from typing import Any
from uuid import UUID
@ -74,6 +75,8 @@ class DataclassEncoder(DjangoJSONEncoder):
return asdict(o)
if isinstance(o, UUID):
return str(o)
if isinstance(o, Enum):
return o.value
return super().default(o) # pragma: no cover

View file

@ -1,12 +1,12 @@
"""flow urls"""
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from authentik.flows.models import FlowDesignation
from authentik.flows.views import (
CancelView,
ConfigureFlowInitView,
FlowExecutorShellView,
FlowExecutorView,
ToDefaultFlow,
)
@ -42,8 +42,9 @@ urlpatterns = [
ConfigureFlowInitView.as_view(),
name="configure",
),
path("b/<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
path(
"<slug:flow_slug>/", FlowExecutorShellView.as_view(), name="flow-executor-shell"
"<slug:flow_slug>/",
ensure_csrf_cookie(FlowExecutorShellView.as_view()),
name="flow-executor-shell",
),
]

View file

@ -3,14 +3,8 @@ from traceback import format_tb
from typing import Any, Optional
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import (
Http404,
HttpRequest,
HttpResponse,
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect, reverse
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
@ -19,6 +13,12 @@ from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import USER_ATTRIBUTE_DEBUG
from authentik.events.models import cleanse_dict
from authentik.flows.challenge import (
ChallengeTypes,
HttpChallengeResponse,
RedirectChallenge,
ShellChallenge,
)
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
from authentik.flows.planner import (
@ -176,7 +176,7 @@ class FlowExecutorView(View):
reamining=len(self.plan.stages),
)
return redirect_with_qs(
"authentik_flows:flow-executor", self.request.GET, **self.kwargs
"authentik_api:flow-executor", self.request.GET, **self.kwargs
)
# User passed all stages
self._logger.debug(
@ -246,9 +246,7 @@ class FlowExecutorShellView(TemplateView):
def get_context_data(self, **kwargs) -> dict[str, Any]:
flow: Flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["background_url"] = flow.background.url
kwargs["exec_url"] = reverse(
"authentik_flows:flow-executor", kwargs=self.kwargs
)
kwargs["flow_slug"] = flow.slug
self.request.session[SESSION_KEY_GET] = self.request.GET
return kwargs
@ -292,16 +290,30 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons
if isinstance(source, HttpResponseRedirect) or source.status_code == 302:
redirect_url = source["Location"]
if request.path != redirect_url:
return JsonResponse({"type": "redirect", "to": redirect_url})
return HttpChallengeResponse(
RedirectChallenge(
{"type": ChallengeTypes.redirect, "to": str(redirect_url)}
)
)
return source
if isinstance(source, TemplateResponse):
return JsonResponse(
{"type": "template", "body": source.render().content.decode("utf-8")}
return HttpChallengeResponse(
ShellChallenge(
{
"type": ChallengeTypes.shell,
"body": source.render().content.decode("utf-8"),
}
)
)
# Check for actual HttpResponse (without isinstance as we dont want to check inheritance)
if source.__class__ == HttpResponse:
return JsonResponse(
{"type": "template", "body": source.content.decode("utf-8")}
return HttpChallengeResponse(
ShellChallenge(
{
"type": ChallengeTypes.shell,
"body": source.content.decode("utf-8"),
}
)
)
return source

View file

@ -1,17 +0,0 @@
{% load authentik_utils %}
{% spaceless %}
<div class="dynamic-array-widget">
{% for widget in widget.subwidgets %}
<div class="array-item input-group">
{% include widget.template_name %}
<div class="input-group-btn">
<button class="array-remove btn btn-danger" type="button">
<span class="pficon-delete"></span>
</button>
</div>
</div>
{% endfor %}
<div><button type="button" class="add-array-item btn btn-default">Add another</button></div>
</div>
{% endspaceless %}

View file

@ -1,25 +1,15 @@
"""authentik lib Templatetags"""
from hashlib import md5
from urllib.parse import urlencode
from django import template
from django.db.models import Model
from django.http.request import HttpRequest
from django.template import Context
from django.templatetags.static import static
from django.utils.html import escape, mark_safe
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.lib.config import CONFIG
from authentik.lib.utils.urls import is_url_absolute
register = template.Library()
LOGGER = get_logger()
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("authentik/user_default.png")
@register.simple_tag(takes_context=True)
def back(context: Context) -> str:
@ -46,38 +36,12 @@ def fieldtype(field):
return field.__class__.__name__
@register.simple_tag
def config(path, default=""):
"""Get a setting from the database. Returns default is setting doesn't exist."""
return CONFIG.y(path, default)
@register.filter(name="css_class")
def css_class(field, css):
"""Add css class to form field"""
return field.as_widget(attrs={"class": css})
@register.simple_tag
def avatar(user: User) -> str:
"""Get avatar, depending on authentik.avatar setting"""
mode = CONFIG.raw.get("authentik").get("avatars")
if mode == "none":
return DEFAULT_AVATAR
if mode == "gravatar":
parameters = [
("s", "158"),
("r", "g"),
]
# gravatar uses md5 for their URLs, so md5 can't be avoided
mail_hash = md5(user.email.encode("utf-8")).hexdigest() # nosec
gravatar_url = (
f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
)
return escape(gravatar_url)
raise ValueError(f"Invalid avatar mode {mode}")
@register.filter
def verbose_name(obj) -> str:
"""Return Object's Verbose Name"""
@ -94,21 +58,3 @@ def form_verbose_name(obj) -> str:
if not obj:
return ""
return verbose_name(obj._meta.model)
@register.filter
def doc(obj) -> str:
"""Return docstring of object"""
return mark_safe(obj.__doc__.replace("\n", "<br>"))
@register.simple_tag(takes_context=True)
def query_transform(context: Context, **kwargs) -> str:
"""Append objects to the current querystring"""
if "request" not in context:
return ""
request: HttpRequest = context["request"]
updated = request.GET.copy()
for key, value in kwargs.items():
updated[key] = value
return updated.urlencode()

View file

@ -1,11 +0,0 @@
"""authentik UI utils"""
from typing import Any
def human_list(_list: list[Any]) -> str:
"""Convert a list of items into 'a, b or c'"""
last_item = _list.pop()
if len(_list) < 1:
return last_item
result = ", ".join(_list)
return "%s or %s" % (result, last_item)

View file

@ -2,7 +2,7 @@
from dataclasses import asdict
from django.db.models.base import Model
from django.shortcuts import reverse
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, SerializerMethodField

View file

@ -59,6 +59,7 @@ class OutpostViewSet(ModelViewSet):
"name",
"providers__name",
]
ordering = ["name"]
@swagger_auto_schema(responses={200: OutpostHealthSerializer(many=True)})
@action(methods=["GET"], detail=True)

View file

@ -1,7 +1,7 @@
"""policy API Views"""
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import reverse
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.request import Request

View file

@ -0,0 +1,86 @@
# Generated by Django 3.1.6 on 2021-02-22 18:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0009_auto_20210215_2159"),
]
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.authenticator_static",
"authentik Stages.Authenticator.Static",
),
(
"authentik.stages.authenticator_totp",
"authentik Stages.Authenticator.TOTP",
),
(
"authentik.stages.authenticator_validate",
"authentik Stages.Authenticator.Validate",
),
(
"authentik.stages.authenticator_webauthn",
"authentik Stages.Authenticator.WebAuthn",
),
("authentik.stages.captcha", "authentik Stages.Captcha"),
("authentik.stages.consent", "authentik Stages.Consent"),
("authentik.stages.dummy", "authentik Stages.Dummy"),
("authentik.stages.email", "authentik Stages.Email"),
(
"authentik.stages.identification",
"authentik Stages.Identification",
),
("authentik.stages.invitation", "authentik Stages.User Invitation"),
("authentik.stages.password", "authentik Stages.Password"),
("authentik.stages.prompt", "authentik Stages.Prompt"),
("authentik.stages.user_delete", "authentik Stages.User Delete"),
("authentik.stages.user_login", "authentik Stages.User Login"),
("authentik.stages.user_logout", "authentik Stages.User Logout"),
("authentik.stages.user_write", "authentik Stages.User Write"),
("authentik.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

@ -1,5 +1,5 @@
"""OAuth2Provider API Views"""
from django.shortcuts import reverse
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField

View file

@ -1,20 +0,0 @@
{% extends 'login/form_with_user.html' %}
{% load i18n %}
{% block beneath_form %}
<div class="pf-c-form__group">
<p>
{% blocktrans with name=context.application.name %}
You're about to sign into <strong id="application-name">{{ name }}</strong>.
{% endblocktrans %}
</p>
<p>{% trans "Application requires following permissions" %}</p>
<ul class="pf-c-list" id="scopes">
{% for scope_name, description in context.scope_descriptions.items %}
<li id="scope-{{ scope_name }}">{{ description }}</li>
{% endfor %}
</ul>
{{ hidden_inputs }}
</div>
{% endblock %}

View file

@ -9,6 +9,7 @@ from django.http import HttpRequest, HttpResponse
from django.http.response import Http404
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.core.models import Application
@ -48,14 +49,14 @@ from authentik.providers.oauth2.models import (
from authentik.providers.oauth2.views.userinfo import UserInfoView
from authentik.stages.consent.models import ConsentMode, ConsentStage
from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_TEMPLATE,
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
ConsentStageView,
)
LOGGER = get_logger()
PLAN_CONTEXT_PARAMS = "params"
PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login"
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET, PROMPT_LOGIN}
@ -232,7 +233,9 @@ class OAuthFulfillmentStage(StageView):
params: OAuthAuthorizationParams
provider: OAuth2Provider
# pylint: disable=unused-argument
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""final Stage of an OAuth2 Flow"""
self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(
PLAN_CONTEXT_PARAMS
)
@ -432,6 +435,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
planner = FlowPlanner(self.provider.authorization_flow)
# planner.use_cache = False
planner.allow_empty_flows = True
scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope)
plan: FlowPlan = planner.plan(
self.request,
{
@ -439,11 +443,12 @@ class AuthorizationFlowInitView(PolicyAccessView):
PLAN_CONTEXT_APPLICATION: self.application,
# OAuth2 related params
PLAN_CONTEXT_PARAMS: self.params,
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
self.params.scope
),
# Consent related params
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html",
PLAN_CONTEXT_CONSENT_HEADER: _(
"You're about to sign into %(application)s."
)
% {"application": self.application.name},
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
},
)
# OpenID clients can specify a `prompt` parameter, and if its set to consent we

View file

@ -22,14 +22,16 @@ class UserInfoView(View):
"""Create a dictionary with all the requested claims about the End-User.
See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse"""
def get_scope_descriptions(self, scopes: list[str]) -> dict[str, str]:
def get_scope_descriptions(self, scopes: list[str]) -> list[dict[str, str]]:
"""Get a list of all Scopes's descriptions"""
scope_descriptions = {}
scope_descriptions = []
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by(
"scope_name"
):
if scope.description != "":
scope_descriptions[scope.scope_name] = scope.description
scope_descriptions.append(
{"id": scope.scope_name, "name": scope.description}
)
# GitHub Compatibility Scopes are handeled differently, since they required custom paths
# Hence they don't exist as Scope objects
github_scope_map = {
@ -44,7 +46,9 @@ class UserInfoView(View):
}
for scope in scopes:
if scope in github_scope_map:
scope_descriptions[scope] = github_scope_map[scope]
scope_descriptions.append(
{"id": scope, "name": github_scope_map[scope]}
)
return scope_descriptions
def get_claims(self, token: RefreshToken) -> dict[str, Any]:

View file

@ -67,6 +67,7 @@ class ProxyProviderViewSet(ModelViewSet):
queryset = ProxyProvider.objects.all()
serializer_class = ProxyProviderSerializer
ordering = ["name"]
class ProxyOutpostConfigSerializer(ModelSerializer):
@ -115,3 +116,4 @@ class ProxyOutpostConfigViewSet(ModelViewSet):
queryset = ProxyProvider.objects.filter(application__isnull=False)
serializer_class = ProxyOutpostConfigSerializer
ordering = ["name"]

View file

@ -3,7 +3,7 @@ from typing import Iterator, Optional
import xmlsec # nosec
from django.http import HttpRequest
from django.shortcuts import reverse
from django.urls import reverse
from lxml.etree import Element, SubElement, tostring # nosec
from authentik.providers.saml.models import SAMLProvider

View file

@ -1,14 +0,0 @@
{% extends 'login/form_with_user.html' %}
{% load i18n %}
{% block beneath_form %}
<div class="pf-c-form__group">
<p>
{% blocktrans with name=context.application.name %}
You're about to sign into <strong id="application-name">{{ name }}</strong>.
{% endblocktrans %}
</p>
{{ hidden_inputs }}
</div>
{% endblock %}

View file

@ -1,15 +1,17 @@
"""authentik SAML IDP Views"""
from django.core.validators import URLValidator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField, DictField
from structlog.stdlib import get_logger
from authentik.core.models import Application
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION
from authentik.flows.stage import StageView
from authentik.flows.stage import ChallengeStageView
from authentik.lib.views import bad_request_message
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.assertion import AssertionProcessor
@ -27,10 +29,17 @@ REQUEST_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_AUTH_N_REQUEST = "authn_request"
class AutosubmitChallenge(Challenge):
"""Autosubmit challenge used to send and navigate a POST request"""
url = CharField()
attrs = DictField(child=CharField())
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
class SAMLFlowFinalView(StageView):
class SAMLFlowFinalView(ChallengeStageView):
"""View used by FlowExecutor after all stages have passed. Logs the authorization,
and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for
and redirects to the SP (if REDIRECT is configured) or shows an auto-submit element
(if POST is configured)."""
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
@ -62,12 +71,13 @@ class SAMLFlowFinalView(StageView):
}
if auth_n_request.relay_state:
form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
return render(
return super().get(
self.request,
"generic/autosubmit_form.html",
{
**{
"type": ChallengeTypes.native,
"component": "ak-stage-autosubmit",
"title": "Redirecting to %(app)s..." % {"app": application.name},
"url": provider.acs_url,
"title": _("Redirecting to %(app)s..." % {"app": application.name}),
"attrs": form_attrs,
},
)
@ -80,3 +90,10 @@ class SAMLFlowFinalView(StageView):
querystring = urlencode(url_args)
return redirect(f"{provider.acs_url}?{querystring}")
return bad_request_message(request, "Invalid sp_binding specified")
def get_challenge(self, *args, **kwargs) -> Challenge:
return AutosubmitChallenge(data=kwargs)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# We'll never get here since the challenge redirects to the SP
return HttpResponseBadRequest()

View file

@ -4,6 +4,7 @@ from typing import Optional
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
@ -31,7 +32,10 @@ from authentik.providers.saml.views.flows import (
SESSION_KEY_AUTH_N_REQUEST,
SAMLFlowFinalView,
)
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
)
LOGGER = get_logger()
@ -68,7 +72,11 @@ class SAMLSSOView(PolicyAccessView):
{
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: self.application,
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html",
PLAN_CONTEXT_CONSENT_HEADER: _(
"You're about to sign into %(application)s."
)
% {"application": self.application.name},
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
},
)
plan.append(in_memory_stage(SAMLFlowFinalView))

View file

@ -2,8 +2,8 @@
from io import StringIO
from django.core.management import call_command
from django.shortcuts import reverse
from django.test import TestCase
from django.urls import reverse
from authentik.core.models import Token, TokenIntents, User

View file

@ -18,8 +18,6 @@ class ChannelsStorage(FallbackStorage):
def _store(self, messages: list[Message], response, *args, **kwargs):
prefix = f"user_{self.request.session.session_key}_messages_"
keys = cache.keys(f"{prefix}*")
if len(keys) < 1:
return super()._store(messages, response, *args, **kwargs)
for key in keys:
uid = key.replace(prefix, "")
for message in messages:
@ -32,4 +30,3 @@ class ChannelsStorage(FallbackStorage):
"message": message.message,
},
)
return None

View file

@ -108,22 +108,22 @@ INSTALLED_APPS = [
"authentik.sources.ldap.apps.AuthentikSourceLDAPConfig",
"authentik.sources.oauth.apps.AuthentikSourceOAuthConfig",
"authentik.sources.saml.apps.AuthentikSourceSAMLConfig",
"authentik.stages.captcha.apps.AuthentikStageCaptchaConfig",
"authentik.stages.consent.apps.AuthentikStageConsentConfig",
"authentik.stages.dummy.apps.AuthentikStageDummyConfig",
"authentik.stages.email.apps.AuthentikStageEmailConfig",
"authentik.stages.prompt.apps.AuthentikStagPromptConfig",
"authentik.stages.identification.apps.AuthentikStageIdentificationConfig",
"authentik.stages.invitation.apps.AuthentikStageUserInvitationConfig",
"authentik.stages.user_delete.apps.AuthentikStageUserDeleteConfig",
"authentik.stages.user_login.apps.AuthentikStageUserLoginConfig",
"authentik.stages.user_logout.apps.AuthentikStageUserLogoutConfig",
"authentik.stages.user_write.apps.AuthentikStageUserWriteConfig",
"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.captcha.apps.AuthentikStageCaptchaConfig",
"authentik.stages.consent.apps.AuthentikStageConsentConfig",
"authentik.stages.dummy.apps.AuthentikStageDummyConfig",
"authentik.stages.email.apps.AuthentikStageEmailConfig",
"authentik.stages.identification.apps.AuthentikStageIdentificationConfig",
"authentik.stages.invitation.apps.AuthentikStageUserInvitationConfig",
"authentik.stages.password.apps.AuthentikStagePasswordConfig",
"authentik.stages.prompt.apps.AuthentikStagePromptConfig",
"authentik.stages.user_delete.apps.AuthentikStageUserDeleteConfig",
"authentik.stages.user_login.apps.AuthentikStageUserLoginConfig",
"authentik.stages.user_logout.apps.AuthentikStageUserLogoutConfig",
"authentik.stages.user_write.apps.AuthentikStageUserWriteConfig",
"rest_framework",
"django_filters",
"drf_yasg2",
@ -470,8 +470,6 @@ for _app in INSTALLED_APPS:
pass
if DEBUG:
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
CELERY_TASK_ALWAYS_EAGER = True
INSTALLED_APPS.append("authentik.core.apps.AuthentikCoreConfig")

View file

@ -2,8 +2,8 @@
from base64 import b64encode
from django.conf import settings
from django.shortcuts import reverse
from django.test import Client, TestCase
from django.urls import reverse
class TestRoot(TestCase):

View file

@ -63,13 +63,9 @@ urlpatterns += [
]
if settings.DEBUG:
import debug_toolbar
urlpatterns = (
[
path("-/debug/", include(debug_toolbar.urls)),
]
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+ urlpatterns
)

View file

@ -3,7 +3,8 @@ from typing import Optional, Type
from django.db import models
from django.forms import ModelForm
from django.urls import reverse, reverse_lazy
from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
@ -56,11 +57,11 @@ class OAuthSource(Source):
@property
def ui_login_button(self) -> UILoginButton:
return UILoginButton(
url=reverse_lazy(
url=reverse(
"authentik_sources_oauth:oauth-client-login",
kwargs={"source_slug": self.slug},
),
icon_path=f"authentik/sources/{self.provider_type}.svg",
icon_url=static(f"authentik/sources/{self.provider_type}.svg"),
name=self.name,
)

View file

@ -1,6 +1,6 @@
"""OAuth Source tests"""
from django.shortcuts import reverse
from django.test import TestCase
from django.urls import reverse
from authentik.sources.oauth.models import OAuthSource

View file

@ -14,7 +14,9 @@ class PostUserEnrollmentStage(StageView):
"""Dynamically injected stage which saves the OAuth Connection after
the user has been enrolled."""
# pylint: disable=unused-argument
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Stage used after the user has been enrolled"""
access: UserOAuthSourceConnection = self.executor.plan.context[
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS
]

View file

@ -4,8 +4,7 @@ from typing import Type
from django.db import models
from django.forms import ModelForm
from django.http import HttpRequest
from django.shortcuts import reverse
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
@ -166,10 +165,9 @@ class SAMLSource(Source):
def ui_login_button(self) -> UILoginButton:
return UILoginButton(
name=self.name,
url=reverse_lazy(
url=reverse(
"authentik_sources_saml:login", kwargs={"source_slug": self.slug}
),
icon_path="",
)
@property

View file

@ -1,31 +1,9 @@
"""Static Authenticator forms"""
from django import forms
from django.utils.safestring import mark_safe
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
class StaticTokenWidget(forms.widgets.Widget):
"""Widget to render tokens as multiple labels"""
def render(self, name, value, attrs=None, renderer=None):
final_string = '<ul class="ak-otp-tokens">'
for token in value:
final_string += f"<li>{token.token}</li>"
final_string += "</ul>"
return mark_safe(final_string) # nosec
class SetupForm(forms.Form):
"""Form to setup Static OTP"""
tokens = forms.CharField(widget=StaticTokenWidget, disabled=True, required=False)
def __init__(self, tokens, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["tokens"].initial = tokens
class AuthenticatorStaticStageForm(forms.ModelForm):
"""Static Authenticator Stage setup form"""

View file

@ -3,7 +3,7 @@ from typing import Optional, Type
from django.db import models
from django.forms import ModelForm
from django.shortcuts import reverse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer

View file

@ -1,14 +1,16 @@
"""Static OTP Setup stage"""
from typing import Any
from django.http import HttpRequest, HttpResponse
from django.views.generic import FormView
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from rest_framework.fields import CharField, ListField
from structlog.stdlib import get_logger
from authentik.flows.challenge import (
ChallengeResponse,
ChallengeTypes,
WithUserInfoChallenge,
)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.stages.authenticator_static.forms import SetupForm
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
LOGGER = get_logger()
@ -16,16 +18,24 @@ SESSION_STATIC_DEVICE = "static_device"
SESSION_STATIC_TOKENS = "static_device_tokens"
class AuthenticatorStaticStageView(FormView, StageView):
class AuthenticatorStaticChallenge(WithUserInfoChallenge):
"""Static authenticator challenge"""
codes = ListField(child=CharField())
class AuthenticatorStaticStageView(ChallengeStageView):
"""Static OTP Setup stage"""
form_class = SetupForm
def get_form_kwargs(self, **kwargs) -> dict[str, Any]:
kwargs = super().get_form_kwargs(**kwargs)
tokens = self.request.session[SESSION_STATIC_TOKENS]
kwargs["tokens"] = tokens
return kwargs
def get_challenge(self, *args, **kwargs) -> AuthenticatorStaticChallenge:
tokens: list[StaticToken] = self.request.session[SESSION_STATIC_TOKENS]
return AuthenticatorStaticChallenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-authenticator-static",
"codes": [token.token for token in tokens],
}
)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
@ -51,7 +61,7 @@ class AuthenticatorStaticStageView(FormView, StageView):
self.request.session[SESSION_STATIC_TOKENS] = tokens
return super().get(request, *args, **kwargs)
def form_valid(self, form: SetupForm) -> HttpResponse:
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
"""Verify OTP Token"""
device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE]
device.save()

View file

@ -1,54 +1,9 @@
"""OTP Time forms"""
from django import forms
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_otp.models import Device
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
class PictureWidget(forms.widgets.Widget):
"""Widget to render value as img-tag"""
def render(self, name, value, attrs=None, renderer=None):
return mark_safe(f"<br>{value}") # nosec
class SetupForm(forms.Form):
"""Form to setup Time-based OTP"""
device: Device = None
qr_code = forms.CharField(
widget=PictureWidget,
disabled=True,
required=False,
label=_("Scan this Code with your OTP App."),
)
code = forms.CharField(
label=_("Please enter the Token on your device."),
widget=forms.TextInput(
attrs={
"autocomplete": "off",
"placeholder": "Code",
"autofocus": "autofocus",
}
),
)
def __init__(self, device, qr_code, *args, **kwargs):
super().__init__(*args, **kwargs)
self.device = device
self.fields["qr_code"].initial = qr_code
def clean_code(self):
"""Check code with new otp device"""
if self.device is not None:
if not self.device.verify_token(self.cleaned_data.get("code")):
raise forms.ValidationError(_("OTP Code does not match"))
return self.cleaned_data.get("code")
class AuthenticatorTOTPStageForm(forms.ModelForm):
"""OTP Time-based Stage setup form"""

View file

@ -3,7 +3,7 @@ from typing import Optional, Type
from django.db import models
from django.forms import ModelForm
from django.shortcuts import reverse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer

View file

@ -1,43 +1,66 @@
"""TOTP Setup stage"""
from typing import Any
from django.http import HttpRequest, HttpResponse
from django.utils.encoding import force_str
from django.views.generic import FormView
from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _
from django_otp.plugins.otp_totp.models import TOTPDevice
from lxml.etree import tostring # nosec
from qrcode import QRCode
from qrcode.image.svg import SvgFillImage
from rest_framework.fields import CharField, IntegerField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
ChallengeTypes,
WithUserInfoChallenge,
)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.stages.authenticator_totp.forms import SetupForm
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
LOGGER = get_logger()
SESSION_TOTP_DEVICE = "totp_device"
class AuthenticatorTOTPStageView(FormView, StageView):
class AuthenticatorTOTPChallenge(WithUserInfoChallenge):
"""TOTP Setup challenge"""
config_url = CharField()
class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
"""TOTP Challenge response, device is set by get_response_instance"""
device: TOTPDevice
code = IntegerField()
def validate_code(self, code: int) -> int:
"""Validate totp code"""
if self.device is not None:
if not self.device.verify_token(code):
raise ValidationError(_("OTP Code does not match"))
return code
class AuthenticatorTOTPStageView(ChallengeStageView):
"""OTP totp Setup stage"""
form_class = SetupForm
response_class = AuthenticatorTOTPChallengeResponse
def get_form_kwargs(self, **kwargs) -> dict[str, Any]:
kwargs = super().get_form_kwargs(**kwargs)
def get_challenge(self, *args, **kwargs) -> Challenge:
device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE]
kwargs["device"] = device
kwargs["qr_code"] = self._get_qr_code(device)
return kwargs
return AuthenticatorTOTPChallenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-authenticator-totp",
"config_url": device.config_url,
}
)
def _get_qr_code(self, device: TOTPDevice) -> str:
"""Get QR Code SVG as string based on `device`"""
qr_code = QRCode(image_factory=SvgFillImage)
qr_code.add_data(device.config_url)
svg_image = tostring(qr_code.make_image().get_image())
sr_wrapper = f'<div id="qr" data-otpuri="{device.config_url}">{force_str(svg_image)}</div>'
return sr_wrapper
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
response = super().get_response_instance(data)
response.device = self.request.session[SESSION_TOTP_DEVICE]
return response
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
@ -58,8 +81,8 @@ class AuthenticatorTOTPStageView(FormView, StageView):
self.request.session[SESSION_TOTP_DEVICE] = device
return super().get(request, *args, **kwargs)
def form_valid(self, form: SetupForm) -> HttpResponse:
"""Verify OTP Token"""
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
"""TOTP Token is validated by challenge"""
device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE]
device.save()
del self.request.session[SESSION_TOTP_DEVICE]

View file

@ -29,7 +29,8 @@ class UserSettingsView(LoginRequiredMixin, TemplateView):
class DisableView(LoginRequiredMixin, View):
"""Disable TOTP for user"""
def get(self, request: HttpRequest) -> HttpResponse:
# pylint: disable=unused-argument
def get(self, request: HttpRequest, **kwargs) -> HttpResponse:
"""Delete all the devices for user"""
totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
totp.delete()

View file

@ -0,0 +1,119 @@
"""Validation stage challenge checking"""
from django.db.models import Model
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from django_otp import match_token
from django_otp.models import Device
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.fields import CharField, JSONField
from rest_framework.serializers import Serializer, ValidationError
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
from webauthn.webauthn import (
AuthenticationRejectedException,
RegistrationRejectedException,
WebAuthnUserDataMissing,
)
from authentik.core.models import User
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge, get_origin
class DeviceChallenge(Serializer):
"""Single device challenge"""
device_class = CharField()
device_uid = CharField()
challenge = JSONField()
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
"""Generate challenge for a single device"""
if isinstance(device, (TOTPDevice, StaticDevice)):
# Code-based challenges have no hints
return {}
return get_webauthn_challenge(request, device)
def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict:
"""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("=")
webauthn_user = WebAuthnUser(
device.user.uid,
device.user.username,
device.user.name,
device.user.avatar,
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
return webauthn_assertion_options.assertion_dict
def validate_challenge_code(code: str, request: HttpRequest, user: User) -> str:
"""Validate code-based challenges. We test against every device, on purpose, as
the user mustn't choose between totp and static devices."""
device = match_token(user, code)
if not device:
raise ValidationError(_("Invalid Token"))
return code
def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> dict:
"""Validate WebAuthn Challenge"""
challenge = request.session.get("challenge")
assertion_response = data
credential_id = assertion_response.get("id")
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device:
raise ValidationError("Device does not exist.")
webauthn_user = WebAuthnUser(
user.uid,
user.username,
user.name,
user.avatar,
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_response = WebAuthnAssertionResponse(
webauthn_user,
assertion_response,
challenge,
get_origin(request),
uv_required=False,
) # User Verification
try:
sign_count = webauthn_assertion_response.verify()
except (
AuthenticationRejectedException,
WebAuthnUserDataMissing,
RegistrationRejectedException,
) as exc:
raise ValidationError("Assertion failed") from exc
device.set_sign_count(sign_count)
return data

View file

@ -14,6 +14,7 @@ from authentik.flows.models import NotConfiguredAction, Stage
class DeviceClasses(models.TextChoices):
"""Device classes this stage can validate"""
# device class must match Device's class name so StaticDevice -> static
STATIC = "static"
TOTP = "totp", _("TOTP")
WEBAUTHN = "webauthn", _("WebAuthn")

View file

@ -1,46 +1,153 @@
"""OTP Validation"""
from typing import Any
"""Authenticator Validation"""
from django.http import HttpRequest, HttpResponse
from django.views.generic import FormView
from django_otp import user_has_device
from django_otp import devices_for_user
from rest_framework.fields import CharField, JSONField, ListField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
from authentik.flows.challenge import (
ChallengeResponse,
ChallengeTypes,
WithUserInfoChallenge,
)
from authentik.flows.models import NotConfiguredAction
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.stages.authenticator_validate.forms import ValidationForm
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_validate.challenge import (
DeviceChallenge,
get_challenge_for_device,
validate_challenge_code,
validate_challenge_webauthn,
)
from authentik.stages.authenticator_validate.models import (
AuthenticatorValidateStage,
DeviceClasses,
)
LOGGER = get_logger()
PER_DEVICE_CLASSES = [DeviceClasses.WEBAUTHN]
class AuthenticatorValidateStageView(FormView, StageView):
"""OTP Validation"""
form_class = ValidationForm
class AuthenticatorChallenge(WithUserInfoChallenge):
"""Authenticator challenge"""
def get_form_kwargs(self, **kwargs) -> dict[str, Any]:
kwargs = super().get_form_kwargs(**kwargs)
kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
return kwargs
device_challenges = ListField(child=DeviceChallenge())
class AuthenticatorChallengeResponse(ChallengeResponse):
"""Challenge used for Code-based and WebAuthn authenticators"""
code = CharField(required=False)
webauthn = JSONField(required=False)
def validate_code(self, code: str) -> str:
"""Validate code-based response, raise error if code isn't allowed"""
device_challenges: list[dict] = self.stage.request.session.get(
"device_challenges"
)
if not any(
x["device_class"] in (DeviceClasses.TOTP, DeviceClasses.STATIC)
for x in device_challenges
):
raise ValidationError("Got code but no compatible device class allowed")
return validate_challenge_code(
code, self.stage.request, self.stage.get_pending_user()
)
def validate_webauthn(self, webauthn: dict) -> dict:
"""Validate webauthn response, raise error if webauthn wasn't allowed
or response is invalid"""
device_challenges: list[dict] = self.stage.request.session.get(
"device_challenges"
)
if not any(
x["device_class"] in (DeviceClasses.WEBAUTHN) for x in device_challenges
):
raise ValidationError("Got webauthn but no compatible device class allowed")
return validate_challenge_webauthn(
webauthn, self.stage.request, self.stage.get_pending_user()
)
def validate(self, data: dict):
# Checking if the given data is from a valid device class is done above
# Here we only check if the any data was sent at all
if "code" not in data and "webauthn" not in data:
raise ValidationError("Empty response")
return data
class AuthenticatorValidateStageView(ChallengeStageView):
"""Authenticator Validation"""
response_class = AuthenticatorChallengeResponse
def get_device_challenges(self) -> list[dict]:
"""Get a list of all device challenges applicable for the current stage"""
challenges = []
user_devices = devices_for_user(self.get_pending_user())
# static and totp are only shown once
# since their challenges are device-independant
seen_classes = []
stage: AuthenticatorValidateStage = self.executor.current_stage
for device in user_devices:
device_class = device.__class__.__name__.lower().replace("device", "")
if device_class not in stage.device_classes:
continue
# Ensure only classes in PER_DEVICE_CLASSES are returned per device
# otherwise only return a single challenge
if device_class in seen_classes and device_class not in PER_DEVICE_CLASSES:
continue
if device_class not in seen_classes:
seen_classes.append(device_class)
challenges.append(
DeviceChallenge(
data={
"device_class": device_class,
"device_uid": device.pk,
"challenge": get_challenge_for_device(self.request, device),
}
).initial_data
)
return challenges
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check if a user is set, and check if the user has any devices
if not, we can skip this entire stage"""
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user:
LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok()
has_devices = user_has_device(user)
stage: AuthenticatorValidateStage = self.executor.current_stage
challenges = self.get_device_challenges()
self.request.session["device_challenges"] = challenges
if not has_devices:
# No allowed devices
if len(challenges) < 1:
if stage.not_configured_action == NotConfiguredAction.SKIP:
LOGGER.debug("Authenticator not configured, skipping stage")
return self.executor.stage_ok()
if stage.not_configured_action == NotConfiguredAction.DENY:
LOGGER.debug("Authenticator not configured, denying")
return self.executor.stage_invalid()
return super().get(request, *args, **kwargs)
def form_valid(self, form: ValidationForm) -> HttpResponse:
"""Verify OTP Token"""
# Since we do token checking in the form, we know the token is valid here
# so we can just continue
def get_challenge(self) -> AuthenticatorChallenge:
challenges = self.request.session["device_challenges"]
return AuthenticatorChallenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-authenticator-validate",
"device_challenges": challenges,
}
)
# pylint: disable=unused-argument
def challenge_valid(
self, challenge: AuthenticatorChallengeResponse
) -> HttpResponse:
# All validation is done by the serializer
return self.executor.stage_ok()

View file

@ -22,7 +22,7 @@ def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
designation=FlowDesignation.STAGE_CONFIGURATION,
defaults={
"name": "default-authenticator-webuahtn-setup",
"title": "Setup Static OTP Tokens",
"title": "Setup WebAuthn",
},
)

View file

@ -0,0 +1,20 @@
# Generated by Django 3.1.6 on 2021-02-22 18:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_authenticator_webauthn", "0002_default_setup_flow"),
]
operations = [
migrations.AddField(
model_name="webauthndevice",
name="confirmed",
field=models.BooleanField(
default=True, help_text="Is this device ready for use?"
),
),
]

View file

@ -4,10 +4,11 @@ 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.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views import View
from django_otp.models import Device
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import ConfigurableStage, Stage
@ -27,10 +28,10 @@ class AuthenticateWebAuthnStage(ConfigurableStage, Stage):
@property
def type(self) -> Type[View]:
from authentik.stages.authenticator_webauthn.stage import (
AuthenticateWebAuthnStageView,
AuthenticatorWebAuthnStageView,
)
return AuthenticateWebAuthnStageView
return AuthenticatorWebAuthnStageView
@property
def form(self) -> Type[ModelForm]:
@ -56,7 +57,7 @@ class AuthenticateWebAuthnStage(ConfigurableStage, Stage):
verbose_name_plural = _("WebAuthn Authenticator Setup Stages")
class WebAuthnDevice(models.Model):
class WebAuthnDevice(Device):
"""WebAuthn Device for a single user"""
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)

View file

@ -1,13 +1,29 @@
"""WebAuthn stage"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.generic import FormView
from django.http.request import QueryDict
from rest_framework.fields import JSONField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
from webauthn.webauthn import (
RegistrationRejectedException,
WebAuthnCredential,
WebAuthnMakeCredentialOptions,
WebAuthnRegistrationResponse,
)
from authentik.core.models import User
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import (
generate_challenge,
get_origin,
get_rp_id,
)
RP_NAME = "authentik"
LOGGER = get_logger()
@ -16,29 +32,135 @@ SESSION_KEY_WEBAUTHN_AUTHENTICATED = (
)
class AuthenticateWebAuthnStageView(FormView, StageView):
class AuthenticatorWebAuthnChallenge(Challenge):
"""WebAuthn Challenge"""
registration = JSONField()
class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
"""WebAuthn Challenge response"""
response = JSONField()
request: HttpRequest
user: User
def validate_response(self, response: dict) -> dict:
"""Validate webauthn challenge response"""
challenge = self.request.session["challenge"]
trusted_attestation_cert_required = True
self_attestation_permitted = True
none_attestation_permitted = True
webauthn_registration_response = WebAuthnRegistrationResponse(
get_rp_id(self.request),
get_origin(self.request),
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)
raise ValidationError("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:
raise ValidationError("Credential ID already exists.")
webauthn_credential.credential_id = str(
webauthn_credential.credential_id, "utf-8"
)
webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8")
return webauthn_credential
class AuthenticatorWebAuthnStageView(ChallengeStageView):
"""WebAuthn stage"""
response_class = AuthenticatorWebAuthnChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
# clear session variables prior to starting a new registration
self.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.
self.request.session["challenge"] = challenge.rstrip("=")
user = self.get_pending_user()
make_credential_options = WebAuthnMakeCredentialOptions(
challenge,
RP_NAME,
get_rp_id(self.request),
user.uid,
user.username,
user.name,
user.avatar,
)
return AuthenticatorWebAuthnChallenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-authenticator-webauthn",
"registration": make_credential_options.registration_dict,
}
)
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")
return super().get(request, *args, **kwargs)
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()
def get_response_instance(
self, data: QueryDict
) -> AuthenticatorWebAuthnChallengeResponse:
response: AuthenticatorWebAuthnChallengeResponse = (
super().get_response_instance(data)
)
response.request = self.request
response.user = self.get_pending_user()
return response
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# Webauthn Challenge has already been validated
webauthn_credential: WebAuthnCredential = response.validated_data["response"]
existing_device = WebAuthnDevice.objects.filter(
credential_id=webauthn_credential.credential_id
).first()
if not existing_device:
WebAuthnDevice.objects.create(
user=self.get_pending_user(),
public_key=webauthn_credential.public_key,
credential_id=webauthn_credential.credential_id,
sign_count=webauthn_credential.sign_count,
rp_id=get_rp_id(self.request),
)
else:
return self.executor.stage_invalid(
"Device with Credential ID already exists."
)
return self.executor.stage_ok()

View file

@ -1,37 +1,9 @@
"""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,
)
from authentik.stages.authenticator_webauthn.views import UserSettingsView
# 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

@ -2,6 +2,8 @@
import base64
import os
from django.http import HttpRequest
CHALLENGE_DEFAULT_BYTE_LEN = 32
@ -21,3 +23,18 @@ def generate_challenge(challenge_len=CHALLENGE_DEFAULT_BYTE_LEN):
if not isinstance(challenge_base64, str):
challenge_base64 = challenge_base64.decode("utf-8")
return challenge_base64
def get_rp_id(request: HttpRequest) -> str:
"""Get hostname from http request, without port"""
host = request.get_host()
if ":" in host:
return host.split(":")[0]
return host
def get_origin(request: HttpRequest) -> str:
"""Return Origin by building an absolute URL and removing the
trailing slash"""
full_url = request.build_absolute_uri("/")
return full_url[:-1]

View file

@ -1,229 +1,12 @@
"""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 file

@ -12,6 +12,7 @@ class CaptchaStageSerializer(StageSerializer):
model = CaptchaStage
fields = StageSerializer.Meta.fields + ["public_key", "private_key"]
extra_kwargs = {"private_key": {"write_only": True}}
class CaptchaStageViewSet(ModelViewSet):

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