Merge pull request #564 from BeryJu/stage-challenge
This commit is contained in:
commit
269e6c4f38
11
Makefile
11
Makefile
|
@ -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
25
Pipfile
|
@ -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 = "*"
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 }}">
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>/",
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 %}
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
|
@ -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
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -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?"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
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()
|
||||
return self.executor.stage_invalid()
|
||||
|
|
|
@ -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"
|
||||
),
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
Reference in New Issue