From 8708e487aebb0f131ee50af3a5088da927457941 Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 17 Feb 2021 20:49:58 +0100 Subject: [PATCH] stages: add WebAuthn stage (#550) * core: add User.uid for globally unique user ID * admin: fix ?next for Flow list * stages: add initial webauthn implementation * web: add ak-flow-submit event to submit flow stage * web: show error message for webauthn registration * admin: fix next param not redirecting correctly * stages/webauthn: remove form * stages/webauthn: add API * web: update flow diagram on ak-refresh * stages/webauthn: add initial authentication * stages/webauthn: initial authentication implementation * web: cleanup webauthn utils * stages: rename otp_* to authenticator and move webauthn to authenticator * docs: fix broken links * stages/authenticator_*: fix template paths * stages/authenticator_validate: add device classes * stages/authenticator_webauthn: implement django_otp.devices * stages/authenticator_*: update default stage names * web: add button to create stage on flow page * web: don't minify HTML, remove nbsp * admin: fix typo in stage list * stages/*: use common base class for stage serializer * stages/authenticator_*: create default objects after rename * tests/e2e: adjust stage order --- Pipfile | 1 + Pipfile.lock | 114 +- .../templates/administration/stage/list.html | 2 +- authentik/api/v2/urls.py | 16 +- authentik/flows/api.py | 35 +- .../flows/migrations/0008_default_flows.py | 6 +- authentik/flows/tests/test_api.py | 2 +- authentik/flows/transfer/common.py | 17 +- authentik/flows/transfer/exporter.py | 3 +- .../migrations/0003_auto_20210110_1907.py | 7 +- .../migrations/0004_auto_20210112_2158.py | 10 +- .../migrations/0006_auto_20210203_1134.py | 10 +- .../migrations/0008_auto_20210213_1640.py | 83 ++ .../migrations/0009_auto_20210215_2159.py | 86 ++ authentik/root/settings.py | 7 +- .../__init__.py | 0 authentik/stages/authenticator_static/api.py | 21 + authentik/stages/authenticator_static/apps.py | 11 + .../forms.py | 10 +- .../migrations/0001_initial.py | 4 +- .../0002_otpstaticstage_configure_flow.py | 2 +- .../migrations/0003_default_setup_flow.py | 15 + .../migrations/0004_auto_20210216_0838.py | 25 + .../migrations/0005_default_setup_flow.py | 55 + .../migrations/__init__.py | 0 .../stages/authenticator_static/models.py | 56 + .../settings.py | 2 +- .../stage.py | 8 +- .../authenticator_static}/user_settings.html | 4 +- .../urls.py | 4 +- .../views.py | 10 +- .../__init__.py | 0 authentik/stages/authenticator_totp/api.py | 21 + authentik/stages/authenticator_totp/apps.py | 11 + .../{otp_time => authenticator_totp}/forms.py | 6 +- .../migrations/0001_initial.py | 0 .../migrations/0002_auto_20200701_1900.py | 2 +- .../0003_otptimestage_configure_flow.py | 2 +- .../migrations/0004_default_setup_flow.py | 15 + .../migrations/0005_auto_20210216_0838.py | 25 + .../migrations/0006_default_setup_flow.py} | 23 +- .../migrations/__init__.py | 0 .../models.py | 24 +- .../settings.py | 0 .../{otp_time => authenticator_totp}/stage.py | 8 +- .../authenticator_totp}/user_settings.html | 4 +- .../{otp_time => authenticator_totp}/urls.py | 2 +- .../{otp_time => authenticator_totp}/views.py | 6 +- .../__init__.py | 0 .../stages/authenticator_validate/api.py | 24 + .../stages/authenticator_validate/apps.py | 10 + .../forms.py | 12 +- .../migrations/0001_initial.py | 0 .../migrations/0002_auto_20210216_0838.py | 25 + ...thenticatorvalidatestage_device_classes.py | 26 + .../migrations/__init__.py | 0 .../stages/authenticator_validate/models.py | 74 + .../settings.py | 0 .../stage.py | 10 +- .../stages/authenticator_webauthn/__init__.py | 0 .../stages/authenticator_webauthn/api.py | 21 + .../stages/authenticator_webauthn/apps.py | 11 + .../stages/authenticator_webauthn/forms.py | 17 + .../migrations/0001_initial.py | 81 + .../migrations/0002_default_setup_flow.py} | 21 +- .../migrations/__init__.py | 0 .../stages/authenticator_webauthn/models.py | 80 + .../stages/authenticator_webauthn/stage.py | 44 + .../stages/authenticator_webauthn/auth.html | 15 + .../stages/authenticator_webauthn/setup.html | 16 + .../authenticator_webauthn/user_settings.html | 33 + .../stages/authenticator_webauthn/urls.py | 38 + .../stages/authenticator_webauthn/utils.py | 23 + .../stages/authenticator_webauthn/views.py | 241 +++ authentik/stages/captcha/api.py | 6 +- authentik/stages/consent/api.py | 6 +- authentik/stages/dummy/api.py | 6 +- authentik/stages/email/api.py | 12 +- authentik/stages/identification/api.py | 8 +- authentik/stages/invitation/api.py | 7 +- authentik/stages/otp_static/api.py | 21 - authentik/stages/otp_static/apps.py | 11 - authentik/stages/otp_static/models.py | 50 - authentik/stages/otp_time/api.py | 21 - authentik/stages/otp_time/apps.py | 11 - authentik/stages/otp_validate/api.py | 24 - authentik/stages/otp_validate/apps.py | 10 - authentik/stages/otp_validate/models.py | 44 - authentik/stages/password/api.py | 8 +- authentik/stages/prompt/api.py | 7 +- authentik/stages/user_delete/api.py | 9 +- authentik/stages/user_login/api.py | 8 +- authentik/stages/user_logout/api.py | 9 +- authentik/stages/user_write/api.py | 9 +- .../to_2021_3_authenticator.py | 29 + swagger.yaml | 1301 +++++++++++------ ...ws_otp.py => test_flows_authenticators.py} | 22 +- web/package-lock.json | 5 + web/package.json | 1 + web/rollup.config.js | 2 - web/src/api/Client.ts | 10 +- web/src/api/Flows.ts | 9 + web/src/authentik.css | 6 + .../elements/policies/BoundPoliciesList.ts | 2 +- .../authenticator_webauthn/WebAuthnAuth.ts | 106 ++ .../WebAuthnRegister.ts | 113 ++ .../stages/authenticator_webauthn/utils.ts | 201 +++ web/src/elements/table/Table.ts | 8 +- web/src/main.ts | 3 + .../pages/applications/ApplicationListPage.ts | 2 +- .../crypto/CertificateKeyPairListPage.ts | 4 +- web/src/pages/events/RuleListPage.ts | 2 +- web/src/pages/events/TransportListPage.ts | 4 +- web/src/pages/flows/BoundStagesList.ts | 25 +- web/src/pages/flows/FlowListPage.ts | 8 +- web/src/pages/generic/FlowShellCard.ts | 27 + web/src/pages/outposts/OutpostHealth.ts | 8 +- web/src/pages/outposts/OutpostListPage.ts | 4 +- .../PropertyMappingListPage.ts | 4 +- web/src/pages/providers/ProviderListPage.ts | 2 +- .../pages/providers/ProxyProviderViewPage.ts | 4 +- web/src/pages/sources/SourcesListPage.ts | 2 +- .../index.md | 2 +- .../{otp_time => authenticator_totp}/index.md | 2 +- .../stages/authenticator_validate/index.md | 8 + .../docs/flow/stages/otp_validation/index.md | 5 - website/sidebars.js | 6 +- website/static/flows/login-2fa.pbflow | 2 +- 128 files changed, 2949 insertions(+), 874 deletions(-) create mode 100644 authentik/policies/event_matcher/migrations/0008_auto_20210213_1640.py create mode 100644 authentik/policies/event_matcher/migrations/0009_auto_20210215_2159.py rename authentik/stages/{otp_static => authenticator_static}/__init__.py (100%) create mode 100644 authentik/stages/authenticator_static/api.py create mode 100644 authentik/stages/authenticator_static/apps.py rename authentik/stages/{otp_static => authenticator_static}/forms.py (77%) rename authentik/stages/{otp_static => authenticator_static}/migrations/0001_initial.py (87%) rename authentik/stages/{otp_static => authenticator_static}/migrations/0002_otpstaticstage_configure_flow.py (91%) create mode 100644 authentik/stages/authenticator_static/migrations/0003_default_setup_flow.py create mode 100644 authentik/stages/authenticator_static/migrations/0004_auto_20210216_0838.py create mode 100644 authentik/stages/authenticator_static/migrations/0005_default_setup_flow.py rename authentik/stages/{otp_static => authenticator_static}/migrations/__init__.py (100%) create mode 100644 authentik/stages/authenticator_static/models.py rename authentik/stages/{otp_static => authenticator_static}/settings.py (62%) rename authentik/stages/{otp_static => authenticator_static}/stage.py (89%) rename authentik/stages/{otp_static/templates/stages/otp_static => authenticator_static/templates/stages/authenticator_static}/user_settings.html (72%) rename authentik/stages/{otp_static => authenticator_static}/urls.py (66%) rename authentik/stages/{otp_static => authenticator_static}/views.py (83%) rename authentik/stages/{otp_time => authenticator_totp}/__init__.py (100%) create mode 100644 authentik/stages/authenticator_totp/api.py create mode 100644 authentik/stages/authenticator_totp/apps.py rename authentik/stages/{otp_time => authenticator_totp}/forms.py (90%) rename authentik/stages/{otp_time => authenticator_totp}/migrations/0001_initial.py (100%) rename authentik/stages/{otp_time => authenticator_totp}/migrations/0002_auto_20200701_1900.py (89%) rename authentik/stages/{otp_time => authenticator_totp}/migrations/0003_otptimestage_configure_flow.py (90%) create mode 100644 authentik/stages/authenticator_totp/migrations/0004_default_setup_flow.py create mode 100644 authentik/stages/authenticator_totp/migrations/0005_auto_20210216_0838.py rename authentik/stages/{otp_time/migrations/0004_default_setup_flow.py => authenticator_totp/migrations/0006_default_setup_flow.py} (60%) rename authentik/stages/{otp_time => authenticator_totp}/migrations/__init__.py (100%) rename authentik/stages/{otp_time => authenticator_totp}/models.py (58%) rename authentik/stages/{otp_time => authenticator_totp}/settings.py (100%) rename authentik/stages/{otp_time => authenticator_totp}/stage.py (89%) rename authentik/stages/{otp_time/templates/stages/otp_time => authenticator_totp/templates/stages/authenticator_totp}/user_settings.html (69%) rename authentik/stages/{otp_time => authenticator_totp}/urls.py (75%) rename authentik/stages/{otp_time => authenticator_totp}/views.py (85%) rename authentik/stages/{otp_validate => authenticator_validate}/__init__.py (100%) create mode 100644 authentik/stages/authenticator_validate/api.py create mode 100644 authentik/stages/authenticator_validate/apps.py rename authentik/stages/{otp_validate => authenticator_validate}/forms.py (76%) rename authentik/stages/{otp_validate => authenticator_validate}/migrations/0001_initial.py (100%) create mode 100644 authentik/stages/authenticator_validate/migrations/0002_auto_20210216_0838.py create mode 100644 authentik/stages/authenticator_validate/migrations/0003_authenticatorvalidatestage_device_classes.py rename authentik/stages/{otp_validate => authenticator_validate}/migrations/__init__.py (100%) create mode 100644 authentik/stages/authenticator_validate/models.py rename authentik/stages/{otp_validate => authenticator_validate}/settings.py (100%) rename authentik/stages/{otp_validate => authenticator_validate}/stage.py (79%) create mode 100644 authentik/stages/authenticator_webauthn/__init__.py create mode 100644 authentik/stages/authenticator_webauthn/api.py create mode 100644 authentik/stages/authenticator_webauthn/apps.py create mode 100644 authentik/stages/authenticator_webauthn/forms.py create mode 100644 authentik/stages/authenticator_webauthn/migrations/0001_initial.py rename authentik/stages/{otp_static/migrations/0003_default_setup_flow.py => authenticator_webauthn/migrations/0002_default_setup_flow.py} (63%) create mode 100644 authentik/stages/authenticator_webauthn/migrations/__init__.py create mode 100644 authentik/stages/authenticator_webauthn/models.py create mode 100644 authentik/stages/authenticator_webauthn/stage.py create mode 100644 authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/auth.html create mode 100644 authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/setup.html create mode 100644 authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html create mode 100644 authentik/stages/authenticator_webauthn/urls.py create mode 100644 authentik/stages/authenticator_webauthn/utils.py create mode 100644 authentik/stages/authenticator_webauthn/views.py delete mode 100644 authentik/stages/otp_static/api.py delete mode 100644 authentik/stages/otp_static/apps.py delete mode 100644 authentik/stages/otp_static/models.py delete mode 100644 authentik/stages/otp_time/api.py delete mode 100644 authentik/stages/otp_time/apps.py delete mode 100644 authentik/stages/otp_validate/api.py delete mode 100644 authentik/stages/otp_validate/apps.py delete mode 100644 authentik/stages/otp_validate/models.py create mode 100644 lifecycle/system_migrations/to_2021_3_authenticator.py rename tests/e2e/{test_flows_otp.py => test_flows_authenticators.py} (88%) create mode 100644 web/src/elements/stages/authenticator_webauthn/WebAuthnAuth.ts create mode 100644 web/src/elements/stages/authenticator_webauthn/WebAuthnRegister.ts create mode 100644 web/src/elements/stages/authenticator_webauthn/utils.ts rename website/docs/flow/stages/{otp_static => authenticator_static}/index.md (83%) rename website/docs/flow/stages/{otp_time => authenticator_totp}/index.md (88%) create mode 100644 website/docs/flow/stages/authenticator_validate/index.md delete mode 100644 website/docs/flow/stages/otp_validation/index.md diff --git a/Pipfile b/Pipfile index 3e3da956e..7dcb084a0 100644 --- a/Pipfile +++ b/Pipfile @@ -45,6 +45,7 @@ kubernetes = "*" docker = "*" xmlsec = "*" geoip2 = "*" +webauthn = "*" [requires] python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index 08301bf4f..2ec45f827 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7151710a45e6ca0bd25335b14be005aa5179eb91de361de93686022c9b71c3d1" + "sha256": "933685b75680e3a06d2f523239d848b14d1507d385de42401863f4fb6345366c" }, "pipfile-spec": 6, "requires": { @@ -56,6 +56,7 @@ "sha256:f326b3c1bbfda5b9308252ee0dcb30b612ee92b0e105d4abec70335fab5b1245", "sha256:f411cb22115cb15452d099fec0ee636b06cf81bfb40ed9c02d30c8dc2bc2e3d1" ], + "markers": "python_version >= '3.6'", "version": "==3.7.3" }, "aioredis": { @@ -70,6 +71,7 @@ "sha256:1e759a7f202d910939de6eca45c23a107f6b71111f41d1282c648e9ac3d21901", "sha256:affdd263d8b8eb3c98170b78bf83867cdb6a14901d586e00ddb65bfe2f0c4e60" ], + "markers": "python_version >= '3.6'", "version": "==5.0.5" }, "asgiref": { @@ -77,6 +79,7 @@ "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" ], + "markers": "python_version >= '3.5'", "version": "==3.3.1" }, "async-timeout": { @@ -84,6 +87,7 @@ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" ], + "markers": "python_full_version >= '3.5.3'", "version": "==3.0.1" }, "attrs": { @@ -91,6 +95,7 @@ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, "autobahn": { @@ -98,6 +103,7 @@ "sha256:41a3a3f89cde48643baf4e105d9491c566295f9abee951379e59121784044b8b", "sha256:7e6b1bf95196b733978bab2d54a7ab8899c16ce11be369dc58422c07b7eea726" ], + "markers": "python_version >= '3.6'", "version": "==21.2.1" }, "automat": { @@ -116,7 +122,8 @@ }, "boto3": { "hashes": [ - "sha256:877f204dabe1bfa21aa9cfaacc72bd4b70a897d0fdcea799afa5c4743b6fc7ac" + "sha256:877f204dabe1bfa21aa9cfaacc72bd4b70a897d0fdcea799afa5c4743b6fc7ac", + "sha256:3a8412020a59509e783755b5c9b910a4fc7f6b6f2b9473e7cd1e07b67672e0d1" ], "index": "pypi", "version": "==1.17.9" @@ -126,6 +133,7 @@ "sha256:c8614c230e7a8e042a8c07d47caea50ad21cb51415289bd34fa6d0382beddad7", "sha256:d725840b881be62fc52e8e24a6ada651128cf7f1ed1639b87322a7a213ffdbad" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==1.20.9" }, "cachetools": { @@ -133,8 +141,15 @@ "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2", "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9" ], + "markers": "python_version ~= '3.5'", "version": "==4.2.1" }, + "cbor2": { + "hashes": [ + "sha256:a33aa2e5534fd74401ac95686886e655e3b2ce6383b3f958199b6e70a87c94bf" + ], + "version": "==5.2.0" + }, "celery": { "hashes": [ "sha256:5e8d364e058554e83bbb116e8377d90c79be254785f357cb2cec026e79febe13", @@ -220,6 +235,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "click-didyoumean": { @@ -288,6 +304,7 @@ "sha256:0052c9887600c57054a5867d4b0240159fa009faa3bcf6a1627271d9cdcb005a", "sha256:c22b692707f514de9013651ecb687f2abe4f35cf6fe292ece634e9f1737bc7e3" ], + "markers": "python_version >= '3.6'", "version": "==3.0.1" }, "defusedxml": { @@ -428,6 +445,7 @@ "hashes": [ "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" }, "geoip2": { @@ -443,6 +461,7 @@ "sha256:1b461d079b5650efe492a7814e95c536ffa9e7a96e39a6d16189c1604f18554f", "sha256:8ce6862cf4e9252de10045f05fa80393fde831da9c2b45c39288edeee3cde7f2" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==1.26.1" }, "gunicorn": { @@ -458,6 +477,7 @@ "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" ], + "markers": "python_version >= '3.6'", "version": "==0.12.0" }, "hiredis": { @@ -509,6 +529,7 @@ "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390", "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.0" }, "httptools": { @@ -554,6 +575,7 @@ "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" ], + "markers": "python_version >= '3.5'", "version": "==0.5.1" }, "itypes": { @@ -568,6 +590,7 @@ "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.3" }, "jmespath": { @@ -575,6 +598,7 @@ "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.0" }, "jsonschema": { @@ -589,6 +613,7 @@ "sha256:6dc509178ac4269b0e66ab4881f70a2035c33d3a622e20585f965986a5182006", "sha256:f4965fba0a4718d47d470beeb5d6446e3357a62402b16c510b6a2f251e05ac3c" ], + "markers": "python_version >= '3.6'", "version": "==5.0.2" }, "kubernetes": { @@ -601,8 +626,11 @@ }, "ldap3": { "hashes": [ - "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91", - "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57" + "sha256:c1df41d89459be6f304e0ceec4b00fdea533dbbcd83c802b1272dcdb94620b57", + "sha256:afc6fc0d01f02af82cd7bfabd3bbfd5dc96a6ae91e97db0a2dab8a0f1b436056", + "sha256:4139c91f0eef9782df7b77c8cbc6243086affcb6a8a249b768a9658438e5da59", + "sha256:8c949edbad2be8a03e719ba48bd6779f327ec156929562814b3e84ab56889c8c", + "sha256:18c3ee656a6775b9b0d60f7c6c5b094d878d1d90fc03d56731039f0a4b546a91" ], "index": "pypi", "version": "==2.9" @@ -705,12 +733,14 @@ "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "maxminddb": { "hashes": [ "sha256:47e86a084dd814fac88c99ea34ba3278a74bc9de5a25f4b815b608798747c7dc" ], + "markers": "python_version >= '3.6'", "version": "==2.0.3" }, "msgpack": { @@ -786,6 +816,7 @@ "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" ], + "markers": "python_version >= '3.6'", "version": "==5.1.0" }, "oauthlib": { @@ -793,6 +824,7 @@ "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.1.0" }, "packaging": { @@ -815,6 +847,7 @@ "sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974", "sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd" ], + "markers": "python_full_version >= '3.6.1'", "version": "==3.0.16" }, "psycopg2-binary": { @@ -860,15 +893,37 @@ }, "pyasn1": { "hashes": [ + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba" + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7" ], "version": "==0.4.8" }, "pyasn1-modules": { "hashes": [ + "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", - "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74" + "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", + "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", + "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", + "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", + "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", + "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", + "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", + "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405", + "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", + "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", + "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4" ], "version": "==0.2.8" }, @@ -877,6 +932,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pycryptodome": { @@ -948,6 +1004,7 @@ "sha256:f933ecf4cb736c7af60a6a533db2bf569717f2318b265f92907acff1db43bc34", "sha256:fc9c55dc1ed57db76595f2d19a479fc1c3a1be2c9da8de798a93d286c5f65f38" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==3.10.1" }, "pyhamcrest": { @@ -955,6 +1012,7 @@ "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" ], + "markers": "python_version >= '3.5'", "version": "==2.0.2" }, "pyjwkest": { @@ -976,12 +1034,14 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pyrsistent": { "hashes": [ "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e" ], + "markers": "python_version >= '3.5'", "version": "==0.17.3" }, "python-dateutil": { @@ -989,6 +1049,7 @@ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.1" }, "python-dotenv": { @@ -1045,6 +1106,7 @@ "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==3.5.3" }, "requests": { @@ -1052,11 +1114,13 @@ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.25.1" }, "requests-oauthlib": { "hashes": [ "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc", "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a" ], "index": "pypi", @@ -1105,6 +1169,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "sqlparse": { @@ -1112,6 +1177,7 @@ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], + "markers": "python_version >= '3.5'", "version": "==0.4.1" }, "structlog": { @@ -1159,6 +1225,7 @@ "sha256:f058bd0168271de4dcdc39845b52dd0a4a2fecf5f1246335f13f5e96eaebb467", "sha256:f3c19e5bd42bbe4bf345704ad7c326c74d3fd7a1b3844987853bef180be638d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==20.3.0" }, "txaio": { @@ -1166,6 +1233,7 @@ "sha256:1488d31d564a116538cc1265ac3f7979fb6223bb5a9e9f1479436ee2c17d8549", "sha256:a8676d6c68aea1f0e2548c4afdb8e6253873af3bc2659bb5bcd9f39dff7ff90f" ], + "markers": "python_version >= '3.6'", "version": "==20.12.1" }, "typing-extensions": { @@ -1181,6 +1249,7 @@ "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.1" }, "urllib3": { @@ -1225,6 +1294,7 @@ "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" ], + "markers": "python_version >= '3.6'", "version": "==5.0.0" }, "watchgod": { @@ -1241,6 +1311,14 @@ ], "version": "==0.2.5" }, + "webauthn": { + "hashes": [ + "sha256:238391b2e2cc60fb51a2cd2d2d6be149920b9af6184651353d9f95856617a9e7", + "sha256:8ad9072ff1d6169f3be30d4dc8733ea563dd266962397bc58b40f674a6af74ac" + ], + "index": "pypi", + "version": "==0.4.7" + }, "websocket-client": { "hashes": [ "sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549", @@ -1334,6 +1412,7 @@ "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" ], + "markers": "python_version >= '3.6'", "version": "==1.6.3" }, "zope.interface": { @@ -1391,6 +1470,7 @@ "sha256:f37d45fab14ffef9d33a0dc3bc59ce0c5313e2253323312d47739192da94f5fd", "sha256:f44906f70205d456d503105023041f1e63aece7623b31c390a0103db4de17537" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==5.2.0" } }, @@ -1407,6 +1487,7 @@ "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" ], + "markers": "python_version >= '3.5'", "version": "==3.3.1" }, "astroid": { @@ -1414,6 +1495,7 @@ "sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1", "sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38" ], + "markers": "python_version >= '3.5'", "version": "==2.4.1" }, "attrs": { @@ -1421,6 +1503,7 @@ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.3.0" }, "autopep8": { @@ -1451,6 +1534,7 @@ "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "bumpversion": { @@ -1466,6 +1550,7 @@ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==7.1.2" }, "colorama": { @@ -1559,6 +1644,7 @@ "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839", "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.8.4" }, "flake8-polyfill": { @@ -1573,6 +1659,7 @@ "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" ], + "markers": "python_version >= '3.4'", "version": "==4.0.5" }, "gitpython": { @@ -1580,6 +1667,7 @@ "sha256:8621a7e777e276a5ec838b59280ba5272dd144a18169c36c903d8b38b99f750a", "sha256:c5347c81d232d9b8e7f47b68a83e5dc92e7952127133c5f2df9133f2c75a1b29" ], + "markers": "python_version >= '3.4'", "version": "==3.1.13" }, "iniconfig": { @@ -1594,6 +1682,7 @@ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==4.3.21" }, "lazy-object-proxy": { @@ -1620,6 +1709,7 @@ "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.4.3" }, "mccabe": { @@ -1656,6 +1746,7 @@ "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" ], + "markers": "python_version >= '2.6'", "version": "==5.5.1" }, "pep8-naming": { @@ -1670,6 +1761,7 @@ "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.1" }, "prospector": { @@ -1684,6 +1776,7 @@ "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.10.0" }, "pycodestyle": { @@ -1691,6 +1784,7 @@ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pydocstyle": { @@ -1698,6 +1792,7 @@ "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" ], + "markers": "python_version >= '3.5'", "version": "==5.1.1" }, "pyflakes": { @@ -1705,6 +1800,7 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pylint": { @@ -1747,6 +1843,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "pytest": { @@ -1870,6 +1967,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "smmap": { @@ -1877,6 +1975,7 @@ "sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714", "sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.5" }, "snowballstemmer": { @@ -1891,6 +1990,7 @@ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], + "markers": "python_version >= '3.5'", "version": "==0.4.1" }, "stevedore": { @@ -1898,6 +1998,7 @@ "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee", "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a" ], + "markers": "python_version >= '3.6'", "version": "==3.3.0" }, "toml": { @@ -1905,6 +2006,7 @@ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "typed-ast": { diff --git a/authentik/admin/templates/administration/stage/list.html b/authentik/admin/templates/administration/stage/list.html index def5e20ba..d71c30348 100644 --- a/authentik/admin/templates/administration/stage/list.html +++ b/authentik/admin/templates/administration/stage/list.html @@ -68,7 +68,7 @@ {% if not state %} {% if stage.configure_flow %} - {% trans "Enable Static Tokens" %} + {% trans "Enable Static Tokens" %} {% endif %} {% else %} - {% trans "Disable Static Tokens" %} + {% trans "Disable Static Tokens" %} {% endif %} diff --git a/authentik/stages/otp_static/urls.py b/authentik/stages/authenticator_static/urls.py similarity index 66% rename from authentik/stages/otp_static/urls.py rename to authentik/stages/authenticator_static/urls.py index 55ee7807c..505473312 100644 --- a/authentik/stages/otp_static/urls.py +++ b/authentik/stages/authenticator_static/urls.py @@ -1,7 +1,7 @@ -"""OTP static urls""" +"""Static Authenticator urls""" from django.urls import path -from authentik.stages.otp_static.views import DisableView, UserSettingsView +from authentik.stages.authenticator_static.views import DisableView, UserSettingsView urlpatterns = [ path( diff --git a/authentik/stages/otp_static/views.py b/authentik/stages/authenticator_static/views.py similarity index 83% rename from authentik/stages/otp_static/views.py rename to authentik/stages/authenticator_static/views.py index ccc495240..2d4fb048b 100644 --- a/authentik/stages/otp_static/views.py +++ b/authentik/stages/authenticator_static/views.py @@ -1,4 +1,4 @@ -"""otp Static view Tokens""" +"""Static Authenticator view Tokens""" from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpRequest, HttpResponse @@ -8,17 +8,19 @@ from django.views.generic import TemplateView from django_otp.plugins.otp_static.models import StaticDevice, StaticToken from authentik.events.models import Event -from authentik.stages.otp_static.models import OTPStaticStage +from authentik.stages.authenticator_static.models import AuthenticatorStaticStage class UserSettingsView(LoginRequiredMixin, TemplateView): """View for user settings to control OTP""" - template_name = "stages/otp_static/user_settings.html" + template_name = "stages/authenticator_static/user_settings.html" def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - stage = get_object_or_404(OTPStaticStage, pk=self.kwargs["stage_uuid"]) + stage = get_object_or_404( + AuthenticatorStaticStage, pk=self.kwargs["stage_uuid"] + ) kwargs["stage"] = stage static_devices = StaticDevice.objects.filter( user=self.request.user, confirmed=True diff --git a/authentik/stages/otp_time/__init__.py b/authentik/stages/authenticator_totp/__init__.py similarity index 100% rename from authentik/stages/otp_time/__init__.py rename to authentik/stages/authenticator_totp/__init__.py diff --git a/authentik/stages/authenticator_totp/api.py b/authentik/stages/authenticator_totp/api.py new file mode 100644 index 000000000..6a8dab0d8 --- /dev/null +++ b/authentik/stages/authenticator_totp/api.py @@ -0,0 +1,21 @@ +"""AuthenticatorTOTPStage API Views""" +from rest_framework.viewsets import ModelViewSet + +from authentik.flows.api import StageSerializer +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage + + +class AuthenticatorTOTPStageSerializer(StageSerializer): + """AuthenticatorTOTPStage Serializer""" + + class Meta: + + model = AuthenticatorTOTPStage + fields = StageSerializer.Meta.fields + ["configure_flow", "digits"] + + +class AuthenticatorTOTPStageViewSet(ModelViewSet): + """AuthenticatorTOTPStage Viewset""" + + queryset = AuthenticatorTOTPStage.objects.all() + serializer_class = AuthenticatorTOTPStageSerializer diff --git a/authentik/stages/authenticator_totp/apps.py b/authentik/stages/authenticator_totp/apps.py new file mode 100644 index 000000000..008351c29 --- /dev/null +++ b/authentik/stages/authenticator_totp/apps.py @@ -0,0 +1,11 @@ +"""OTP Time""" +from django.apps import AppConfig + + +class AuthentikStageAuthenticatorTOTPConfig(AppConfig): + """TOTP App config""" + + name = "authentik.stages.authenticator_totp" + label = "authentik_stages_authenticator_totp" + verbose_name = "authentik Stages.Authenticator.TOTP" + mountpoint = "-/user/authenticator/totp/" diff --git a/authentik/stages/otp_time/forms.py b/authentik/stages/authenticator_totp/forms.py similarity index 90% rename from authentik/stages/otp_time/forms.py rename to authentik/stages/authenticator_totp/forms.py index eb40521b8..91d635c92 100644 --- a/authentik/stages/otp_time/forms.py +++ b/authentik/stages/authenticator_totp/forms.py @@ -4,7 +4,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django_otp.models import Device -from authentik.stages.otp_time.models import OTPTimeStage +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage class PictureWidget(forms.widgets.Widget): @@ -49,12 +49,12 @@ class SetupForm(forms.Form): return self.cleaned_data.get("code") -class OTPTimeStageForm(forms.ModelForm): +class AuthenticatorTOTPStageForm(forms.ModelForm): """OTP Time-based Stage setup form""" class Meta: - model = OTPTimeStage + model = AuthenticatorTOTPStage fields = ["name", "configure_flow", "digits"] widgets = { diff --git a/authentik/stages/otp_time/migrations/0001_initial.py b/authentik/stages/authenticator_totp/migrations/0001_initial.py similarity index 100% rename from authentik/stages/otp_time/migrations/0001_initial.py rename to authentik/stages/authenticator_totp/migrations/0001_initial.py diff --git a/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py b/authentik/stages/authenticator_totp/migrations/0002_auto_20200701_1900.py similarity index 89% rename from authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py rename to authentik/stages/authenticator_totp/migrations/0002_auto_20200701_1900.py index 9dca4752f..edcd23104 100644 --- a/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py +++ b/authentik/stages/authenticator_totp/migrations/0002_auto_20200701_1900.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("authentik_stages_otp_time", "0001_initial"), + ("authentik_stages_authenticator_totp", "0001_initial"), ] operations = [ diff --git a/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py b/authentik/stages/authenticator_totp/migrations/0003_otptimestage_configure_flow.py similarity index 90% rename from authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py rename to authentik/stages/authenticator_totp/migrations/0003_otptimestage_configure_flow.py index 64d09b8b8..a692ec9ca 100644 --- a/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py +++ b/authentik/stages/authenticator_totp/migrations/0003_otptimestage_configure_flow.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ("authentik_flows", "0013_auto_20200924_1605"), - ("authentik_stages_otp_time", "0002_auto_20200701_1900"), + ("authentik_stages_authenticator_totp", "0002_auto_20200701_1900"), ] operations = [ diff --git a/authentik/stages/authenticator_totp/migrations/0004_default_setup_flow.py b/authentik/stages/authenticator_totp/migrations/0004_default_setup_flow.py new file mode 100644 index 000000000..0bc3c705c --- /dev/null +++ b/authentik/stages/authenticator_totp/migrations/0004_default_setup_flow.py @@ -0,0 +1,15 @@ +# Generated by Django 3.1.1 on 2020-09-25 15:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_stages_authenticator_totp", + "0003_otptimestage_configure_flow", + ), + ] + + operations = [] diff --git a/authentik/stages/authenticator_totp/migrations/0005_auto_20210216_0838.py b/authentik/stages/authenticator_totp/migrations/0005_auto_20210216_0838.py new file mode 100644 index 000000000..3ba742263 --- /dev/null +++ b/authentik/stages/authenticator_totp/migrations/0005_auto_20210216_0838.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.6 on 2021-02-16 08:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ("authentik_stages_authenticator_totp", "0004_default_setup_flow"), + ] + + operations = [ + migrations.RenameModel( + old_name="OTPTimeStage", + new_name="AuthenticatorTOTPStage", + ), + migrations.AlterModelOptions( + name="authenticatortotpstage", + options={ + "verbose_name": "TOTP Authenticator Setup Stage", + "verbose_name_plural": "TOTP Authenticator Setup Stages", + }, + ), + ] diff --git a/authentik/stages/otp_time/migrations/0004_default_setup_flow.py b/authentik/stages/authenticator_totp/migrations/0006_default_setup_flow.py similarity index 60% rename from authentik/stages/otp_time/migrations/0004_default_setup_flow.py rename to authentik/stages/authenticator_totp/migrations/0006_default_setup_flow.py index 9b5df159b..85788c348 100644 --- a/authentik/stages/otp_time/migrations/0004_default_setup_flow.py +++ b/authentik/stages/authenticator_totp/migrations/0006_default_setup_flow.py @@ -5,35 +5,39 @@ from django.db import migrations from django.db.backends.base.schema import BaseDatabaseSchemaEditor from authentik.flows.models import FlowDesignation -from authentik.stages.otp_time.models import TOTPDigits +from authentik.stages.authenticator_totp.models import TOTPDigits def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): Flow = apps.get_model("authentik_flows", "Flow") FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") - OTPTimeStage = apps.get_model("authentik_stages_otp_time", "OTPTimeStage") + AuthenticatorTOTPStage = apps.get_model( + "authentik_stages_authenticator_totp", "AuthenticatorTOTPStage" + ) db_alias = schema_editor.connection.alias flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-otp-time-configure", + slug="default-authenticator-totp-setup", designation=FlowDesignation.STAGE_CONFIGURATION, defaults={ - "name": "default-otp-time-configure", + "name": "default-authenticator-totp-setup", "title": "Setup Two-Factor authentication", }, ) - stage, _ = OTPTimeStage.objects.using(db_alias).update_or_create( - name="default-otp-time-configure", defaults={"digits": TOTPDigits.SIX} + stage, _ = AuthenticatorTOTPStage.objects.using(db_alias).update_or_create( + name="default-authenticator-totp-setup", defaults={"digits": TOTPDigits.SIX} ) FlowStageBinding.objects.using(db_alias).update_or_create( target=flow, stage=stage, defaults={"order": 0} ) - for stage in OTPTimeStage.objects.using(db_alias).filter(configure_flow=None): + for stage in AuthenticatorTOTPStage.objects.using(db_alias).filter( + configure_flow=None + ): stage.configure_flow = flow stage.save() @@ -41,7 +45,10 @@ def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEdito class Migration(migrations.Migration): dependencies = [ - ("authentik_stages_otp_time", "0003_otptimestage_configure_flow"), + ( + "authentik_stages_authenticator_totp", + "0005_auto_20210216_0838", + ), ] operations = [ diff --git a/authentik/stages/otp_time/migrations/__init__.py b/authentik/stages/authenticator_totp/migrations/__init__.py similarity index 100% rename from authentik/stages/otp_time/migrations/__init__.py rename to authentik/stages/authenticator_totp/migrations/__init__.py diff --git a/authentik/stages/otp_time/models.py b/authentik/stages/authenticator_totp/models.py similarity index 58% rename from authentik/stages/otp_time/models.py rename to authentik/stages/authenticator_totp/models.py index 08a54e394..010a1fd97 100644 --- a/authentik/stages/otp_time/models.py +++ b/authentik/stages/authenticator_totp/models.py @@ -18,40 +18,42 @@ class TOTPDigits(models.IntegerChoices): EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator") -class OTPTimeStage(ConfigurableStage, Stage): +class AuthenticatorTOTPStage(ConfigurableStage, Stage): """Enroll a user's device into Time-based OTP.""" digits = models.IntegerField(choices=TOTPDigits.choices) @property def serializer(self) -> BaseSerializer: - from authentik.stages.otp_time.api import OTPTimeStageSerializer + from authentik.stages.authenticator_totp.api import ( + AuthenticatorTOTPStageSerializer, + ) - return OTPTimeStageSerializer + return AuthenticatorTOTPStageSerializer @property def type(self) -> Type[View]: - from authentik.stages.otp_time.stage import OTPTimeStageView + from authentik.stages.authenticator_totp.stage import AuthenticatorTOTPStageView - return OTPTimeStageView + return AuthenticatorTOTPStageView @property def form(self) -> Type[ModelForm]: - from authentik.stages.otp_time.forms import OTPTimeStageForm + from authentik.stages.authenticator_totp.forms import AuthenticatorTOTPStageForm - return OTPTimeStageForm + return AuthenticatorTOTPStageForm @property def ui_user_settings(self) -> Optional[str]: return reverse( - "authentik_stages_otp_time:user-settings", + "authentik_stages_authenticator_totp:user-settings", kwargs={"stage_uuid": self.stage_uuid}, ) def __str__(self) -> str: - return f"OTP Time (TOTP) Stage {self.name}" + return f"TOTP Authenticator Setup Stage {self.name}" class Meta: - verbose_name = _("OTP Time (TOTP) Setup Stage") - verbose_name_plural = _("OTP Time (TOTP) Setup Stages") + verbose_name = _("TOTP Authenticator Setup Stage") + verbose_name_plural = _("TOTP Authenticator Setup Stages") diff --git a/authentik/stages/otp_time/settings.py b/authentik/stages/authenticator_totp/settings.py similarity index 100% rename from authentik/stages/otp_time/settings.py rename to authentik/stages/authenticator_totp/settings.py diff --git a/authentik/stages/otp_time/stage.py b/authentik/stages/authenticator_totp/stage.py similarity index 89% rename from authentik/stages/otp_time/stage.py rename to authentik/stages/authenticator_totp/stage.py index 08984ece2..631b24cf6 100644 --- a/authentik/stages/otp_time/stage.py +++ b/authentik/stages/authenticator_totp/stage.py @@ -12,14 +12,14 @@ from structlog.stdlib import get_logger from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import StageView -from authentik.stages.otp_time.forms import SetupForm -from authentik.stages.otp_time.models import OTPTimeStage +from authentik.stages.authenticator_totp.forms import SetupForm +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage LOGGER = get_logger() SESSION_TOTP_DEVICE = "totp_device" -class OTPTimeStageView(FormView, StageView): +class AuthenticatorTOTPStageView(FormView, StageView): """OTP totp Setup stage""" form_class = SetupForm @@ -50,7 +50,7 @@ class OTPTimeStageView(FormView, StageView): if TOTPDevice.objects.filter(user=user).exists(): return self.executor.stage_ok() - stage: OTPTimeStage = self.executor.current_stage + stage: AuthenticatorTOTPStage = self.executor.current_stage if SESSION_TOTP_DEVICE not in self.request.session: device = TOTPDevice(user=user, confirmed=True, digits=stage.digits) diff --git a/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html b/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html similarity index 69% rename from authentik/stages/otp_time/templates/stages/otp_time/user_settings.html rename to authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html index c6e24146e..54bc16e14 100644 --- a/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html +++ b/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html @@ -18,10 +18,10 @@

{% if not state %} {% if stage.configure_flow %} - {% trans "Enable Time-based OTP" %} + {% trans "Enable Time-based OTP" %} {% endif %} {% else %} - {% trans "Disable Time-based OTP" %} + {% trans "Disable Time-based OTP" %} {% endif %}

diff --git a/authentik/stages/otp_time/urls.py b/authentik/stages/authenticator_totp/urls.py similarity index 75% rename from authentik/stages/otp_time/urls.py rename to authentik/stages/authenticator_totp/urls.py index 3570fc8d4..f5ba850e8 100644 --- a/authentik/stages/otp_time/urls.py +++ b/authentik/stages/authenticator_totp/urls.py @@ -1,7 +1,7 @@ """OTP Time urls""" from django.urls import path -from authentik.stages.otp_time.views import DisableView, UserSettingsView +from authentik.stages.authenticator_totp.views import DisableView, UserSettingsView urlpatterns = [ path( diff --git a/authentik/stages/otp_time/views.py b/authentik/stages/authenticator_totp/views.py similarity index 85% rename from authentik/stages/otp_time/views.py rename to authentik/stages/authenticator_totp/views.py index f1322cadf..338350d71 100644 --- a/authentik/stages/otp_time/views.py +++ b/authentik/stages/authenticator_totp/views.py @@ -8,17 +8,17 @@ from django.views.generic import TemplateView from django_otp.plugins.otp_totp.models import TOTPDevice from authentik.events.models import Event -from authentik.stages.otp_time.models import OTPTimeStage +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage class UserSettingsView(LoginRequiredMixin, TemplateView): """View for user settings to control OTP""" - template_name = "stages/otp_time/user_settings.html" + template_name = "stages/authenticator_totp/user_settings.html" def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - stage = get_object_or_404(OTPTimeStage, pk=self.kwargs["stage_uuid"]) + stage = get_object_or_404(AuthenticatorTOTPStage, pk=self.kwargs["stage_uuid"]) kwargs["stage"] = stage totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True) diff --git a/authentik/stages/otp_validate/__init__.py b/authentik/stages/authenticator_validate/__init__.py similarity index 100% rename from authentik/stages/otp_validate/__init__.py rename to authentik/stages/authenticator_validate/__init__.py diff --git a/authentik/stages/authenticator_validate/api.py b/authentik/stages/authenticator_validate/api.py new file mode 100644 index 000000000..54bfa25b0 --- /dev/null +++ b/authentik/stages/authenticator_validate/api.py @@ -0,0 +1,24 @@ +"""AuthenticatorValidateStage API Views""" +from rest_framework.viewsets import ModelViewSet + +from authentik.flows.api import StageSerializer +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage + + +class AuthenticatorValidateStageSerializer(StageSerializer): + """AuthenticatorValidateStage Serializer""" + + class Meta: + + model = AuthenticatorValidateStage + fields = StageSerializer.Meta.fields + [ + "not_configured_action", + "device_classes", + ] + + +class AuthenticatorValidateStageViewSet(ModelViewSet): + """AuthenticatorValidateStage Viewset""" + + queryset = AuthenticatorValidateStage.objects.all() + serializer_class = AuthenticatorValidateStageSerializer diff --git a/authentik/stages/authenticator_validate/apps.py b/authentik/stages/authenticator_validate/apps.py new file mode 100644 index 000000000..e50715bf8 --- /dev/null +++ b/authentik/stages/authenticator_validate/apps.py @@ -0,0 +1,10 @@ +"""Authenticator Validation Stage""" +from django.apps import AppConfig + + +class AuthentikStageAuthenticatorValidateConfig(AppConfig): + """Authenticator Validation Stage""" + + name = "authentik.stages.authenticator_validate" + label = "authentik_stages_authenticator_validate" + verbose_name = "authentik Stages.Authenticator.Validate" diff --git a/authentik/stages/otp_validate/forms.py b/authentik/stages/authenticator_validate/forms.py similarity index 76% rename from authentik/stages/otp_validate/forms.py rename to authentik/stages/authenticator_validate/forms.py index a8d1f04b8..23e87fba0 100644 --- a/authentik/stages/otp_validate/forms.py +++ b/authentik/stages/authenticator_validate/forms.py @@ -4,7 +4,10 @@ from django.utils.translation import gettext_lazy as _ from django_otp import match_token from authentik.core.models import User -from authentik.stages.otp_validate.models import OTPValidateStage +from authentik.stages.authenticator_validate.models import ( + AuthenticatorValidateStage, + DeviceClasses, +) class ValidationForm(forms.Form): @@ -36,14 +39,15 @@ class ValidationForm(forms.Form): return code -class OTPValidateStageForm(forms.ModelForm): +class AuthenticatorValidateStageForm(forms.ModelForm): """OTP Validate stage forms""" class Meta: - model = OTPValidateStage - fields = ["name"] + model = AuthenticatorValidateStage + fields = ["name", "device_classes"] widgets = { "name": forms.TextInput(), + "device_classes": forms.SelectMultiple(choices=DeviceClasses.choices), } diff --git a/authentik/stages/otp_validate/migrations/0001_initial.py b/authentik/stages/authenticator_validate/migrations/0001_initial.py similarity index 100% rename from authentik/stages/otp_validate/migrations/0001_initial.py rename to authentik/stages/authenticator_validate/migrations/0001_initial.py diff --git a/authentik/stages/authenticator_validate/migrations/0002_auto_20210216_0838.py b/authentik/stages/authenticator_validate/migrations/0002_auto_20210216_0838.py new file mode 100644 index 000000000..c6a53fb25 --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0002_auto_20210216_0838.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.6 on 2021-02-16 08:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ("authentik_stages_authenticator_validate", "0001_initial"), + ] + + operations = [ + migrations.RenameModel( + old_name="OTPValidateStage", + new_name="AuthenticatorValidateStage", + ), + migrations.AlterModelOptions( + name="authenticatorvalidatestage", + options={ + "verbose_name": "Authenticator Validation Stage", + "verbose_name_plural": "Authenticator Validation Stages", + }, + ), + ] diff --git a/authentik/stages/authenticator_validate/migrations/0003_authenticatorvalidatestage_device_classes.py b/authentik/stages/authenticator_validate/migrations/0003_authenticatorvalidatestage_device_classes.py new file mode 100644 index 000000000..8b865bbff --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0003_authenticatorvalidatestage_device_classes.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1.6 on 2021-02-16 13:03 + +import django.contrib.postgres.fields +from django.db import migrations, models + +import authentik.stages.authenticator_validate.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_authenticator_validate", "0002_auto_20210216_0838"), + ] + + operations = [ + migrations.AddField( + model_name="authenticatorvalidatestage", + name="device_classes", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), + default=authentik.stages.authenticator_validate.models.default_device_classes, + help_text="Device classes which can be used to authenticate", + size=None, + ), + ), + ] diff --git a/authentik/stages/otp_validate/migrations/__init__.py b/authentik/stages/authenticator_validate/migrations/__init__.py similarity index 100% rename from authentik/stages/otp_validate/migrations/__init__.py rename to authentik/stages/authenticator_validate/migrations/__init__.py diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py new file mode 100644 index 000000000..688cd0e1a --- /dev/null +++ b/authentik/stages/authenticator_validate/models.py @@ -0,0 +1,74 @@ +"""Authenticator Validation Stage""" +from typing import Type + +from django.contrib.postgres.fields.array import ArrayField +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import NotConfiguredAction, Stage + + +class DeviceClasses(models.TextChoices): + """Device classes this stage can validate""" + + STATIC = "static" + TOTP = "totp", _("TOTP") + WEBAUTHN = "webauthn", _("WebAuthn") + + +def default_device_classes() -> list: + """By default, accept all device classes""" + return [ + DeviceClasses.STATIC, + DeviceClasses.TOTP, + DeviceClasses.WEBAUTHN, + ] + + +class AuthenticatorValidateStage(Stage): + """Validate user's configured OTP Device.""" + + not_configured_action = models.TextField( + choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP + ) + + device_classes = ArrayField( + models.TextField(), + help_text=_("Device classes which can be used to authenticate"), + default=default_device_classes, + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.authenticator_validate.api import ( + AuthenticatorValidateStageSerializer, + ) + + return AuthenticatorValidateStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.authenticator_validate.stage import ( + AuthenticatorValidateStageView, + ) + + return AuthenticatorValidateStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.authenticator_validate.forms import ( + AuthenticatorValidateStageForm, + ) + + return AuthenticatorValidateStageForm + + def __str__(self) -> str: + return f"Authenticator Validation Stage {self.name}" + + class Meta: + + verbose_name = _("Authenticator Validation Stage") + verbose_name_plural = _("Authenticator Validation Stages") diff --git a/authentik/stages/otp_validate/settings.py b/authentik/stages/authenticator_validate/settings.py similarity index 100% rename from authentik/stages/otp_validate/settings.py rename to authentik/stages/authenticator_validate/settings.py diff --git a/authentik/stages/otp_validate/stage.py b/authentik/stages/authenticator_validate/stage.py similarity index 79% rename from authentik/stages/otp_validate/stage.py rename to authentik/stages/authenticator_validate/stage.py index fae6086f4..04c3018b2 100644 --- a/authentik/stages/otp_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -9,13 +9,13 @@ from structlog.stdlib import get_logger from authentik.flows.models import NotConfiguredAction from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import StageView -from authentik.stages.otp_validate.forms import ValidationForm -from authentik.stages.otp_validate.models import OTPValidateStage +from authentik.stages.authenticator_validate.forms import ValidationForm +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage LOGGER = get_logger() -class OTPValidateStageView(FormView, StageView): +class AuthenticatorValidateStageView(FormView, StageView): """OTP Validation""" form_class = ValidationForm @@ -31,11 +31,11 @@ class OTPValidateStageView(FormView, StageView): LOGGER.debug("No pending user, continuing") return self.executor.stage_ok() has_devices = user_has_device(user) - stage: OTPValidateStage = self.executor.current_stage + stage: AuthenticatorValidateStage = self.executor.current_stage if not has_devices: if stage.not_configured_action == NotConfiguredAction.SKIP: - LOGGER.debug("OTP not configured, skipping stage") + LOGGER.debug("Authenticator not configured, skipping stage") return self.executor.stage_ok() return super().get(request, *args, **kwargs) diff --git a/authentik/stages/authenticator_webauthn/__init__.py b/authentik/stages/authenticator_webauthn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/authenticator_webauthn/api.py b/authentik/stages/authenticator_webauthn/api.py new file mode 100644 index 000000000..d3ecaed79 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/api.py @@ -0,0 +1,21 @@ +"""AuthenticateWebAuthnStage API Views""" +from rest_framework.viewsets import ModelViewSet + +from authentik.flows.api import StageSerializer +from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage + + +class AuthenticateWebAuthnStageSerializer(StageSerializer): + """AuthenticateWebAuthnStage Serializer""" + + class Meta: + + model = AuthenticateWebAuthnStage + fields = StageSerializer.Meta.fields + + +class AuthenticateWebAuthnStageViewSet(ModelViewSet): + """AuthenticateWebAuthnStage Viewset""" + + queryset = AuthenticateWebAuthnStage.objects.all() + serializer_class = AuthenticateWebAuthnStageSerializer diff --git a/authentik/stages/authenticator_webauthn/apps.py b/authentik/stages/authenticator_webauthn/apps.py new file mode 100644 index 000000000..ec8736525 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/apps.py @@ -0,0 +1,11 @@ +"""authentik webauthn app config""" +from django.apps import AppConfig + + +class AuthentikStageAuthenticatorWebAuthnConfig(AppConfig): + """authentik webauthn config""" + + name = "authentik.stages.authenticator_webauthn" + label = "authentik_stages_authenticator_webauthn" + verbose_name = "authentik Stages.Authenticator.WebAuthn" + mountpoint = "-/user/authenticator/webauthn/" diff --git a/authentik/stages/authenticator_webauthn/forms.py b/authentik/stages/authenticator_webauthn/forms.py new file mode 100644 index 000000000..78368c190 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/forms.py @@ -0,0 +1,17 @@ +"""Webauthn stage forms""" +from django import forms + +from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage + + +class AuthenticateWebAuthnStageForm(forms.ModelForm): + """OTP Time-based Stage setup form""" + + class Meta: + + model = AuthenticateWebAuthnStage + fields = ["name"] + + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/authenticator_webauthn/migrations/0001_initial.py b/authentik/stages/authenticator_webauthn/migrations/0001_initial.py new file mode 100644 index 000000000..7d097bd6a --- /dev/null +++ b/authentik/stages/authenticator_webauthn/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 3.1.6 on 2021-02-17 10:48 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_flows", "0016_auto_20201202_1307"), + ] + + operations = [ + migrations.CreateModel( + name="WebAuthnDevice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.TextField(max_length=200)), + ("credential_id", models.CharField(max_length=300, unique=True)), + ("public_key", models.TextField()), + ("sign_count", models.IntegerField(default=0)), + ("rp_id", models.CharField(max_length=253)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ( + "last_used_on", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="AuthenticateWebAuthnStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_flows.stage", + ), + ), + ( + "configure_flow", + models.ForeignKey( + blank=True, + help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_flows.flow", + ), + ), + ], + options={ + "verbose_name": "WebAuthn Authenticator Setup Stage", + "verbose_name_plural": "WebAuthn Authenticator Setup Stages", + }, + bases=("authentik_flows.stage", models.Model), + ), + ] diff --git a/authentik/stages/otp_static/migrations/0003_default_setup_flow.py b/authentik/stages/authenticator_webauthn/migrations/0002_default_setup_flow.py similarity index 63% rename from authentik/stages/otp_static/migrations/0003_default_setup_flow.py rename to authentik/stages/authenticator_webauthn/migrations/0002_default_setup_flow.py index 4bf6dac47..6be74a728 100644 --- a/authentik/stages/otp_static/migrations/0003_default_setup_flow.py +++ b/authentik/stages/authenticator_webauthn/migrations/0002_default_setup_flow.py @@ -11,28 +11,32 @@ def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEdito Flow = apps.get_model("authentik_flows", "Flow") FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") - OTPStaticStage = apps.get_model("authentik_stages_otp_static", "OTPStaticStage") + AuthenticateWebAuthnStage = apps.get_model( + "authentik_stages_authenticator_webauthn", "AuthenticateWebAuthnStage" + ) db_alias = schema_editor.connection.alias flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-otp-static-configure", + slug="default-authenticator-webuahtn-setup", designation=FlowDesignation.STAGE_CONFIGURATION, defaults={ - "name": "default-otp-static-configure", + "name": "default-authenticator-webuahtn-setup", "title": "Setup Static OTP Tokens", }, ) - stage, _ = OTPStaticStage.objects.using(db_alias).update_or_create( - name="default-otp-static-configure", defaults={"token_count": 6} + stage, _ = AuthenticateWebAuthnStage.objects.using(db_alias).update_or_create( + name="default-authenticator-webuahtn-setup" ) FlowStageBinding.objects.using(db_alias).update_or_create( target=flow, stage=stage, defaults={"order": 0} ) - for stage in OTPStaticStage.objects.using(db_alias).filter(configure_flow=None): + for stage in AuthenticateWebAuthnStage.objects.using(db_alias).filter( + configure_flow=None + ): stage.configure_flow = flow stage.save() @@ -40,7 +44,10 @@ def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEdito class Migration(migrations.Migration): dependencies = [ - ("authentik_stages_otp_static", "0002_otpstaticstage_configure_flow"), + ( + "authentik_stages_authenticator_webauthn", + "0001_initial", + ), ] operations = [ diff --git a/authentik/stages/authenticator_webauthn/migrations/__init__.py b/authentik/stages/authenticator_webauthn/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/authenticator_webauthn/models.py b/authentik/stages/authenticator_webauthn/models.py new file mode 100644 index 000000000..a3f5c409c --- /dev/null +++ b/authentik/stages/authenticator_webauthn/models.py @@ -0,0 +1,80 @@ +"""WebAuthn stage""" +from typing import Optional, Type + +from django.contrib.auth import get_user_model +from django.db import models +from django.forms import ModelForm +from django.shortcuts import reverse +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import ConfigurableStage, Stage + + +class AuthenticateWebAuthnStage(ConfigurableStage, Stage): + """WebAuthn stage""" + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.authenticator_webauthn.api import ( + AuthenticateWebAuthnStageSerializer, + ) + + return AuthenticateWebAuthnStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.authenticator_webauthn.stage import ( + AuthenticateWebAuthnStageView, + ) + + return AuthenticateWebAuthnStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.authenticator_webauthn.forms import ( + AuthenticateWebAuthnStageForm, + ) + + return AuthenticateWebAuthnStageForm + + @property + def ui_user_settings(self) -> Optional[str]: + return reverse( + "authentik_stages_authenticator_webauthn:user-settings", + kwargs={"stage_uuid": self.stage_uuid}, + ) + + def __str__(self) -> str: + return f"WebAuthn Authenticator Setup Stage {self.name}" + + class Meta: + + verbose_name = _("WebAuthn Authenticator Setup Stage") + verbose_name_plural = _("WebAuthn Authenticator Setup Stages") + + +class WebAuthnDevice(models.Model): + """WebAuthn Device for a single user""" + + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + name = models.TextField(max_length=200) + credential_id = models.CharField(max_length=300, unique=True) + public_key = models.TextField() + sign_count = models.IntegerField(default=0) + rp_id = models.CharField(max_length=253) + + created_on = models.DateTimeField(auto_now_add=True) + last_used_on = models.DateTimeField(default=now) + + def set_sign_count(self, sign_count: int) -> None: + """Set the sign_count and update the last_used_on datetime.""" + self.sign_count = sign_count + self.last_used_on = now() + self.save() + + def __str__(self): + return self.name or str(self.user) diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py new file mode 100644 index 000000000..3116dc161 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/stage.py @@ -0,0 +1,44 @@ +"""WebAuthn stage""" + +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.views.generic import FormView +from structlog.stdlib import get_logger + +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.stages.authenticator_webauthn.models import WebAuthnDevice + +LOGGER = get_logger() + +SESSION_KEY_WEBAUTHN_AUTHENTICATED = ( + "authentik_stages_authenticator_webauthn_authenticated" +) + + +class AuthenticateWebAuthnStageView(FormView, StageView): + """WebAuthn stage""" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) + if not user: + LOGGER.debug("No pending user, continuing") + return self.executor.stage_ok() + devices = WebAuthnDevice.objects.filter(user=user) + # If the current user is logged in already, or the pending user + # has no devices, show setup + if self.request.user == user: + # Because the user is already authenticated, skip the later check + self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True + return render(request, "stages/authenticator_webauthn/setup.html") + if not devices.exists(): + return self.executor.stage_ok() + self.request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = False + return render(request, "stages/authenticator_webauthn/auth.html") + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Since the client can't directly indicate when a stage is done, + we use the post handler for this""" + if request.session.pop(SESSION_KEY_WEBAUTHN_AUTHENTICATED, False): + return self.executor.stage_ok() + return self.executor.stage_invalid() diff --git a/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/auth.html b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/auth.html new file mode 100644 index 000000000..f35ee05ed --- /dev/null +++ b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/auth.html @@ -0,0 +1,15 @@ +{% load i18n %} + +
+

+ {% trans 'WebAuthn' %} +

+
+
+ {% block card %} +
+ + +
+ {% endblock %} +
diff --git a/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/setup.html b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/setup.html new file mode 100644 index 000000000..6a47a71e9 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/setup.html @@ -0,0 +1,16 @@ +{% load i18n %} + +
+

+ {% trans 'Configure WebAuthn' %} +

+
+
+ {% block card %} +
+ + +
+ {% endblock %} +
+ diff --git a/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html new file mode 100644 index 000000000..89a26a8a3 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/user_settings.html @@ -0,0 +1,33 @@ +{% load i18n %} +{% load humanize %} + +
+
+ {% trans "WebAuthn Devices" %} +
+
+
    + {% for device in devices %} +
  • +
    +
    +
    {{ device.name|default:"-" }}
    +
    + {% blocktrans with created_on=device.created_on|naturaltime %} + Created {{ created_on }} + {% endblocktrans %} +
    +
    +
    +
  • + {% endfor %} +
+
+ +
diff --git a/authentik/stages/authenticator_webauthn/urls.py b/authentik/stages/authenticator_webauthn/urls.py new file mode 100644 index 000000000..d2d7e5c91 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/urls.py @@ -0,0 +1,38 @@ +"""WebAuthn urls""" +from django.urls import path +from django.views.decorators.csrf import csrf_exempt + +from authentik.stages.authenticator_webauthn.views import ( + BeginActivateView, + BeginAssertion, + UserSettingsView, + VerifyAssertion, + VerifyCredentialInfo, +) + +# TODO: Move to API views so we don't need csrf_exempt +urlpatterns = [ + path( + "begin-activate/", + csrf_exempt(BeginActivateView.as_view()), + name="activate-begin", + ), + path( + "begin-assertion/", + csrf_exempt(BeginAssertion.as_view()), + name="assertion-begin", + ), + path( + "verify-credential-info/", + csrf_exempt(VerifyCredentialInfo.as_view()), + name="credential-info-verify", + ), + path( + "verify-assertion/", + csrf_exempt(VerifyAssertion.as_view()), + name="assertion-verify", + ), + path( + "/settings/", UserSettingsView.as_view(), name="user-settings" + ), +] diff --git a/authentik/stages/authenticator_webauthn/utils.py b/authentik/stages/authenticator_webauthn/utils.py new file mode 100644 index 000000000..217f685e4 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/utils.py @@ -0,0 +1,23 @@ +"""webauthn utils""" +import base64 +import os + +CHALLENGE_DEFAULT_BYTE_LEN = 32 + + +def generate_challenge(challenge_len=CHALLENGE_DEFAULT_BYTE_LEN): + """Generate a challenge of challenge_len bytes, Base64-encoded. + We use URL-safe base64, but we *don't* strip the padding, so that + the browser can decode it without too much hassle. + Note that if we are doing byte comparisons with the challenge in collectedClientData + later on, that value will not have padding, so we must remove the padding + before storing the value in the session. + """ + # If we know Python 3.6 or greater is available, we could replace this with one + # call to secrets.token_urlsafe + challenge_bytes = os.urandom(challenge_len) + challenge_base64 = base64.urlsafe_b64encode(challenge_bytes) + # Python 2/3 compatibility: b64encode returns bytes only in newer Python versions + if not isinstance(challenge_base64, str): + challenge_base64 = challenge_base64.decode("utf-8") + return challenge_base64 diff --git a/authentik/stages/authenticator_webauthn/views.py b/authentik/stages/authenticator_webauthn/views.py new file mode 100644 index 000000000..b9a4a4c57 --- /dev/null +++ b/authentik/stages/authenticator_webauthn/views.py @@ -0,0 +1,241 @@ +"""webauthn views""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.http.response import HttpResponseBadRequest +from django.shortcuts import get_object_or_404 +from django.views import View +from django.views.generic import TemplateView +from structlog.stdlib import get_logger +from webauthn import ( + WebAuthnAssertionOptions, + WebAuthnAssertionResponse, + WebAuthnMakeCredentialOptions, + WebAuthnRegistrationResponse, + WebAuthnUser, +) +from webauthn.webauthn import ( + AuthenticationRejectedException, + RegistrationRejectedException, + WebAuthnUserDataMissing, +) + +from authentik.core.models import User +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.lib.templatetags.authentik_utils import avatar +from authentik.stages.authenticator_webauthn.models import ( + AuthenticateWebAuthnStage, + WebAuthnDevice, +) +from authentik.stages.authenticator_webauthn.stage import ( + SESSION_KEY_WEBAUTHN_AUTHENTICATED, +) +from authentik.stages.authenticator_webauthn.utils import generate_challenge + +LOGGER = get_logger() +RP_ID = "localhost" +RP_NAME = "authentik" +ORIGIN = "http://localhost:8000" + + +class FlowUserRequiredView(View): + """Base class for views which can only be called in the context of a flow.""" + + user: User + + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + plan = request.session.get(SESSION_KEY_PLAN, None) + if not plan: + return HttpResponseBadRequest() + self.user = plan.context.get(PLAN_CONTEXT_PENDING_USER) + if not self.user: + return HttpResponseBadRequest() + return super().dispatch(request, *args, **kwargs) + + +class BeginActivateView(FlowUserRequiredView): + """Initial device registration view""" + + def post(self, request: HttpRequest) -> HttpResponse: + """Initial device registration view""" + # clear session variables prior to starting a new registration + request.session.pop("challenge", None) + + challenge = generate_challenge(32) + + # We strip the saved challenge of padding, so that we can do a byte + # comparison on the URL-safe-without-padding challenge we get back + # from the browser. + # We will still pass the padded version down to the browser so that the JS + # can decode the challenge into binary without too much trouble. + request.session["challenge"] = challenge.rstrip("=") + + make_credential_options = WebAuthnMakeCredentialOptions( + challenge, + RP_NAME, + RP_ID, + self.user.uid, + self.user.username, + self.user.name, + avatar(self.user), + ) + + return JsonResponse(make_credential_options.registration_dict) + + +class VerifyCredentialInfo(FlowUserRequiredView): + """Finish device registration""" + + def post(self, request: HttpRequest) -> HttpResponse: + """Finish device registration""" + challenge = request.session["challenge"] + + registration_response = request.POST + trusted_attestation_cert_required = True + self_attestation_permitted = True + none_attestation_permitted = True + + webauthn_registration_response = WebAuthnRegistrationResponse( + RP_ID, + ORIGIN, + registration_response, + challenge, + trusted_attestation_cert_required=trusted_attestation_cert_required, + self_attestation_permitted=self_attestation_permitted, + none_attestation_permitted=none_attestation_permitted, + uv_required=False, + ) # User Verification + + try: + webauthn_credential = webauthn_registration_response.verify() + except RegistrationRejectedException as exc: + LOGGER.warning("registration failed", exc=exc) + return JsonResponse({"fail": "Registration failed. Error: {}".format(exc)}) + + # Step 17. + # + # Check that the credentialId is not yet registered to any other user. + # If registration is requested for a credential that is already registered + # to a different user, the Relying Party SHOULD fail this registration + # ceremony, or it MAY decide to accept the registration, e.g. while deleting + # the older registration. + credential_id_exists = WebAuthnDevice.objects.filter( + credential_id=webauthn_credential.credential_id + ).first() + if credential_id_exists: + return JsonResponse({"fail": "Credential ID already exists."}, status=401) + + webauthn_credential.credential_id = str( + webauthn_credential.credential_id, "utf-8" + ) + webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8") + existing_device = WebAuthnDevice.objects.filter( + credential_id=webauthn_credential.credential_id + ).first() + if not existing_device: + user = WebAuthnDevice.objects.create( + user=self.user, + public_key=webauthn_credential.public_key, + credential_id=webauthn_credential.credential_id, + sign_count=webauthn_credential.sign_count, + rp_id=RP_ID, + ) + else: + return JsonResponse({"fail": "User already exists."}, status=401) + + LOGGER.debug("Successfully registered.", user=user) + + return JsonResponse({"success": "User successfully registered."}) + + +class BeginAssertion(FlowUserRequiredView): + """Send the client a challenge that we'll check later""" + + def post(self, request: HttpRequest) -> HttpResponse: + """Send the client a challenge that we'll check later""" + request.session.pop("challenge", None) + + challenge = generate_challenge(32) + + # We strip the padding from the challenge stored in the session + # for the reasons outlined in the comment in webauthn_begin_activate. + request.session["challenge"] = challenge.rstrip("=") + + devices = WebAuthnDevice.objects.filter(user=self.user) + if not devices.exists(): + return HttpResponseBadRequest() + device: WebAuthnDevice = devices.first() + + webauthn_user = WebAuthnUser( + self.user.uid, + self.user.username, + self.user.name, + avatar(self.user), + device.credential_id, + device.public_key, + device.sign_count, + device.rp_id, + ) + + webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge) + + return JsonResponse(webauthn_assertion_options.assertion_dict) + + +class VerifyAssertion(FlowUserRequiredView): + """Verify assertion result that we've sent to the client""" + + def post(self, request: HttpRequest) -> HttpResponse: + """Verify assertion result that we've sent to the client""" + challenge = request.session.get("challenge") + assertion_response = request.POST + credential_id = assertion_response.get("id") + + device = WebAuthnDevice.objects.filter(credential_id=credential_id).first() + if not device: + return JsonResponse({"fail": "Device does not exist."}, status=401) + + webauthn_user = WebAuthnUser( + self.user.uid, + self.user.username, + self.user.name, + avatar(self.user), + device.credential_id, + device.public_key, + device.sign_count, + device.rp_id, + ) + + webauthn_assertion_response = WebAuthnAssertionResponse( + webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False + ) # User Verification + + try: + sign_count = webauthn_assertion_response.verify() + except ( + AuthenticationRejectedException, + WebAuthnUserDataMissing, + RegistrationRejectedException, + ) as exc: + return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)}) + + device.set_sign_count(sign_count) + request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True + return JsonResponse( + {"success": "Successfully authenticated as {}".format(self.user.username)} + ) + + +class UserSettingsView(LoginRequiredMixin, TemplateView): + """View for user settings to control WebAuthn devices""" + + template_name = "stages/authenticator_webauthn/user_settings.html" + + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + kwargs["devices"] = WebAuthnDevice.objects.filter(user=self.request.user) + stage = get_object_or_404( + AuthenticateWebAuthnStage, pk=self.kwargs["stage_uuid"] + ) + kwargs["stage"] = stage + return kwargs diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py index f3389a78b..a60103666 100644 --- a/authentik/stages/captcha/api.py +++ b/authentik/stages/captcha/api.py @@ -1,17 +1,17 @@ """CaptchaStage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.captcha.models import CaptchaStage -class CaptchaStageSerializer(ModelSerializer): +class CaptchaStageSerializer(StageSerializer): """CaptchaStage Serializer""" class Meta: model = CaptchaStage - fields = ["pk", "name", "public_key", "private_key"] + fields = StageSerializer.Meta.fields + ["public_key", "private_key"] class CaptchaStageViewSet(ModelViewSet): diff --git a/authentik/stages/consent/api.py b/authentik/stages/consent/api.py index 3c7f1415d..3e106c937 100644 --- a/authentik/stages/consent/api.py +++ b/authentik/stages/consent/api.py @@ -1,17 +1,17 @@ """ConsentStage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.consent.models import ConsentStage -class ConsentStageSerializer(ModelSerializer): +class ConsentStageSerializer(StageSerializer): """ConsentStage Serializer""" class Meta: model = ConsentStage - fields = ["pk", "name", "mode", "consent_expire_in"] + fields = StageSerializer.Meta.fields + ["mode", "consent_expire_in"] class ConsentStageViewSet(ModelViewSet): diff --git a/authentik/stages/dummy/api.py b/authentik/stages/dummy/api.py index fa875a9fa..3b677bbac 100644 --- a/authentik/stages/dummy/api.py +++ b/authentik/stages/dummy/api.py @@ -1,17 +1,17 @@ """DummyStage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.dummy.models import DummyStage -class DummyStageSerializer(ModelSerializer): +class DummyStageSerializer(StageSerializer): """DummyStage Serializer""" class Meta: model = DummyStage - fields = ["pk", "name"] + fields = StageSerializer.Meta.fields class DummyStageViewSet(ModelViewSet): diff --git a/authentik/stages/email/api.py b/authentik/stages/email/api.py index 01cdf3d2b..ffe5d6089 100644 --- a/authentik/stages/email/api.py +++ b/authentik/stages/email/api.py @@ -1,23 +1,21 @@ """EmailStage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.email.models import EmailStage, get_template_choices -class EmailStageSerializer(ModelSerializer): +class EmailStageSerializer(StageSerializer): """EmailStage Serializer""" - def __init__(self, *args, **kwrags): - super().__init__(*args, **kwrags) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.fields["template"].choices = get_template_choices() class Meta: model = EmailStage - fields = [ - "pk", - "name", + fields = StageSerializer.Meta.fields + [ "use_global_settings", "host", "port", diff --git a/authentik/stages/identification/api.py b/authentik/stages/identification/api.py index be08013fc..aba1629ec 100644 --- a/authentik/stages/identification/api.py +++ b/authentik/stages/identification/api.py @@ -1,19 +1,17 @@ """Identification Stage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.identification.models import IdentificationStage -class IdentificationStageSerializer(ModelSerializer): +class IdentificationStageSerializer(StageSerializer): """IdentificationStage Serializer""" class Meta: model = IdentificationStage - fields = [ - "pk", - "name", + fields = StageSerializer.Meta.fields + [ "user_fields", "case_insensitive_matching", "show_matched_user", diff --git a/authentik/stages/invitation/api.py b/authentik/stages/invitation/api.py index 9a4402453..e8aadea19 100644 --- a/authentik/stages/invitation/api.py +++ b/authentik/stages/invitation/api.py @@ -2,18 +2,17 @@ from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.invitation.models import Invitation, InvitationStage -class InvitationStageSerializer(ModelSerializer): +class InvitationStageSerializer(StageSerializer): """InvitationStage Serializer""" class Meta: model = InvitationStage - fields = [ - "pk", - "name", + fields = StageSerializer.Meta.fields + [ "continue_flow_without_invitation", ] diff --git a/authentik/stages/otp_static/api.py b/authentik/stages/otp_static/api.py deleted file mode 100644 index 120ab2f18..000000000 --- a/authentik/stages/otp_static/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""OTPStaticStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from authentik.stages.otp_static.models import OTPStaticStage - - -class OTPStaticStageSerializer(ModelSerializer): - """OTPStaticStage Serializer""" - - class Meta: - - model = OTPStaticStage - fields = ["pk", "name", "configure_flow", "token_count"] - - -class OTPStaticStageViewSet(ModelViewSet): - """OTPStaticStage Viewset""" - - queryset = OTPStaticStage.objects.all() - serializer_class = OTPStaticStageSerializer diff --git a/authentik/stages/otp_static/apps.py b/authentik/stages/otp_static/apps.py deleted file mode 100644 index 3dcb8dd63..000000000 --- a/authentik/stages/otp_static/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OTP Static stage""" -from django.apps import AppConfig - - -class AuthentikStageOTPStaticConfig(AppConfig): - """OTP Static stage""" - - name = "authentik.stages.otp_static" - label = "authentik_stages_otp_static" - verbose_name = "authentik Stages.OTP.Static" - mountpoint = "-/user/otp/static/" diff --git a/authentik/stages/otp_static/models.py b/authentik/stages/otp_static/models.py deleted file mode 100644 index c91ba317e..000000000 --- a/authentik/stages/otp_static/models.py +++ /dev/null @@ -1,50 +0,0 @@ -"""OTP Static models""" -from typing import Optional, Type - -from django.db import models -from django.forms import ModelForm -from django.shortcuts import reverse -from django.utils.translation import gettext_lazy as _ -from django.views import View -from rest_framework.serializers import BaseSerializer - -from authentik.flows.models import ConfigurableStage, Stage - - -class OTPStaticStage(ConfigurableStage, Stage): - """Generate static tokens for the user as a backup.""" - - token_count = models.IntegerField(default=6) - - @property - def serializer(self) -> BaseSerializer: - from authentik.stages.otp_static.api import OTPStaticStageSerializer - - return OTPStaticStageSerializer - - @property - def type(self) -> Type[View]: - from authentik.stages.otp_static.stage import OTPStaticStageView - - return OTPStaticStageView - - @property - def form(self) -> Type[ModelForm]: - from authentik.stages.otp_static.forms import OTPStaticStageForm - - return OTPStaticStageForm - - @property - def ui_user_settings(self) -> Optional[str]: - return reverse( - "authentik_stages_otp_static:user-settings", - kwargs={"stage_uuid": self.stage_uuid}, - ) - - def __str__(self) -> str: - return f"OTP Static Stage {self.name}" - - class Meta: - - verbose_name = _("OTP Static Setup Stage") - verbose_name_plural = _("OTP Static Setup Stages") diff --git a/authentik/stages/otp_time/api.py b/authentik/stages/otp_time/api.py deleted file mode 100644 index 7670477d7..000000000 --- a/authentik/stages/otp_time/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""OTPTimeStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from authentik.stages.otp_time.models import OTPTimeStage - - -class OTPTimeStageSerializer(ModelSerializer): - """OTPTimeStage Serializer""" - - class Meta: - - model = OTPTimeStage - fields = ["pk", "name", "configure_flow", "digits"] - - -class OTPTimeStageViewSet(ModelViewSet): - """OTPTimeStage Viewset""" - - queryset = OTPTimeStage.objects.all() - serializer_class = OTPTimeStageSerializer diff --git a/authentik/stages/otp_time/apps.py b/authentik/stages/otp_time/apps.py deleted file mode 100644 index a8e8cf362..000000000 --- a/authentik/stages/otp_time/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OTP Time""" -from django.apps import AppConfig - - -class AuthentikStageOTPTimeConfig(AppConfig): - """OTP time App config""" - - name = "authentik.stages.otp_time" - label = "authentik_stages_otp_time" - verbose_name = "authentik Stages.OTP.Time" - mountpoint = "-/user/otp/time/" diff --git a/authentik/stages/otp_validate/api.py b/authentik/stages/otp_validate/api.py deleted file mode 100644 index 6d9222939..000000000 --- a/authentik/stages/otp_validate/api.py +++ /dev/null @@ -1,24 +0,0 @@ -"""OTPValidateStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from authentik.stages.otp_validate.models import OTPValidateStage - - -class OTPValidateStageSerializer(ModelSerializer): - """OTPValidateStage Serializer""" - - class Meta: - - model = OTPValidateStage - fields = [ - "pk", - "name", - ] - - -class OTPValidateStageViewSet(ModelViewSet): - """OTPValidateStage Viewset""" - - queryset = OTPValidateStage.objects.all() - serializer_class = OTPValidateStageSerializer diff --git a/authentik/stages/otp_validate/apps.py b/authentik/stages/otp_validate/apps.py deleted file mode 100644 index 92b558cf0..000000000 --- a/authentik/stages/otp_validate/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""OTP Validation Stage""" -from django.apps import AppConfig - - -class AuthentikStageOTPValidateConfig(AppConfig): - """OTP Validation Stage""" - - name = "authentik.stages.otp_validate" - label = "authentik_stages_otp_validate" - verbose_name = "authentik Stages.OTP.Validate" diff --git a/authentik/stages/otp_validate/models.py b/authentik/stages/otp_validate/models.py deleted file mode 100644 index 33e58988f..000000000 --- a/authentik/stages/otp_validate/models.py +++ /dev/null @@ -1,44 +0,0 @@ -"""OTP Validation Stage""" -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext_lazy as _ -from django.views import View -from rest_framework.serializers import BaseSerializer - -from authentik.flows.models import NotConfiguredAction, Stage - - -class OTPValidateStage(Stage): - """Validate user's configured OTP Device.""" - - not_configured_action = models.TextField( - choices=NotConfiguredAction.choices, default=NotConfiguredAction.SKIP - ) - - @property - def serializer(self) -> BaseSerializer: - from authentik.stages.otp_validate.api import OTPValidateStageSerializer - - return OTPValidateStageSerializer - - @property - def type(self) -> Type[View]: - from authentik.stages.otp_validate.stage import OTPValidateStageView - - return OTPValidateStageView - - @property - def form(self) -> Type[ModelForm]: - from authentik.stages.otp_validate.forms import OTPValidateStageForm - - return OTPValidateStageForm - - def __str__(self) -> str: - return f"OTP Validation Stage {self.name}" - - class Meta: - - verbose_name = _("OTP Validation Stage") - verbose_name_plural = _("OTP Validation Stages") diff --git a/authentik/stages/password/api.py b/authentik/stages/password/api.py index edce69f3e..e5ca7dc22 100644 --- a/authentik/stages/password/api.py +++ b/authentik/stages/password/api.py @@ -1,19 +1,17 @@ """PasswordStage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.password.models import PasswordStage -class PasswordStageSerializer(ModelSerializer): +class PasswordStageSerializer(StageSerializer): """PasswordStage Serializer""" class Meta: model = PasswordStage - fields = [ - "pk", - "name", + fields = StageSerializer.Meta.fields + [ "backends", "configure_flow", "failed_attempts_before_cancel", diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py index d5618c9b1..a04f021a8 100644 --- a/authentik/stages/prompt/api.py +++ b/authentik/stages/prompt/api.py @@ -3,10 +3,11 @@ from rest_framework.serializers import CharField, ModelSerializer from rest_framework.validators import UniqueValidator from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.prompt.models import Prompt, PromptStage -class PromptStageSerializer(ModelSerializer): +class PromptStageSerializer(StageSerializer): """PromptStage Serializer""" name = CharField(validators=[UniqueValidator(queryset=PromptStage.objects.all())]) @@ -14,9 +15,7 @@ class PromptStageSerializer(ModelSerializer): class Meta: model = PromptStage - fields = [ - "pk", - "name", + fields = StageSerializer.Meta.fields + [ "fields", "validation_policies", ] diff --git a/authentik/stages/user_delete/api.py b/authentik/stages/user_delete/api.py index ef283ae38..0f60f1479 100644 --- a/authentik/stages/user_delete/api.py +++ b/authentik/stages/user_delete/api.py @@ -1,20 +1,17 @@ """User Delete Stage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.user_delete.models import UserDeleteStage -class UserDeleteStageSerializer(ModelSerializer): +class UserDeleteStageSerializer(StageSerializer): """UserDeleteStage Serializer""" class Meta: model = UserDeleteStage - fields = [ - "pk", - "name", - ] + fields = StageSerializer.Meta.fields class UserDeleteStageViewSet(ModelViewSet): diff --git a/authentik/stages/user_login/api.py b/authentik/stages/user_login/api.py index 43e29f105..406267fb0 100644 --- a/authentik/stages/user_login/api.py +++ b/authentik/stages/user_login/api.py @@ -1,19 +1,17 @@ """Login Stage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.user_login.models import UserLoginStage -class UserLoginStageSerializer(ModelSerializer): +class UserLoginStageSerializer(StageSerializer): """UserLoginStage Serializer""" class Meta: model = UserLoginStage - fields = [ - "pk", - "name", + fields = StageSerializer.Meta.fields + [ "session_duration", ] diff --git a/authentik/stages/user_logout/api.py b/authentik/stages/user_logout/api.py index 8467b7a70..8d67c7d99 100644 --- a/authentik/stages/user_logout/api.py +++ b/authentik/stages/user_logout/api.py @@ -1,20 +1,17 @@ """Logout Stage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.user_logout.models import UserLogoutStage -class UserLogoutStageSerializer(ModelSerializer): +class UserLogoutStageSerializer(StageSerializer): """UserLogoutStage Serializer""" class Meta: model = UserLogoutStage - fields = [ - "pk", - "name", - ] + fields = StageSerializer.Meta.fields class UserLogoutStageViewSet(ModelViewSet): diff --git a/authentik/stages/user_write/api.py b/authentik/stages/user_write/api.py index 0e2833e02..121285eb6 100644 --- a/authentik/stages/user_write/api.py +++ b/authentik/stages/user_write/api.py @@ -1,20 +1,17 @@ """User Write Stage API Views""" -from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet +from authentik.flows.api import StageSerializer from authentik.stages.user_write.models import UserWriteStage -class UserWriteStageSerializer(ModelSerializer): +class UserWriteStageSerializer(StageSerializer): """UserWriteStage Serializer""" class Meta: model = UserWriteStage - fields = [ - "pk", - "name", - ] + fields = StageSerializer.Meta.fields class UserWriteStageViewSet(ModelViewSet): diff --git a/lifecycle/system_migrations/to_2021_3_authenticator.py b/lifecycle/system_migrations/to_2021_3_authenticator.py new file mode 100644 index 000000000..607d2e2c4 --- /dev/null +++ b/lifecycle/system_migrations/to_2021_3_authenticator.py @@ -0,0 +1,29 @@ +# flake8: noqa +from lifecycle.migrate import BaseMigration + +SQL_STATEMENT = """BEGIN TRANSACTION; +ALTER TABLE authentik_stages_otp_static_otpstaticstage RENAME TO authentik_stages_authenticator_static_otpstaticstage; +UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_static', 'authentik_stages_authenticator_static'); +UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_static', 'authentik_stages_authenticator_static'); + +ALTER TABLE authentik_stages_otp_time_otptimestage RENAME TO authentik_stages_authenticator_totp_otptimestage; +UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_time', 'authentik_stages_authenticator_totp'); +UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_time', 'authentik_stages_authenticator_totp'); + +ALTER TABLE authentik_stages_otp_validate_otpvalidatestage RENAME TO authentik_stages_authenticator_validate_otpvalidatestage; +UPDATE django_migrations SET app = replace(app, 'authentik_stages_otp_validate', 'authentik_stages_authenticator_validate'); +UPDATE django_content_type SET app_label = replace(app_label, 'authentik_stages_otp_validate', 'authentik_stages_authenticator_validate'); + +END TRANSACTION;""" + + +class Migration(BaseMigration): + def needs_migration(self) -> bool: + self.cur.execute( + "select * from information_schema.tables where table_name = 'authentik_stages_otp_static_otpstaticstage';" + ) + return bool(self.cur.rowcount) + + def run(self): + self.cur.execute(SQL_STATEMENT) + self.con.commit() diff --git a/swagger.yaml b/swagger.yaml index ea37c08f0..e6b8005c1 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -5420,6 +5420,42 @@ paths: tags: - stages parameters: [] + /stages/all/types/: + get: + operationId: stages_all_types + description: Get all creatable stage types + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: Types of an object that can be created + schema: + description: '' + type: array + items: + $ref: '#/definitions/TypeCreate' + tags: + - stages + parameters: [] /stages/all/{stage_uuid}/: get: operationId: stages_all_read @@ -5439,6 +5475,514 @@ paths: required: true type: string format: uuid + /stages/authenticator/static/: + get: + operationId: stages_authenticator_static_list + description: AuthenticatorStaticStage Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/AuthenticatorStaticStage' + tags: + - stages + post: + operationId: stages_authenticator_static_create + description: AuthenticatorStaticStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorStaticStage' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/AuthenticatorStaticStage' + tags: + - stages + parameters: [] + /stages/authenticator/static/{stage_uuid}/: + get: + operationId: stages_authenticator_static_read + description: AuthenticatorStaticStage Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorStaticStage' + tags: + - stages + put: + operationId: stages_authenticator_static_update + description: AuthenticatorStaticStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorStaticStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorStaticStage' + tags: + - stages + patch: + operationId: stages_authenticator_static_partial_update + description: AuthenticatorStaticStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorStaticStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorStaticStage' + tags: + - stages + delete: + operationId: stages_authenticator_static_delete + description: AuthenticatorStaticStage Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - stages + parameters: + - name: stage_uuid + in: path + description: A UUID string identifying this Static Authenticator Stage. + required: true + type: string + format: uuid + /stages/authenticator/totp/: + get: + operationId: stages_authenticator_totp_list + description: AuthenticatorTOTPStage Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/AuthenticatorTOTPStage' + tags: + - stages + post: + operationId: stages_authenticator_totp_create + description: AuthenticatorTOTPStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorTOTPStage' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/AuthenticatorTOTPStage' + tags: + - stages + parameters: [] + /stages/authenticator/totp/{stage_uuid}/: + get: + operationId: stages_authenticator_totp_read + description: AuthenticatorTOTPStage Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorTOTPStage' + tags: + - stages + put: + operationId: stages_authenticator_totp_update + description: AuthenticatorTOTPStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorTOTPStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorTOTPStage' + tags: + - stages + patch: + operationId: stages_authenticator_totp_partial_update + description: AuthenticatorTOTPStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorTOTPStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorTOTPStage' + tags: + - stages + delete: + operationId: stages_authenticator_totp_delete + description: AuthenticatorTOTPStage Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - stages + parameters: + - name: stage_uuid + in: path + description: A UUID string identifying this TOTP Authenticator Setup Stage. + required: true + type: string + format: uuid + /stages/authenticator/validate/: + get: + operationId: stages_authenticator_validate_list + description: AuthenticatorValidateStage Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/AuthenticatorValidateStage' + tags: + - stages + post: + operationId: stages_authenticator_validate_create + description: AuthenticatorValidateStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorValidateStage' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/AuthenticatorValidateStage' + tags: + - stages + parameters: [] + /stages/authenticator/validate/{stage_uuid}/: + get: + operationId: stages_authenticator_validate_read + description: AuthenticatorValidateStage Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorValidateStage' + tags: + - stages + put: + operationId: stages_authenticator_validate_update + description: AuthenticatorValidateStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorValidateStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorValidateStage' + tags: + - stages + patch: + operationId: stages_authenticator_validate_partial_update + description: AuthenticatorValidateStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticatorValidateStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticatorValidateStage' + tags: + - stages + delete: + operationId: stages_authenticator_validate_delete + description: AuthenticatorValidateStage Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - stages + parameters: + - name: stage_uuid + in: path + description: A UUID string identifying this Authenticator Validation Stage. + required: true + type: string + format: uuid + /stages/authenticator/webauthn/: + get: + operationId: stages_authenticator_webauthn_list + description: AuthenticateWebAuthnStage Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/AuthenticateWebAuthnStage' + tags: + - stages + post: + operationId: stages_authenticator_webauthn_create + description: AuthenticateWebAuthnStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticateWebAuthnStage' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/AuthenticateWebAuthnStage' + tags: + - stages + parameters: [] + /stages/authenticator/webauthn/{stage_uuid}/: + get: + operationId: stages_authenticator_webauthn_read + description: AuthenticateWebAuthnStage Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticateWebAuthnStage' + tags: + - stages + put: + operationId: stages_authenticator_webauthn_update + description: AuthenticateWebAuthnStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticateWebAuthnStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticateWebAuthnStage' + tags: + - stages + patch: + operationId: stages_authenticator_webauthn_partial_update + description: AuthenticateWebAuthnStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/AuthenticateWebAuthnStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/AuthenticateWebAuthnStage' + tags: + - stages + delete: + operationId: stages_authenticator_webauthn_delete + description: AuthenticateWebAuthnStage Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - stages + parameters: + - name: stage_uuid + in: path + description: A UUID string identifying this WebAuthn Authenticator Setup Stage. + required: true + type: string + format: uuid /stages/captcha/: get: operationId: stages_captcha_list @@ -6328,387 +6872,6 @@ paths: required: true type: string format: uuid - /stages/otp_static/: - get: - operationId: stages_otp_static_list - description: OTPStaticStage Viewset - parameters: - - name: ordering - in: query - description: Which field to use when ordering the results. - required: false - type: string - - name: search - in: query - description: A search term. - required: false - type: string - - name: page - in: query - description: A page number within the paginated result set. - required: false - type: integer - - name: page_size - in: query - description: Number of results to return per page. - required: false - type: integer - responses: - '200': - description: '' - schema: - required: - - count - - results - type: object - properties: - count: - type: integer - next: - type: string - format: uri - x-nullable: true - previous: - type: string - format: uri - x-nullable: true - results: - type: array - items: - $ref: '#/definitions/OTPStaticStage' - tags: - - stages - post: - operationId: stages_otp_static_create - description: OTPStaticStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPStaticStage' - responses: - '201': - description: '' - schema: - $ref: '#/definitions/OTPStaticStage' - tags: - - stages - parameters: [] - /stages/otp_static/{stage_uuid}/: - get: - operationId: stages_otp_static_read - description: OTPStaticStage Viewset - parameters: [] - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPStaticStage' - tags: - - stages - put: - operationId: stages_otp_static_update - description: OTPStaticStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPStaticStage' - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPStaticStage' - tags: - - stages - patch: - operationId: stages_otp_static_partial_update - description: OTPStaticStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPStaticStage' - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPStaticStage' - tags: - - stages - delete: - operationId: stages_otp_static_delete - description: OTPStaticStage Viewset - parameters: [] - responses: - '204': - description: '' - tags: - - stages - parameters: - - name: stage_uuid - in: path - description: A UUID string identifying this OTP Static Setup Stage. - required: true - type: string - format: uuid - /stages/otp_time/: - get: - operationId: stages_otp_time_list - description: OTPTimeStage Viewset - parameters: - - name: ordering - in: query - description: Which field to use when ordering the results. - required: false - type: string - - name: search - in: query - description: A search term. - required: false - type: string - - name: page - in: query - description: A page number within the paginated result set. - required: false - type: integer - - name: page_size - in: query - description: Number of results to return per page. - required: false - type: integer - responses: - '200': - description: '' - schema: - required: - - count - - results - type: object - properties: - count: - type: integer - next: - type: string - format: uri - x-nullable: true - previous: - type: string - format: uri - x-nullable: true - results: - type: array - items: - $ref: '#/definitions/OTPTimeStage' - tags: - - stages - post: - operationId: stages_otp_time_create - description: OTPTimeStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPTimeStage' - responses: - '201': - description: '' - schema: - $ref: '#/definitions/OTPTimeStage' - tags: - - stages - parameters: [] - /stages/otp_time/{stage_uuid}/: - get: - operationId: stages_otp_time_read - description: OTPTimeStage Viewset - parameters: [] - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPTimeStage' - tags: - - stages - put: - operationId: stages_otp_time_update - description: OTPTimeStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPTimeStage' - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPTimeStage' - tags: - - stages - patch: - operationId: stages_otp_time_partial_update - description: OTPTimeStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPTimeStage' - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPTimeStage' - tags: - - stages - delete: - operationId: stages_otp_time_delete - description: OTPTimeStage Viewset - parameters: [] - responses: - '204': - description: '' - tags: - - stages - parameters: - - name: stage_uuid - in: path - description: A UUID string identifying this OTP Time (TOTP) Setup Stage. - required: true - type: string - format: uuid - /stages/otp_validate/: - get: - operationId: stages_otp_validate_list - description: OTPValidateStage Viewset - parameters: - - name: ordering - in: query - description: Which field to use when ordering the results. - required: false - type: string - - name: search - in: query - description: A search term. - required: false - type: string - - name: page - in: query - description: A page number within the paginated result set. - required: false - type: integer - - name: page_size - in: query - description: Number of results to return per page. - required: false - type: integer - responses: - '200': - description: '' - schema: - required: - - count - - results - type: object - properties: - count: - type: integer - next: - type: string - format: uri - x-nullable: true - previous: - type: string - format: uri - x-nullable: true - results: - type: array - items: - $ref: '#/definitions/OTPValidateStage' - tags: - - stages - post: - operationId: stages_otp_validate_create - description: OTPValidateStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPValidateStage' - responses: - '201': - description: '' - schema: - $ref: '#/definitions/OTPValidateStage' - tags: - - stages - parameters: [] - /stages/otp_validate/{stage_uuid}/: - get: - operationId: stages_otp_validate_read - description: OTPValidateStage Viewset - parameters: [] - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPValidateStage' - tags: - - stages - put: - operationId: stages_otp_validate_update - description: OTPValidateStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPValidateStage' - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPValidateStage' - tags: - - stages - patch: - operationId: stages_otp_validate_partial_update - description: OTPValidateStage Viewset - parameters: - - name: data - in: body - required: true - schema: - $ref: '#/definitions/OTPValidateStage' - responses: - '200': - description: '' - schema: - $ref: '#/definitions/OTPValidateStage' - tags: - - stages - delete: - operationId: stages_otp_validate_delete - description: OTPValidateStage Viewset - parameters: [] - responses: - '204': - description: '' - tags: - - stages - parameters: - - name: stage_uuid - in: path - description: A UUID string identifying this OTP Validation Stage. - required: true - type: string - format: uuid /stages/password/: get: operationId: stages_password_list @@ -8191,14 +8354,18 @@ definitions: title: Name type: string minLength: 1 - __type__: - title: 'type ' + object_type: + title: Object type type: string readOnly: true verbose_name: title: Verbose name type: string readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true FlowStageBinding: description: FlowStageBinding Serializer required: @@ -9117,9 +9284,10 @@ definitions: - authentik.stages.user_login - authentik.stages.user_logout - authentik.stages.user_write - - authentik.stages.otp_static - - authentik.stages.otp_time - - authentik.stages.otp_validate + - authentik.stages.authenticator_static + - authentik.stages.authenticator_totp + - authentik.stages.authenticator_validate + - authentik.stages.authenticator_webauthn - authentik.stages.password - authentik.managed - authentik.core @@ -10316,6 +10484,152 @@ definitions: \ log out manually. (Format: hours=1;minutes=2;seconds=3)." type: string minLength: 1 + AuthenticatorStaticStage: + description: AuthenticatorStaticStage Serializer + required: + - name + type: object + properties: + pk: + title: Stage uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true + configure_flow: + title: Configure flow + description: Flow used by an authenticated user to configure this Stage. If + empty, user will not be able to configure this stage. + type: string + format: uuid + x-nullable: true + token_count: + title: Token count + type: integer + maximum: 2147483647 + minimum: -2147483648 + AuthenticatorTOTPStage: + description: AuthenticatorTOTPStage Serializer + required: + - name + - digits + type: object + properties: + pk: + title: Stage uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true + configure_flow: + title: Configure flow + description: Flow used by an authenticated user to configure this Stage. If + empty, user will not be able to configure this stage. + type: string + format: uuid + x-nullable: true + digits: + title: Digits + type: integer + enum: + - 6 + - 8 + AuthenticatorValidateStage: + description: AuthenticatorValidateStage Serializer + required: + - name + type: object + properties: + pk: + title: Stage uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true + not_configured_action: + title: Not configured action + type: string + enum: + - skip + device_classes: + description: '' + type: array + items: + title: Device classes + type: string + minLength: 1 + AuthenticateWebAuthnStage: + description: AuthenticateWebAuthnStage Serializer + required: + - name + type: object + properties: + pk: + title: Stage uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true CaptchaStage: description: CaptchaStage Serializer required: @@ -10333,6 +10647,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true public_key: title: Public key description: Public key, acquired from https://www.google.com/recaptcha/intro/v3.html @@ -10358,6 +10684,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true mode: title: Mode type: string @@ -10385,6 +10723,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true EmailStage: description: EmailStage Serializer required: @@ -10400,6 +10750,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true use_global_settings: title: Use global settings description: When enabled, global Email connection settings will be used and @@ -10470,6 +10832,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true user_fields: description: '' type: array @@ -10524,6 +10898,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true continue_flow_without_invitation: title: Continue flow without invitation description: If this flag is set, this Stage will jump to the next Stage when @@ -10548,77 +10934,6 @@ definitions: title: Fixed data description: Optional fixed data to enforce on user enrollment. type: object - OTPStaticStage: - description: OTPStaticStage Serializer - required: - - name - type: object - properties: - pk: - title: Stage uuid - type: string - format: uuid - readOnly: true - name: - title: Name - type: string - minLength: 1 - configure_flow: - title: Configure flow - description: Flow used by an authenticated user to configure this Stage. If - empty, user will not be able to configure this stage. - type: string - format: uuid - x-nullable: true - token_count: - title: Token count - type: integer - maximum: 2147483647 - minimum: -2147483648 - OTPTimeStage: - description: OTPTimeStage Serializer - required: - - name - - digits - type: object - properties: - pk: - title: Stage uuid - type: string - format: uuid - readOnly: true - name: - title: Name - type: string - minLength: 1 - configure_flow: - title: Configure flow - description: Flow used by an authenticated user to configure this Stage. If - empty, user will not be able to configure this stage. - type: string - format: uuid - x-nullable: true - digits: - title: Digits - type: integer - enum: - - 6 - - 8 - OTPValidateStage: - description: OTPValidateStage Serializer - required: - - name - type: object - properties: - pk: - title: Stage uuid - type: string - format: uuid - readOnly: true - name: - title: Name - type: string - minLength: 1 PasswordStage: description: PasswordStage Serializer required: @@ -10635,6 +10950,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true backends: description: '' type: array @@ -10723,6 +11050,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true fields: type: array items: @@ -10750,6 +11089,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true UserLoginStage: description: UserLoginStage Serializer required: @@ -10765,6 +11116,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true session_duration: title: Session duration description: 'Determines how long a session lasts. Default of 0 means that @@ -10786,6 +11149,18 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true UserWriteStage: description: UserWriteStage Serializer required: @@ -10801,3 +11176,15 @@ definitions: title: Name type: string minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true diff --git a/tests/e2e/test_flows_otp.py b/tests/e2e/test_flows_authenticators.py similarity index 88% rename from tests/e2e/test_flows_otp.py rename to tests/e2e/test_flows_authenticators.py index 113acc33e..632454932 100644 --- a/tests/e2e/test_flows_otp.py +++ b/tests/e2e/test_flows_authenticators.py @@ -13,18 +13,18 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from authentik.flows.models import Flow, FlowStageBinding -from authentik.stages.otp_static.models import OTPStaticStage -from authentik.stages.otp_time.models import OTPTimeStage -from authentik.stages.otp_validate.models import OTPValidateStage +from authentik.stages.authenticator_static.models import AuthenticatorStaticStage +from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage from tests.e2e.utils import USER, SeleniumTestCase, retry @skipUnless(platform.startswith("linux"), "requires local docker") -class TestFlowsOTP(SeleniumTestCase): +class TestFlowsAuthenticator(SeleniumTestCase): """test flow with otp stages""" @retry() - def test_otp_validate(self): + def test_totp_validate(self): """test flow with otp stages""" sleep(1) # Setup TOTP Device @@ -32,10 +32,8 @@ class TestFlowsOTP(SeleniumTestCase): device = TOTPDevice.objects.create(user=user, confirmed=True, digits=6) flow: Flow = Flow.objects.get(slug="default-authentication-flow") - # Move the user_login stage to order 3 - FlowStageBinding.objects.filter(target=flow, order=2).update(order=3) FlowStageBinding.objects.create( - target=flow, order=2, stage=OTPValidateStage.objects.create() + target=flow, order=30, stage=AuthenticatorValidateStage.objects.create() ) self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/") @@ -53,7 +51,7 @@ class TestFlowsOTP(SeleniumTestCase): self.assert_user(USER()) @retry() - def test_otp_totp_setup(self): + def test_totp_setup(self): """test TOTP Setup stage""" flow: Flow = Flow.objects.get(slug="default-authentication-flow") @@ -69,7 +67,7 @@ class TestFlowsOTP(SeleniumTestCase): self.driver.get( self.url( "authentik_flows:configure", - stage_uuid=OTPTimeStage.objects.first().stage_uuid, + stage_uuid=AuthenticatorTOTPStage.objects.first().stage_uuid, ) ) @@ -96,7 +94,7 @@ class TestFlowsOTP(SeleniumTestCase): self.assertTrue(TOTPDevice.objects.filter(user=USER(), confirmed=True).exists()) @retry() - def test_otp_static_setup(self): + def test_static_setup(self): """test Static OTP Setup stage""" flow: Flow = Flow.objects.get(slug="default-authentication-flow") @@ -112,7 +110,7 @@ class TestFlowsOTP(SeleniumTestCase): self.driver.get( self.url( "authentik_flows:configure", - stage_uuid=OTPStaticStage.objects.first().stage_uuid, + stage_uuid=AuthenticatorStaticStage.objects.first().stage_uuid, ) ) diff --git a/web/package-lock.json b/web/package-lock.json index b985d8ff9..0b1c8e89d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -725,6 +725,11 @@ } } }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/web/package.json b/web/package.json index be0abbcae..20edbb62e 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@sentry/tracing": "^6.1.0", "@types/chart.js": "^2.9.30", "@types/codemirror": "0.0.108", + "base64-js": "^1.5.1", "chart.js": "^2.9.4", "codemirror": "^5.59.2", "construct-style-sheets-polyfill": "^2.4.16", diff --git a/web/rollup.config.js b/web/rollup.config.js index 2386494d8..3e366b3f1 100644 --- a/web/rollup.config.js +++ b/web/rollup.config.js @@ -1,6 +1,5 @@ import resolve from "rollup-plugin-node-resolve"; import commonjs from "rollup-plugin-commonjs"; -import minifyHTML from "rollup-plugin-minify-html-literals"; import { terser } from "rollup-plugin-terser"; import sourcemaps from "rollup-plugin-sourcemaps"; import typescript from "@rollup/plugin-typescript"; @@ -38,7 +37,6 @@ export default [ resolve({ browser: true }), commonjs(), sourcemaps(), - minifyHTML(), terser(), copy({ targets: [...resources], diff --git a/web/src/api/Client.ts b/web/src/api/Client.ts index 581e94732..dfb74c42f 100644 --- a/web/src/api/Client.ts +++ b/web/src/api/Client.ts @@ -49,7 +49,7 @@ export class Client { .then((r) => r); } - update(url: string[], body: T, query?: QueryArguments): Promise { + private writeRequest(url: string[], body: T, method: string, query?: QueryArguments): Promise { const finalUrl = this.makeUrl(url, query); const csrftoken = getCookie("authentik_csrf"); const request = new Request(finalUrl, { @@ -57,10 +57,10 @@ export class Client { "Accept": "application/json", "Content-Type": "application/json", "X-CSRFToken": csrftoken, - }, + }, }); return fetch(request, { - method: "PATCH", + method: method, mode: "same-origin", body: JSON.stringify(body), }) @@ -78,6 +78,10 @@ export class Client { .then((r) => r.json()) .then((r) => r); } + + update(url: string[], body: T, query?: QueryArguments): Promise { + return this.writeRequest(url, body, "PATCH", query); + } } export const DefaultClient = new Client(); diff --git a/web/src/api/Flows.ts b/web/src/api/Flows.ts index 0084f36a9..18f604e2b 100644 --- a/web/src/api/Flows.ts +++ b/web/src/api/Flows.ts @@ -1,4 +1,5 @@ import { DefaultClient, AKResponse, QueryArguments } from "./Client"; +import { TypeCreate } from "./Providers"; export enum FlowDesignation { Authentication = "authentication", @@ -57,6 +58,14 @@ export class Stage { constructor() { throw Error(); } + + static getTypes(): Promise { + return DefaultClient.fetch(["stages", "all", "types"]); + } + + static adminUrl(rest: string): string { + return `/administration/stages/${rest}`; + } } export class FlowStageBinding { diff --git a/web/src/authentik.css b/web/src/authentik.css index 5fda3c5dd..c052df2bf 100644 --- a/web/src/authentik.css +++ b/web/src/authentik.css @@ -250,4 +250,10 @@ select[multiple] { .pf-c-notification-drawer__header { background-color: var(--ak-dark-background-lighter); } + /* data list */ + .pf-c-data-list__item { + --pf-c-data-list__item--BackgroundColor: var(--ak-dark-background-light); + --pf-c-data-list__item--BorderBottomColor: var(--ak-dark-background-lighter); + color: var(--ak-dark-foreground); + } } diff --git a/web/src/elements/policies/BoundPoliciesList.ts b/web/src/elements/policies/BoundPoliciesList.ts index 4b5798194..1e33101e8 100644 --- a/web/src/elements/policies/BoundPoliciesList.ts +++ b/web/src/elements/policies/BoundPoliciesList.ts @@ -56,7 +56,7 @@ export class BoundPoliciesList extends Table { ${gettext("Edit")}
-   + ${gettext("Delete")} diff --git a/web/src/elements/stages/authenticator_webauthn/WebAuthnAuth.ts b/web/src/elements/stages/authenticator_webauthn/WebAuthnAuth.ts new file mode 100644 index 000000000..ce3266947 --- /dev/null +++ b/web/src/elements/stages/authenticator_webauthn/WebAuthnAuth.ts @@ -0,0 +1,106 @@ +import { gettext } from "django"; +import { customElement, html, LitElement, property, TemplateResult } from "lit-element"; +import { SpinnerSize } from "../../Spinner"; +import { getCredentialRequestOptionsFromServer, postAssertionToServer, transformAssertionForServer, transformCredentialRequestOptions } from "./utils"; + +@customElement("ak-stage-webauthn-auth") +export class WebAuthnAuth extends LitElement { + + @property({ type: Boolean }) + authenticateRunning = false; + + @property() + authenticateMessage = ""; + + async authenticate(): Promise { + // post the login data to the server to retrieve the PublicKeyCredentialRequestOptions + let credentialRequestOptionsFromServer; + try { + credentialRequestOptionsFromServer = await getCredentialRequestOptionsFromServer(); + } catch (err) { + throw new Error(gettext(`Error when getting request options from server: ${err}`)); + } + + // convert certain members of the PublicKeyCredentialRequestOptions into + // byte arrays as expected by the spec. + const transformedCredentialRequestOptions = transformCredentialRequestOptions( + credentialRequestOptionsFromServer); + + // request the authenticator to create an assertion signature using the + // credential private key + let assertion; + try { + assertion = await navigator.credentials.get({ + publicKey: transformedCredentialRequestOptions, + }); + if (!assertion) { + throw new Error(gettext("Assertions is empty")); + } + } catch (err) { + throw new Error(gettext(`Error when creating credential: ${err}`)); + } + + // we now have an authentication assertion! encode the byte arrays contained + // in the assertion data as strings for posting to the server + const transformedAssertionForServer = transformAssertionForServer(assertion); + + // post the assertion to the server for verification. + try { + await postAssertionToServer(transformedAssertionForServer); + } catch (err) { + throw new Error(gettext(`Error when validating assertion on server: ${err}`)); + } + + this.finishStage(); + } + + finishStage(): void { + // Mark this stage as done + this.dispatchEvent( + new CustomEvent("ak-flow-submit", { + bubbles: true, + composed: true, + }) + ); + } + + firstUpdated(): void { + this.authenticateWrapper(); + } + + async authenticateWrapper(): Promise { + if (this.authenticateRunning) { + return; + } + this.authenticateRunning = true; + this.authenticate().catch((e) => { + console.error(gettext(e)); + this.authenticateMessage = e.toString(); + }).finally(() => { + this.authenticateRunning = false; + }); + } + + render(): TemplateResult { + return html`
+ ${this.authenticateRunning ? + html`
+
+
+ +
+
+
`: + html` +
+

${this.authenticateMessage}

+ +
`} +
`; + } + +} diff --git a/web/src/elements/stages/authenticator_webauthn/WebAuthnRegister.ts b/web/src/elements/stages/authenticator_webauthn/WebAuthnRegister.ts new file mode 100644 index 000000000..393bccdf2 --- /dev/null +++ b/web/src/elements/stages/authenticator_webauthn/WebAuthnRegister.ts @@ -0,0 +1,113 @@ +import { gettext } from "django"; +import { customElement, html, LitElement, property, TemplateResult } from "lit-element"; +import { SpinnerSize } from "../../Spinner"; +import { getCredentialCreateOptionsFromServer, postNewAssertionToServer, transformCredentialCreateOptions, transformNewAssertionForServer } from "./utils"; + +@customElement("ak-stage-webauthn-register") +export class WebAuthnRegister extends LitElement { + + @property({type: Boolean}) + registerRunning = false; + + @property() + registerMessage = ""; + + createRenderRoot(): Element | ShadowRoot { + return this; + } + + async register(): Promise { + // post the data to the server to generate the PublicKeyCredentialCreateOptions + let credentialCreateOptionsFromServer; + try { + credentialCreateOptionsFromServer = await getCredentialCreateOptionsFromServer(); + } catch (err) { + throw new Error(gettext(`Failed to generate credential request options: ${err}`)); + } + + // convert certain members of the PublicKeyCredentialCreateOptions into + // byte arrays as expected by the spec. + const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(credentialCreateOptionsFromServer); + + // request the authenticator(s) to create a new credential keypair. + let credential; + try { + credential = await navigator.credentials.create({ + publicKey: publicKeyCredentialCreateOptions + }); + if (!credential) { + throw new Error("Credential is empty"); + } + } catch (err) { + throw new Error(gettext(`Error creating credential: ${err}`)); + } + + // we now have a new credential! We now need to encode the byte arrays + // in the credential into strings, for posting to our server. + const newAssertionForServer = transformNewAssertionForServer(credential); + + // post the transformed credential data to the server for validation + // and storing the public key + try { + await postNewAssertionToServer(newAssertionForServer); + } catch (err) { + throw new Error(gettext(`Server validation of credential failed: ${err}`)); + } + this.finishStage(); + } + + async registerWrapper(): Promise { + if (this.registerRunning) { + return; + } + this.registerRunning = true; + this.register().catch((e) => { + console.error(e); + this.registerMessage = e.toString(); + }).finally(() => { + this.registerRunning = false; + }); + } + + finishStage(): void { + // Mark this stage as done + this.dispatchEvent( + new CustomEvent("ak-flow-submit", { + bubbles: true, + composed: true, + }) + ); + } + + firstUpdated(): void { + this.registerWrapper(); + } + + render(): TemplateResult { + return html`
+ ${this.registerRunning ? + html`
+
+
+ +
+
+
`: + html` +
+

${this.registerMessage}

+ + +
`} +
`; + } + +} diff --git a/web/src/elements/stages/authenticator_webauthn/utils.ts b/web/src/elements/stages/authenticator_webauthn/utils.ts new file mode 100644 index 000000000..686f0e441 --- /dev/null +++ b/web/src/elements/stages/authenticator_webauthn/utils.ts @@ -0,0 +1,201 @@ +import * as base64js from "base64-js"; + +export function b64enc(buf: Uint8Array): string { + return base64js.fromByteArray(buf) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +export function b64RawEnc(buf: Uint8Array): string { + return base64js.fromByteArray(buf) + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +export function hexEncode(buf: Uint8Array): string { + return Array.from(buf) + .map(function (x) { + return ("0" + x.toString(16)).substr(-2); + }) + .join(""); +} + +export interface GenericResponse { + fail?: string; + success?: string; + [key: string]: string | number | GenericResponse | undefined; +} + +async function fetchJSON(url: string, options: RequestInit): Promise { + const response = await fetch(url, options); + const body = await response.json(); + if (body.fail) + throw body.fail; + return body; +} + +/** + * Transforms items in the credentialCreateOptions generated on the server + * into byte arrays expected by the navigator.credentials.create() call + */ +export function transformCredentialCreateOptions(credentialCreateOptions: PublicKeyCredentialCreationOptions): PublicKeyCredentialCreationOptions { + const user = credentialCreateOptions.user; + user.id = u8arr(credentialCreateOptions.user.id.toString()); + const challenge = u8arr(credentialCreateOptions.challenge.toString()); + + const transformedCredentialCreateOptions = Object.assign( + {}, credentialCreateOptions, + { challenge, user }); + + return transformedCredentialCreateOptions; +} + +export interface Assertion { + id: string; + rawId: string; + type: string; + attObj: string; + clientData: string; + registrationClientExtensions: string; +} + +/** + * Transforms the binary data in the credential into base64 strings + * for posting to the server. + * @param {PublicKeyCredential} newAssertion + */ +export function transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion { + const attObj = new Uint8Array( + (newAssertion.response).attestationObject); + const clientDataJSON = new Uint8Array( + newAssertion.response.clientDataJSON); + const rawId = new Uint8Array( + newAssertion.rawId); + + const registrationClientExtensions = newAssertion.getClientExtensionResults(); + return { + id: newAssertion.id, + rawId: b64enc(rawId), + type: newAssertion.type, + attObj: b64enc(attObj), + clientData: b64enc(clientDataJSON), + registrationClientExtensions: JSON.stringify(registrationClientExtensions) + }; +} + +/** + * Post the assertion to the server for validation and logging the user in. + * @param {Object} assertionDataForServer + */ +export async function postNewAssertionToServer(assertionDataForServer: Assertion): Promise { + const formData = new FormData(); + Object.entries(assertionDataForServer).forEach(([key, value]) => { + formData.set(key, value); + }); + + return await fetchJSON( + "/-/user/authenticator/webauthn/verify-credential-info/", { + method: "POST", + body: formData + }); +} + +/** + * Get PublicKeyCredentialRequestOptions for this user from the server + * formData of the registration form + * @param {FormData} formData + */ +export async function getCredentialCreateOptionsFromServer(): Promise { + return await fetchJSON( + "/-/user/authenticator/webauthn/begin-activate/", + { + method: "POST", + } + ); +} + + +/** + * Get PublicKeyCredentialRequestOptions for this user from the server + * formData of the registration form + * @param {FormData} formData + */ +export async function getCredentialRequestOptionsFromServer(): Promise { + return await fetchJSON( + "/-/user/authenticator/webauthn/begin-assertion/", + { + method: "POST", + } + ); +} + +function u8arr(input: string): Uint8Array { + return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), c => c.charCodeAt(0)); +} + +export function transformCredentialRequestOptions(credentialRequestOptions: PublicKeyCredentialRequestOptions): PublicKeyCredentialRequestOptions { + const challenge = u8arr(credentialRequestOptions.challenge.toString()); + + const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(credentialDescriptor => { + const id = u8arr(credentialDescriptor.id.toString()); + return Object.assign({}, credentialDescriptor, { id }); + }); + + const transformedCredentialRequestOptions = Object.assign( + {}, + credentialRequestOptions, + { challenge, allowCredentials }); + + return transformedCredentialRequestOptions; +} + +export interface AuthAssertion { + id: string; + rawId: string; + type: string; + clientData: string; + authData: string; + signature: string; + assertionClientExtensions: string; +} + +/** + * Encodes the binary data in the assertion into strings for posting to the server. + * @param {PublicKeyCredential} newAssertion + */ +export function transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion{ + const response = newAssertion.response; + const authData = new Uint8Array(response.authenticatorData); + const clientDataJSON = new Uint8Array(response.clientDataJSON); + const rawId = new Uint8Array(newAssertion.rawId); + const sig = new Uint8Array(response.signature); + const assertionClientExtensions = newAssertion.getClientExtensionResults(); + + return { + id: newAssertion.id, + rawId: b64enc(rawId), + type: newAssertion.type, + authData: b64RawEnc(authData), + clientData: b64RawEnc(clientDataJSON), + signature: hexEncode(sig), + assertionClientExtensions: JSON.stringify(assertionClientExtensions) + }; +} + +/** + * Post the assertion to the server for validation and logging the user in. + * @param {Object} assertionDataForServer + */ +export async function postAssertionToServer(assertionDataForServer: Assertion): Promise { + const formData = new FormData(); + Object.entries(assertionDataForServer).forEach(([key, value]) => { + formData.set(key, value); + }); + + return await fetchJSON( + "/-/user/authenticator/webauthn/verify-assertion/", { + method: "POST", + body: formData + }); +} diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 38b0798a7..f8e59a146 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -194,11 +194,11 @@ export abstract class Table extends LitElement { } renderToolbar(): TemplateResult { - return html`  `; + `; } renderToolbarAfter(): TemplateResult { @@ -216,10 +216,10 @@ export abstract class Table extends LitElement { renderTable(): TemplateResult { return html`
- ${this.renderSearch()}  + ${this.renderSearch()}
${this.renderToolbar()} -
  +
${this.renderToolbarAfter()} { ${gettext("Edit")}
-   + ${gettext("Delete")} diff --git a/web/src/pages/crypto/CertificateKeyPairListPage.ts b/web/src/pages/crypto/CertificateKeyPairListPage.ts index 806d9852d..98aaa04be 100644 --- a/web/src/pages/crypto/CertificateKeyPairListPage.ts +++ b/web/src/pages/crypto/CertificateKeyPairListPage.ts @@ -56,7 +56,7 @@ export class CertificateKeyPairListPage extends TablePage { ${gettext("Edit")}
-
  + ${gettext("Delete")} @@ -102,7 +102,7 @@ export class CertificateKeyPairListPage extends TablePage { ${gettext("Create")}
-
  + ${gettext("Generate")} diff --git a/web/src/pages/events/RuleListPage.ts b/web/src/pages/events/RuleListPage.ts index dff3c5cec..cc4b9b379 100644 --- a/web/src/pages/events/RuleListPage.ts +++ b/web/src/pages/events/RuleListPage.ts @@ -57,7 +57,7 @@ export class RuleListPage extends TablePage { ${gettext("Edit")}
-
  + ${gettext("Delete")} diff --git a/web/src/pages/events/TransportListPage.ts b/web/src/pages/events/TransportListPage.ts index fb8ed123c..6918ef551 100644 --- a/web/src/pages/events/TransportListPage.ts +++ b/web/src/pages/events/TransportListPage.ts @@ -50,13 +50,13 @@ export class TransportListPage extends TablePage { html` ${gettext("Test")} -   + ${gettext("Edit")}
-
  +
${gettext("Delete")} diff --git a/web/src/pages/flows/BoundStagesList.ts b/web/src/pages/flows/BoundStagesList.ts index 4d93939bd..464ef47a4 100644 --- a/web/src/pages/flows/BoundStagesList.ts +++ b/web/src/pages/flows/BoundStagesList.ts @@ -8,7 +8,8 @@ import "../../elements/AdminLoginsChart"; import "../../elements/buttons/ModalButton"; import "../../elements/buttons/SpinnerButton"; import "../../elements/policies/BoundPoliciesList"; -import { FlowStageBinding } from "../../api/Flows"; +import { FlowStageBinding, Stage } from "../../api/Flows"; +import { until } from "lit-html/directives/until"; @customElement("ak-bound-stages-list") export class BoundStagesList extends Table { @@ -45,7 +46,7 @@ export class BoundStagesList extends Table { ${gettext("Edit")}
-
  + ${gettext("Delete")} @@ -90,6 +91,26 @@ export class BoundStagesList extends Table { renderToolbar(): TemplateResult { return html` + + + + ${gettext("Bind Stage")} diff --git a/web/src/pages/flows/FlowListPage.ts b/web/src/pages/flows/FlowListPage.ts index fe2887697..9689ccd3d 100644 --- a/web/src/pages/flows/FlowListPage.ts +++ b/web/src/pages/flows/FlowListPage.ts @@ -60,16 +60,16 @@ export class FlowListPage extends TablePage { ${gettext("Edit")}
-
  +
${gettext("Delete")}
-
  + ${gettext("Execute")} -   + ${gettext("Export")} @@ -84,7 +84,7 @@ export class FlowListPage extends TablePage { ${gettext("Create")}
-   + ${gettext("Import")} diff --git a/web/src/pages/generic/FlowShellCard.ts b/web/src/pages/generic/FlowShellCard.ts index b0df8bfd4..313175835 100644 --- a/web/src/pages/generic/FlowShellCard.ts +++ b/web/src/pages/generic/FlowShellCard.ts @@ -1,5 +1,6 @@ import { LitElement, html, customElement, property, TemplateResult } from "lit-element"; import { SentryIgnoredError } from "../../common/errors"; +import { getCookie } from "../../utils"; enum ResponseType { redirect = "redirect", @@ -24,6 +25,31 @@ export class FlowShellCard extends LitElement { return this; } + constructor() { + super(); + this.addEventListener("ak-flow-submit", () => { + const csrftoken = getCookie("authentik_csrf"); + const request = new Request(this.flowBodyUrl, { + headers: { + "X-CSRFToken": csrftoken, + }, + }); + fetch(request, { + method: "POST", + mode: "same-origin" + }) + .then((response) => { + return response.json(); + }) + .then((data) => { + this.updateCard(data); + }) + .catch((e) => { + this.errorMessage(e); + }); + }); + } + firstUpdated(): void { fetch(this.flowBodyUrl) .then((r) => { @@ -50,6 +76,7 @@ export class FlowShellCard extends LitElement { async updateCard(data: Response): Promise { switch (data.type) { case ResponseType.redirect: + console.debug(`authentik/flows: redirecting to ${data.to}`); window.location.assign(data.to || ""); break; case ResponseType.template: diff --git a/web/src/pages/outposts/OutpostHealth.ts b/web/src/pages/outposts/OutpostHealth.ts index 7d15542f2..7ba5ddc97 100644 --- a/web/src/pages/outposts/OutpostHealth.ts +++ b/web/src/pages/outposts/OutpostHealth.ts @@ -23,7 +23,7 @@ export class OutpostHealth extends LitElement { return html`
    • -  ${gettext("Not available")} + ${gettext("Not available")}
  • `; @@ -32,13 +32,13 @@ export class OutpostHealth extends LitElement { return html`
    • -  ${gettext(`Last seen: ${new Date(h.last_seen * 1000).toLocaleTimeString()}`)} + ${gettext(`Last seen: ${new Date(h.last_seen * 1000).toLocaleTimeString()}`)}
    • ${h.version_outdated ? - html`  + html` ${gettext(`${h.version}, should be ${h.version_should}`)}` : - html` ${gettext(`Version: ${h.version}`)}`} + html`${gettext(`Version: ${h.version}`)}`}
  • `; diff --git a/web/src/pages/outposts/OutpostListPage.ts b/web/src/pages/outposts/OutpostListPage.ts index 8246f8820..71b636028 100644 --- a/web/src/pages/outposts/OutpostListPage.ts +++ b/web/src/pages/outposts/OutpostListPage.ts @@ -57,13 +57,13 @@ export class OutpostListPage extends TablePage { ${gettext("Edit")}
    -
      + ${gettext("Delete")}
    -
      +
    diff --git a/web/src/pages/sources/SourcesListPage.ts b/web/src/pages/sources/SourcesListPage.ts index 7266e9e22..30511aa4a 100644 --- a/web/src/pages/sources/SourcesListPage.ts +++ b/web/src/pages/sources/SourcesListPage.ts @@ -57,7 +57,7 @@ export class SourceListPage extends TablePage { ${gettext("Edit")}
    -
      + ${gettext("Delete")} diff --git a/website/docs/flow/stages/otp_static/index.md b/website/docs/flow/stages/authenticator_static/index.md similarity index 83% rename from website/docs/flow/stages/otp_static/index.md rename to website/docs/flow/stages/authenticator_static/index.md index 91a8eca8c..ee43781d6 100644 --- a/website/docs/flow/stages/otp_static/index.md +++ b/website/docs/flow/stages/authenticator_static/index.md @@ -1,5 +1,5 @@ --- -title: OTP Static stage +title: Static Authenticator stage --- This stage configures static OTP Tokens, which can be used as a backup method to time-based OTP tokens. diff --git a/website/docs/flow/stages/otp_time/index.md b/website/docs/flow/stages/authenticator_totp/index.md similarity index 88% rename from website/docs/flow/stages/otp_time/index.md rename to website/docs/flow/stages/authenticator_totp/index.md index 105bcde60..6d4961144 100644 --- a/website/docs/flow/stages/otp_time/index.md +++ b/website/docs/flow/stages/authenticator_totp/index.md @@ -1,5 +1,5 @@ --- -title: OTP Time stage +title: TOTP stage --- This stage configures a time-based OTP Device, such as Google Authenticator or Authy. diff --git a/website/docs/flow/stages/authenticator_validate/index.md b/website/docs/flow/stages/authenticator_validate/index.md new file mode 100644 index 000000000..9dffa3dc1 --- /dev/null +++ b/website/docs/flow/stages/authenticator_validate/index.md @@ -0,0 +1,8 @@ +--- +title: Authenticator Validation Stage +--- + +This stage validates an already configured OTP Device. This device has to be configured using any of the other authenticator stages: + +- [TOTP authenticator stage](../authenticator_totp/index.md) +- [Static authenticator stage](../authenticator_static/index.md). diff --git a/website/docs/flow/stages/otp_validation/index.md b/website/docs/flow/stages/otp_validation/index.md deleted file mode 100644 index 5e3af1b68..000000000 --- a/website/docs/flow/stages/otp_validation/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: OTP Validation stage ---- - -This stage validates an already configured OTP Device. This device has to be configured using an [OTP Time stage](../otp_time/index.md) or [OTP Static stage](../otp_static/index.md). diff --git a/website/sidebars.js b/website/sidebars.js index 87fde5e28..2037f2be0 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -46,14 +46,14 @@ module.exports = { type: "category", label: "Stages", items: [ + "flow/stages/authenticator_static/index", + "flow/stages/authenticator_totp/index", + "flow/stages/authenticator_validate/index", "flow/stages/captcha/index", "flow/stages/dummy/index", "flow/stages/email/index", "flow/stages/identification/index", "flow/stages/invitation/index", - "flow/stages/otp_static/index", - "flow/stages/otp_time/index", - "flow/stages/otp_validation/index", "flow/stages/password/index", "flow/stages/prompt/index", "flow/stages/prompt/validation", diff --git a/website/static/flows/login-2fa.pbflow b/website/static/flows/login-2fa.pbflow index 00b384cd1..ada439967 100644 --- a/website/static/flows/login-2fa.pbflow +++ b/website/static/flows/login-2fa.pbflow @@ -41,7 +41,7 @@ "pk": "37f709c3-8817-45e8-9a93-80a925d293c2", "name": "default-authentication-flow-totp" }, - "model": "authentik_stages_otp_validate.otpvalidatestage", + "model": "authentik_stages_authenticator_validate.AuthenticatorValidateStage", "attrs": {} }, {