Merge pull request #564 from BeryJu/stage-challenge

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

View File

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

25
Pipfile
View File

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

147
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "933685b75680e3a06d2f523239d848b14d1507d385de42401863f4fb6345366c" "sha256": "88f986d5c35e42ee1890acc2510b99507d268f8d6b0d3ab5abe8d37c53706379"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -56,6 +56,7 @@
"sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0", "sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0",
"sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2" "sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2"
], ],
"markers": "python_version >= '3.6'",
"version": "==3.7.4" "version": "==3.7.4"
}, },
"aioredis": { "aioredis": {
@ -70,6 +71,7 @@
"sha256:1e759a7f202d910939de6eca45c23a107f6b71111f41d1282c648e9ac3d21901", "sha256:1e759a7f202d910939de6eca45c23a107f6b71111f41d1282c648e9ac3d21901",
"sha256:affdd263d8b8eb3c98170b78bf83867cdb6a14901d586e00ddb65bfe2f0c4e60" "sha256:affdd263d8b8eb3c98170b78bf83867cdb6a14901d586e00ddb65bfe2f0c4e60"
], ],
"markers": "python_version >= '3.6'",
"version": "==5.0.5" "version": "==5.0.5"
}, },
"asgiref": { "asgiref": {
@ -77,6 +79,7 @@
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
], ],
"markers": "python_version >= '3.5'",
"version": "==3.3.1" "version": "==3.3.1"
}, },
"async-timeout": { "async-timeout": {
@ -84,6 +87,7 @@
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
], ],
"markers": "python_full_version >= '3.5.3'",
"version": "==3.0.1" "version": "==3.0.1"
}, },
"attrs": { "attrs": {
@ -91,6 +95,7 @@
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.3.0" "version": "==20.3.0"
}, },
"autobahn": { "autobahn": {
@ -98,6 +103,7 @@
"sha256:884f79c50fdc55ade2c315946a9caa145e8b10075eee9d2c2594ea5e8f5226aa", "sha256:884f79c50fdc55ade2c315946a9caa145e8b10075eee9d2c2594ea5e8f5226aa",
"sha256:bf7a9d302a34d0f719d43c57f65ca1f2f5c982dd6ea0c11e1e190ef6f43710fe" "sha256:bf7a9d302a34d0f719d43c57f65ca1f2f5c982dd6ea0c11e1e190ef6f43710fe"
], ],
"markers": "python_version >= '3.7'",
"version": "==21.2.2" "version": "==21.2.2"
}, },
"automat": { "automat": {
@ -127,6 +133,7 @@
"sha256:48350c0524fafcc6f1cf792a80080eeaf282c4ceed016e9296f1ebfda7c34fb3", "sha256:48350c0524fafcc6f1cf792a80080eeaf282c4ceed016e9296f1ebfda7c34fb3",
"sha256:dd95871cf8a418ab730a219f2bfc301c98f2d9d0a294e43f51715bdd4aedd4cd" "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" "version": "==1.20.16"
}, },
"cachetools": { "cachetools": {
@ -134,6 +141,7 @@
"sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2", "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2",
"sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9" "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"
], ],
"markers": "python_version ~= '3.5'",
"version": "==4.2.1" "version": "==4.2.1"
}, },
"cbor2": { "cbor2": {
@ -227,6 +235,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" "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" "version": "==7.1.2"
}, },
"click-didyoumean": { "click-didyoumean": {
@ -300,6 +309,7 @@
"sha256:0052c9887600c57054a5867d4b0240159fa009faa3bcf6a1627271d9cdcb005a", "sha256:0052c9887600c57054a5867d4b0240159fa009faa3bcf6a1627271d9cdcb005a",
"sha256:c22b692707f514de9013651ecb687f2abe4f35cf6fe292ece634e9f1737bc7e3" "sha256:c22b692707f514de9013651ecb687f2abe4f35cf6fe292ece634e9f1737bc7e3"
], ],
"markers": "python_version >= '3.6'",
"version": "==3.0.1" "version": "==3.0.1"
}, },
"defusedxml": { "defusedxml": {
@ -373,13 +383,6 @@
"index": "pypi", "index": "pypi",
"version": "==2.1.0" "version": "==2.1.0"
}, },
"django-recaptcha": {
"hashes": [
"sha256:567784963fd5400feaf92e8951d8dbbbdb4b4c48a76e225d4baa63a2c9d2cd8c"
],
"index": "pypi",
"version": "==2.0.6"
},
"django-redis": { "django-redis": {
"hashes": [ "hashes": [
"sha256:1133b26b75baa3664164c3f44b9d5d133d1b8de45d94d79f38d1adc5b1d502e5", "sha256:1133b26b75baa3664164c3f44b9d5d133d1b8de45d94d79f38d1adc5b1d502e5",
@ -440,6 +443,7 @@
"hashes": [ "hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.18.2" "version": "==0.18.2"
}, },
"geoip2": { "geoip2": {
@ -455,6 +459,7 @@
"sha256:d3640ea61ee025d5af00e3ffd82ba0a06dd99724adaf50bdd52f49daf29f3f65", "sha256:d3640ea61ee025d5af00e3ffd82ba0a06dd99724adaf50bdd52f49daf29f3f65",
"sha256:da5218cbf33b8461d7661d6b4ad91c12c0107e2767904d5e3ae6408031d5463e" "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" "version": "==1.27.0"
}, },
"gunicorn": { "gunicorn": {
@ -470,6 +475,7 @@
"sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6",
"sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"
], ],
"markers": "python_version >= '3.6'",
"version": "==0.12.0" "version": "==0.12.0"
}, },
"hiredis": { "hiredis": {
@ -521,6 +527,7 @@
"sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390", "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390",
"sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298" "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.0" "version": "==1.1.0"
}, },
"httptools": { "httptools": {
@ -566,6 +573,7 @@
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
"sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
], ],
"markers": "python_version >= '3.5'",
"version": "==0.5.1" "version": "==0.5.1"
}, },
"itypes": { "itypes": {
@ -580,6 +588,7 @@
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" "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" "version": "==2.11.3"
}, },
"jmespath": { "jmespath": {
@ -587,6 +596,7 @@
"sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9",
"sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.0" "version": "==0.10.0"
}, },
"jsonschema": { "jsonschema": {
@ -601,6 +611,7 @@
"sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006", "sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006",
"sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c" "sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c"
], ],
"markers": "python_version >= '3.6'",
"version": "==5.0.2" "version": "==5.0.2"
}, },
"kubernetes": { "kubernetes": {
@ -613,8 +624,11 @@
}, },
"ldap3": { "ldap3": {
"hashes": [ "hashes": [
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57",
"sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59",
"sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91", "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91",
"sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57" "sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c",
"sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.9" "version": "==2.9"
@ -717,12 +731,14 @@
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"maxminddb": { "maxminddb": {
"hashes": [ "hashes": [
"sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc"
], ],
"markers": "python_version >= '3.6'",
"version": "==2.0.3" "version": "==2.0.3"
}, },
"msgpack": { "msgpack": {
@ -798,6 +814,7 @@
"sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281",
"sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"
], ],
"markers": "python_version >= '3.6'",
"version": "==5.1.0" "version": "==5.1.0"
}, },
"oauthlib": { "oauthlib": {
@ -805,6 +822,7 @@
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.1.0" "version": "==3.1.0"
}, },
"packaging": { "packaging": {
@ -827,6 +845,7 @@
"sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974", "sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974",
"sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd" "sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd"
], ],
"markers": "python_full_version >= '3.6.1'",
"version": "==3.0.16" "version": "==3.0.16"
}, },
"psycopg2-binary": { "psycopg2-binary": {
@ -872,15 +891,37 @@
}, },
"pyasn1": { "pyasn1": {
"hashes": [ "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:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"
], ],
"version": "==0.4.8" "version": "==0.4.8"
}, },
"pyasn1-modules": { "pyasn1-modules": {
"hashes": [ "hashes": [
"sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb",
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74" "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" "version": "==0.2.8"
}, },
@ -889,6 +930,7 @@
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.20" "version": "==2.20"
}, },
"pycryptodome": { "pycryptodome": {
@ -960,6 +1002,7 @@
"sha256:f933ecf4cb736c7af60a6a533db2bf569717f2318b265f92907acff1db43bc34", "sha256:f933ecf4cb736c7af60a6a533db2bf569717f2318b265f92907acff1db43bc34",
"sha256:fc9c55dc1ed57db76595f2d19a479fc1c3a1be2c9da8de798a93d286c5f65f38" "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" "version": "==3.10.1"
}, },
"pyhamcrest": { "pyhamcrest": {
@ -967,6 +1010,7 @@
"sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316",
"sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29"
], ],
"markers": "python_version >= '3.5'",
"version": "==2.0.2" "version": "==2.0.2"
}, },
"pyjwkest": { "pyjwkest": {
@ -988,12 +1032,14 @@
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7" "version": "==2.4.7"
}, },
"pyrsistent": { "pyrsistent": {
"hashes": [ "hashes": [
"sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"
], ],
"markers": "python_version >= '3.5'",
"version": "==0.17.3" "version": "==0.17.3"
}, },
"python-dateutil": { "python-dateutil": {
@ -1001,6 +1047,7 @@
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.1" "version": "==2.8.1"
}, },
"python-dotenv": { "python-dotenv": {
@ -1044,19 +1091,12 @@
"index": "pypi", "index": "pypi",
"version": "==5.4.1" "version": "==5.4.1"
}, },
"qrcode": {
"hashes": [
"sha256:3996ee560fc39532910603704c82980ff6d4d5d629f9c3f25f34174ce8606cf5",
"sha256:505253854f607f2abf4d16092c61d4e9d511a3b4392e60bff957a68592b04369"
],
"index": "pypi",
"version": "==6.1"
},
"redis": { "redis": {
"hashes": [ "hashes": [
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" "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" "version": "==3.5.3"
}, },
"requests": { "requests": {
@ -1064,10 +1104,12 @@
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" "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" "version": "==2.25.1"
}, },
"requests-oauthlib": { "requests-oauthlib": {
"hashes": [ "hashes": [
"sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc",
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a" "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
], ],
@ -1117,6 +1159,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0" "version": "==1.15.0"
}, },
"sqlparse": { "sqlparse": {
@ -1124,6 +1167,7 @@
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
], ],
"markers": "python_version >= '3.5'",
"version": "==0.4.1" "version": "==0.4.1"
}, },
"structlog": { "structlog": {
@ -1171,6 +1215,7 @@
"sha256:f058bd0168271de4dcdc39845b52dd0a4a2fecf5f1246335f13f5e96eaebb467", "sha256:f058bd0168271de4dcdc39845b52dd0a4a2fecf5f1246335f13f5e96eaebb467",
"sha256:f3c19e5bd42bbe4bf345704ad7c326c74d3fd7a1b3844987853bef180be638d4" "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" "version": "==20.3.0"
}, },
"txaio": { "txaio": {
@ -1178,6 +1223,7 @@
"sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8", "sha256:7d6f89745680233f1c4db9ddb748df5e88d2a7a37962be174c0fd04c8dba1dc8",
"sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb" "sha256:c16b55f9a67b2419cfdf8846576e2ec9ba94fe6978a83080c352a80db31c93fb"
], ],
"markers": "python_version >= '3.6'",
"version": "==21.2.1" "version": "==21.2.1"
}, },
"typing-extensions": { "typing-extensions": {
@ -1193,6 +1239,7 @@
"sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f",
"sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.1" "version": "==3.0.1"
}, },
"urllib3": { "urllib3": {
@ -1237,6 +1284,7 @@
"sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30",
"sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"
], ],
"markers": "python_version >= '3.6'",
"version": "==5.0.0" "version": "==5.0.0"
}, },
"watchgod": { "watchgod": {
@ -1354,6 +1402,7 @@
"sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a",
"sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"
], ],
"markers": "python_version >= '3.6'",
"version": "==1.6.3" "version": "==1.6.3"
}, },
"zope.interface": { "zope.interface": {
@ -1411,6 +1460,7 @@
"sha256:f37d45fab14ffef9d33a0dc3bc59ce0c5313e2253323312d47739192da94f5fd", "sha256:f37d45fab14ffef9d33a0dc3bc59ce0c5313e2253323312d47739192da94f5fd",
"sha256:f44906f70205d456d503105023041f1e63aece7623b31c390a0103db4de17537" "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" "version": "==5.2.0"
} }
}, },
@ -1422,18 +1472,12 @@
], ],
"version": "==1.4.4" "version": "==1.4.4"
}, },
"asgiref": {
"hashes": [
"sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17",
"sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"
],
"version": "==3.3.1"
},
"astroid": { "astroid": {
"hashes": [ "hashes": [
"sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1", "sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1",
"sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38" "sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38"
], ],
"markers": "python_version >= '3.5'",
"version": "==2.4.1" "version": "==2.4.1"
}, },
"attrs": { "attrs": {
@ -1441,6 +1485,7 @@
"sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
"sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.3.0" "version": "==20.3.0"
}, },
"autopep8": { "autopep8": {
@ -1471,6 +1516,7 @@
"sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410",
"sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6" "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"
], ],
"markers": "python_version >= '3.5'",
"version": "==1.0.1" "version": "==1.0.1"
}, },
"bumpversion": { "bumpversion": {
@ -1486,6 +1532,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" "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" "version": "==7.1.2"
}, },
"colorama": { "colorama": {
@ -1551,22 +1598,6 @@
"index": "pypi", "index": "pypi",
"version": "==5.4" "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": { "dodgy": {
"hashes": [ "hashes": [
"sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a",
@ -1579,6 +1610,7 @@
"sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839", "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
"sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b" "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.8.4" "version": "==3.8.4"
}, },
"flake8-polyfill": { "flake8-polyfill": {
@ -1593,6 +1625,7 @@
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
"sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"
], ],
"markers": "python_version >= '3.4'",
"version": "==4.0.5" "version": "==4.0.5"
}, },
"gitpython": { "gitpython": {
@ -1600,6 +1633,7 @@
"sha256:8621a7e777e276a5ec838b59280ba5272dd144a18169c36c903d8b38b99f750a", "sha256:8621a7e777e276a5ec838b59280ba5272dd144a18169c36c903d8b38b99f750a",
"sha256:c5347c81d232d9b8e7f47b68a83e5dc92e7952127133c5f2df9133f2c75a1b29" "sha256:c5347c81d232d9b8e7f47b68a83e5dc92e7952127133c5f2df9133f2c75a1b29"
], ],
"markers": "python_version >= '3.4'",
"version": "==3.1.13" "version": "==3.1.13"
}, },
"iniconfig": { "iniconfig": {
@ -1614,6 +1648,7 @@
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
"sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==4.3.21" "version": "==4.3.21"
}, },
"lazy-object-proxy": { "lazy-object-proxy": {
@ -1640,6 +1675,7 @@
"sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
"sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.4.3" "version": "==1.4.3"
}, },
"mccabe": { "mccabe": {
@ -1676,6 +1712,7 @@
"sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9",
"sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"
], ],
"markers": "python_version >= '2.6'",
"version": "==5.5.1" "version": "==5.5.1"
}, },
"pep8-naming": { "pep8-naming": {
@ -1690,6 +1727,7 @@
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.13.1" "version": "==0.13.1"
}, },
"prospector": { "prospector": {
@ -1704,6 +1742,7 @@
"sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3",
"sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.10.0" "version": "==1.10.0"
}, },
"pycodestyle": { "pycodestyle": {
@ -1711,6 +1750,7 @@
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.6.0" "version": "==2.6.0"
}, },
"pydocstyle": { "pydocstyle": {
@ -1718,6 +1758,7 @@
"sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
"sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
], ],
"markers": "python_version >= '3.5'",
"version": "==5.1.1" "version": "==5.1.1"
}, },
"pyflakes": { "pyflakes": {
@ -1725,6 +1766,7 @@
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.2.0" "version": "==2.2.0"
}, },
"pylint": { "pylint": {
@ -1767,6 +1809,7 @@
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7" "version": "==2.4.7"
}, },
"pytest": { "pytest": {
@ -1785,13 +1828,6 @@
"index": "pypi", "index": "pypi",
"version": "==4.1.0" "version": "==4.1.0"
}, },
"pytz": {
"hashes": [
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
],
"version": "==2021.1"
},
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
@ -1890,6 +1926,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0" "version": "==1.15.0"
}, },
"smmap": { "smmap": {
@ -1897,6 +1934,7 @@
"sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714", "sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714",
"sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50" "sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.5" "version": "==3.0.5"
}, },
"snowballstemmer": { "snowballstemmer": {
@ -1906,18 +1944,12 @@
], ],
"version": "==2.1.0" "version": "==2.1.0"
}, },
"sqlparse": {
"hashes": [
"sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0",
"sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"
],
"version": "==0.4.1"
},
"stevedore": { "stevedore": {
"hashes": [ "hashes": [
"sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee",
"sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"
], ],
"markers": "python_version >= '3.6'",
"version": "==3.3.0" "version": "==3.3.0"
}, },
"toml": { "toml": {
@ -1925,6 +1957,7 @@
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2" "version": "==0.10.2"
}, },
"typed-ast": { "typed-ast": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
"""authentik core models""" """authentik core models"""
from datetime import timedelta from datetime import timedelta
from hashlib import sha256 from hashlib import md5, sha256
from typing import Any, Optional, Type from typing import Any, Optional, Type
from urllib.parse import urlencode
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
@ -11,7 +12,9 @@ from django.db import models
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django.forms import ModelForm from django.forms import ModelForm
from django.http import HttpRequest from django.http import HttpRequest
from django.templatetags.static import static
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from guardian.mixins import GuardianUserMixin 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.signals import password_changed
from authentik.core.types import UILoginButton from authentik.core.types import UILoginButton
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.managed.models import ManagedModel from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
@ -31,6 +35,9 @@ LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" 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(): def default_token_duration():
"""Default duration a Token is valid""" """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""" """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() 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: class Meta:
permissions = ( permissions = (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,27 +6,15 @@
{% block head %} {% block head %}
{{ block.super }} {{ block.super }}
<style> <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 { .pf-c-background-image::before {
background-image: url("{{ background_url }}"); background-image: url("{{ background_url }}");
background-position: center; background-position: center;
} }
</style> </style>
<script src="{% static 'dist/flow.js' %}?v={{ ak_version }}" type="module"></script>
{% endblock %} {% endblock %}
{% block main_container %} {% block main_container %}
<ak-flow-shell-card <ak-flow-executor class="pf-c-login__main" flowSlug="{{ flow_slug }}">
class="pf-c-login__main" </ak-flow-executor>
flowBodyUrl="{{ exec_url }}">
</ak-flow-shell-card>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +1,15 @@
"""authentik lib Templatetags""" """authentik lib Templatetags"""
from hashlib import md5
from urllib.parse import urlencode
from django import template from django import template
from django.db.models import Model from django.db.models import Model
from django.http.request import HttpRequest
from django.template import Context 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 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 from authentik.lib.utils.urls import is_url_absolute
register = template.Library() register = template.Library()
LOGGER = get_logger() LOGGER = get_logger()
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("authentik/user_default.png")
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def back(context: Context) -> str: def back(context: Context) -> str:
@ -46,38 +36,12 @@ def fieldtype(field):
return field.__class__.__name__ 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") @register.filter(name="css_class")
def css_class(field, css): def css_class(field, css):
"""Add css class to form field""" """Add css class to form field"""
return field.as_widget(attrs={"class": css}) 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 @register.filter
def verbose_name(obj) -> str: def verbose_name(obj) -> str:
"""Return Object's Verbose Name""" """Return Object's Verbose Name"""
@ -94,21 +58,3 @@ def form_verbose_name(obj) -> str:
if not obj: if not obj:
return "" return ""
return verbose_name(obj._meta.model) return verbose_name(obj._meta.model)
@register.filter
def doc(obj) -> str:
"""Return docstring of object"""
return mark_safe(obj.__doc__.replace("\n", "<br>"))
@register.simple_tag(takes_context=True)
def query_transform(context: Context, **kwargs) -> str:
"""Append objects to the current querystring"""
if "request" not in context:
return ""
request: HttpRequest = context["request"]
updated = request.GET.copy()
for key, value in kwargs.items():
updated[key] = value
return updated.urlencode()

View File

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

View File

@ -2,7 +2,7 @@
from dataclasses import asdict from dataclasses import asdict
from django.db.models.base import Model 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 drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, SerializerMethodField from rest_framework.fields import BooleanField, CharField, SerializerMethodField

View File

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

View File

@ -1,7 +1,7 @@
"""policy API Views""" """policy API Views"""
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist 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 drf_yasg2.utils import swagger_auto_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.request import Request from rest_framework.request import Request

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,17 @@
"""authentik SAML IDP Views""" """authentik SAML IDP Views"""
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.http import HttpRequest, HttpResponse 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.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 structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import Event, EventAction 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.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.lib.views import bad_request_message
from authentik.providers.saml.models import SAMLBindings, SAMLProvider from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.assertion import AssertionProcessor from authentik.providers.saml.processors.assertion import AssertionProcessor
@ -27,10 +29,17 @@ REQUEST_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_AUTH_N_REQUEST = "authn_request" 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 # 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, """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).""" (if POST is configured)."""
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
@ -62,12 +71,13 @@ class SAMLFlowFinalView(StageView):
} }
if auth_n_request.relay_state: if auth_n_request.relay_state:
form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state
return render( return super().get(
self.request, 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, "url": provider.acs_url,
"title": _("Redirecting to %(app)s..." % {"app": application.name}),
"attrs": form_attrs, "attrs": form_attrs,
}, },
) )
@ -80,3 +90,10 @@ class SAMLFlowFinalView(StageView):
querystring = urlencode(url_args) querystring = urlencode(url_args)
return redirect(f"{provider.acs_url}?{querystring}") return redirect(f"{provider.acs_url}?{querystring}")
return bad_request_message(request, "Invalid sp_binding specified") return bad_request_message(request, "Invalid sp_binding specified")
def get_challenge(self, *args, **kwargs) -> Challenge:
return AutosubmitChallenge(data=kwargs)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# We'll never get here since the challenge redirects to the SP
return HttpResponseBadRequest()

View File

@ -4,6 +4,7 @@ from typing import Optional
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator 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.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -31,7 +32,10 @@ from authentik.providers.saml.views.flows import (
SESSION_KEY_AUTH_N_REQUEST, SESSION_KEY_AUTH_N_REQUEST,
SAMLFlowFinalView, 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() LOGGER = get_logger()
@ -68,7 +72,11 @@ class SAMLSSOView(PolicyAccessView):
{ {
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: self.application, 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)) plan.append(in_memory_stage(SAMLFlowFinalView))

View File

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

View File

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

View File

@ -108,22 +108,22 @@ INSTALLED_APPS = [
"authentik.sources.ldap.apps.AuthentikSourceLDAPConfig", "authentik.sources.ldap.apps.AuthentikSourceLDAPConfig",
"authentik.sources.oauth.apps.AuthentikSourceOAuthConfig", "authentik.sources.oauth.apps.AuthentikSourceOAuthConfig",
"authentik.sources.saml.apps.AuthentikSourceSAMLConfig", "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_static.apps.AuthentikStageAuthenticatorStaticConfig",
"authentik.stages.authenticator_totp.apps.AuthentikStageAuthenticatorTOTPConfig", "authentik.stages.authenticator_totp.apps.AuthentikStageAuthenticatorTOTPConfig",
"authentik.stages.authenticator_validate.apps.AuthentikStageAuthenticatorValidateConfig", "authentik.stages.authenticator_validate.apps.AuthentikStageAuthenticatorValidateConfig",
"authentik.stages.authenticator_webauthn.apps.AuthentikStageAuthenticatorWebAuthnConfig", "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.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", "rest_framework",
"django_filters", "django_filters",
"drf_yasg2", "drf_yasg2",
@ -470,8 +470,6 @@ for _app in INSTALLED_APPS:
pass pass
if DEBUG: if DEBUG:
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True
INSTALLED_APPS.append("authentik.core.apps.AuthentikCoreConfig") INSTALLED_APPS.append("authentik.core.apps.AuthentikCoreConfig")

View File

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

View File

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

View File

@ -3,7 +3,8 @@ from typing import Optional, Type
from django.db import models from django.db import models
from django.forms import ModelForm 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 django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
@ -56,11 +57,11 @@ class OAuthSource(Source):
@property @property
def ui_login_button(self) -> UILoginButton: def ui_login_button(self) -> UILoginButton:
return UILoginButton( return UILoginButton(
url=reverse_lazy( url=reverse(
"authentik_sources_oauth:oauth-client-login", "authentik_sources_oauth:oauth-client-login",
kwargs={"source_slug": self.slug}, 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, name=self.name,
) )

View File

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

View File

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

View File

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

View File

@ -1,31 +1,9 @@
"""Static Authenticator forms""" """Static Authenticator forms"""
from django import forms from django import forms
from django.utils.safestring import mark_safe
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage 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): class AuthenticatorStaticStageForm(forms.ModelForm):
"""Static Authenticator Stage setup form""" """Static Authenticator Stage setup form"""

View File

@ -3,7 +3,7 @@ from typing import Optional, Type
from django.db import models from django.db import models
from django.forms import ModelForm 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.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer

View File

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

View File

@ -1,54 +1,9 @@
"""OTP Time forms""" """OTP Time forms"""
from django import 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 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): class AuthenticatorTOTPStageForm(forms.ModelForm):
"""OTP Time-based Stage setup form""" """OTP Time-based Stage setup form"""

View File

@ -3,7 +3,7 @@ from typing import Optional, Type
from django.db import models from django.db import models
from django.forms import ModelForm 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.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer

View File

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

View File

@ -29,7 +29,8 @@ class UserSettingsView(LoginRequiredMixin, TemplateView):
class DisableView(LoginRequiredMixin, View): class DisableView(LoginRequiredMixin, View):
"""Disable TOTP for user""" """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""" """Delete all the devices for user"""
totp = TOTPDevice.objects.filter(user=request.user, confirmed=True) totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
totp.delete() totp.delete()

View File

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

View File

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

View File

@ -1,46 +1,153 @@
"""OTP Validation""" """Authenticator Validation"""
from typing import Any
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.views.generic import FormView from django_otp import devices_for_user
from django_otp import user_has_device from rest_framework.fields import CharField, JSONField, ListField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.flows.challenge import (
ChallengeResponse,
ChallengeTypes,
WithUserInfoChallenge,
)
from authentik.flows.models import NotConfiguredAction from authentik.flows.models import NotConfiguredAction
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER 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_validate.forms import ValidationForm from authentik.stages.authenticator_validate.challenge import (
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage DeviceChallenge,
get_challenge_for_device,
validate_challenge_code,
validate_challenge_webauthn,
)
from authentik.stages.authenticator_validate.models import (
AuthenticatorValidateStage,
DeviceClasses,
)
LOGGER = get_logger() 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]: device_challenges = ListField(child=DeviceChallenge())
kwargs = super().get_form_kwargs(**kwargs)
kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
return kwargs 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: 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) user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user: if not user:
LOGGER.debug("No pending user, continuing") LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok() return self.executor.stage_ok()
has_devices = user_has_device(user)
stage: AuthenticatorValidateStage = self.executor.current_stage 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: if stage.not_configured_action == NotConfiguredAction.SKIP:
LOGGER.debug("Authenticator not configured, skipping stage") LOGGER.debug("Authenticator not configured, skipping stage")
return self.executor.stage_ok() 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) return super().get(request, *args, **kwargs)
def form_valid(self, form: ValidationForm) -> HttpResponse: def get_challenge(self) -> AuthenticatorChallenge:
"""Verify OTP Token""" challenges = self.request.session["device_challenges"]
# Since we do token checking in the form, we know the token is valid here return AuthenticatorChallenge(
# so we can just continue 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() return self.executor.stage_ok()

View File

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

View File

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

View File

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

View File

@ -1,13 +1,29 @@
"""WebAuthn stage""" """WebAuthn stage"""
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.http.request import QueryDict
from django.views.generic import FormView from rest_framework.fields import JSONField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger 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.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.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import (
generate_challenge,
get_origin,
get_rp_id,
)
RP_NAME = "authentik"
LOGGER = get_logger() 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""" """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: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user: if not user:
LOGGER.debug("No pending user, continuing") LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok() return self.executor.stage_ok()
devices = WebAuthnDevice.objects.filter(user=user) return super().get(request, *args, **kwargs)
# If the current user is logged in already, or the pending user
# has no devices, show setup
if self.request.user == user:
# Because the user is already authenticated, skip the later check
self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True
return render(request, "stages/authenticator_webauthn/setup.html")
if not devices.exists():
return self.executor.stage_ok()
self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = False
return render(request, "stages/authenticator_webauthn/auth.html")
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get_response_instance(
"""Since the client can't directly indicate when a stage is done, self, data: QueryDict
we use the post handler for this""" ) -> AuthenticatorWebAuthnChallengeResponse:
if request.session.pop(SESSION_KEY_WEBAUTHN_AUTHENTICATED, False): response: AuthenticatorWebAuthnChallengeResponse = (
return self.executor.stage_ok() super().get_response_instance(data)
return self.executor.stage_invalid() )
response.request = self.request
response.user = self.get_pending_user()
return response
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# Webauthn Challenge has already been validated
webauthn_credential: WebAuthnCredential = response.validated_data["response"]
existing_device = WebAuthnDevice.objects.filter(
credential_id=webauthn_credential.credential_id
).first()
if not existing_device:
WebAuthnDevice.objects.create(
user=self.get_pending_user(),
public_key=webauthn_credential.public_key,
credential_id=webauthn_credential.credential_id,
sign_count=webauthn_credential.sign_count,
rp_id=get_rp_id(self.request),
)
else:
return self.executor.stage_invalid(
"Device with Credential ID already exists."
)
return self.executor.stage_ok()

View File

@ -1,37 +1,9 @@
"""WebAuthn urls""" """WebAuthn urls"""
from django.urls import path from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from authentik.stages.authenticator_webauthn.views import ( from authentik.stages.authenticator_webauthn.views import UserSettingsView
BeginActivateView,
BeginAssertion,
UserSettingsView,
VerifyAssertion,
VerifyCredentialInfo,
)
# TODO: Move to API views so we don't need csrf_exempt
urlpatterns = [ 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( path(
"<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings" "<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings"
), ),

View File

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

View File

@ -1,229 +1,12 @@
"""webauthn views""" """webauthn views"""
from django.contrib.auth.mixins import LoginRequiredMixin 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.shortcuts import get_object_or_404
from django.views import View
from django.views.generic import TemplateView 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 ( from authentik.stages.authenticator_webauthn.models import (
AuthenticateWebAuthnStage, AuthenticateWebAuthnStage,
WebAuthnDevice, 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): class UserSettingsView(LoginRequiredMixin, TemplateView):

View File

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

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