From 1cfe1aff132a1041c31247a6cda5d172ad5181be Mon Sep 17 00:00:00 2001 From: Jens L Date: Sat, 5 Dec 2020 22:08:42 +0100 Subject: [PATCH] wip: rename to authentik (#361) * root: initial rename * web: rename custom element prefix * root: rename external functions with pb_ prefix * root: fix formatting * root: replace domain with goauthentik.io * proxy: update path * root: rename remaining prefixes * flows: rename file extension * root: pbadmin -> akadmin * docs: fix image filenames * lifecycle: ignore migration files * ci: copy default config from current source before loading last tagged * *: new sentry dsn * tests: fix missing python3.9-dev package * root: add additional migrations for service accounts created by outposts * core: mark system-created service accounts with attribute * policies/expression: fix pb_ replacement not working * web: fix last linting errors, add lit-analyse * policies/expressions: fix lint errors * web: fix sidebar display on screens where not all items fit * proxy: attempt to fix proxy pipeline * proxy: use go env GOPATH to get gopath * lib: fix user_default naming inconsistency * docs: add upgrade docs * docs: update screenshots to use authentik * admin: fix create button on empty-state of outpost * web: fix modal submit not refreshing SiteShell and Table * web: fix height of app-card and height of generic icon * web: fix rendering of subtext * admin: fix version check error not being caught * web: fix worker count not being shown * docs: update screenshots * root: new icon * web: fix lint error * admin: fix linting error * root: migrate coverage config to pyproject --- .bumpversion.cfg | 2 +- .coveragerc | 33 - .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/release.yml | 34 +- .github/workflows/tag.yml | 16 +- Dockerfile | 12 +- Makefile | 18 +- README.md | 26 +- SECURITY.md | 2 +- authentik/__init__.py | 2 + {passbook => authentik}/admin/__init__.py | 0 {passbook => authentik}/admin/api/__init__.py | 0 authentik/admin/api/overview.py | 79 + authentik/admin/api/overview_metrics.py | 79 + authentik/admin/api/tasks.py | 72 + authentik/admin/apps.py | 11 + {passbook => authentik}/admin/fields.py | 0 .../admin/forms/__init__.py | 0 .../admin/forms/overview.py | 0 authentik/admin/forms/policies.py | 12 + authentik/admin/forms/source.py | 17 + authentik/admin/forms/users.py | 22 + authentik/admin/mixins.py | 9 + authentik/admin/settings.py | 10 + authentik/admin/tasks.py | 30 + .../administration/application/list.html | 121 ++ .../admin/templates/administration/base.html | 0 .../certificatekeypair/list.html | 116 ++ .../templates/administration/flow/import.html | 0 .../templates/administration/flow/list.html | 135 ++ .../templates/administration/group/list.html | 114 + .../administration/outpost/list.html | 149 ++ .../outpost_service_connection/list.html | 154 ++ .../templates/administration/overview.html | 230 +++ .../templates/administration/policy/list.html | 148 ++ .../templates/administration/policy/test.html | 0 .../administration/policy_binding/list.html | 119 ++ .../administration/property_mapping/list.html | 139 ++ .../administration/provider/list.html | 159 ++ .../templates/administration/source/list.html | 153 ++ .../templates/administration/stage/list.html | 148 ++ .../administration/stage_binding/list.html | 125 ++ .../administration/stage_invitation/list.html | 103 + .../administration/stage_prompt/list.html | 130 ++ .../templates/administration/task/list.html | 84 + .../templates/administration/token/list.html | 102 + .../administration/user/disable.html | 42 + .../templates/administration/user/list.html | 125 ++ .../admin/templates/fields/codemirror.html | 1 + authentik/admin/templates/generic/create.html | 18 + authentik/admin/templates/generic/form.html | 38 + .../templates/generic/form_non_model.html | 20 + authentik/admin/templates/generic/update.html | 18 + .../admin/templatetags/__init__.py | 0 .../admin/templatetags/admin_reflection.py | 62 + authentik/admin/tests.py | 66 + authentik/admin/urls.py | 353 ++++ .../admin/views/__init__.py | 0 authentik/admin/views/applications.py | 93 + authentik/admin/views/certificate_key_pair.py | 86 + authentik/admin/views/flows.py | 151 ++ authentik/admin/views/groups.py | 83 + authentik/admin/views/outposts.py | 93 + .../views/outposts_service_connections.py | 83 + authentik/admin/views/overview.py | 85 + authentik/admin/views/policies.py | 129 ++ authentik/admin/views/policies_bindings.py | 99 + authentik/admin/views/property_mappings.py | 83 + authentik/admin/views/providers.py | 83 + authentik/admin/views/sources.py | 81 + authentik/admin/views/stages.py | 79 + authentik/admin/views/stages_bindings.py | 79 + authentik/admin/views/stages_invitations.py | 76 + authentik/admin/views/stages_prompts.py | 88 + authentik/admin/views/tasks.py | 23 + authentik/admin/views/tokens.py | 45 + authentik/admin/views/users.py | 168 ++ authentik/admin/views/utils.py | 124 ++ {passbook => authentik}/api/__init__.py | 0 authentik/api/apps.py | 12 + authentik/api/auth.py | 57 + {passbook => authentik}/api/pagination.py | 0 .../api/templates/rest_framework/api.html | 7 + authentik/api/urls.py | 8 + {passbook => authentik}/api/v2/__init__.py | 0 authentik/api/v2/config.py | 46 + {passbook => authentik}/api/v2/messages.py | 0 authentik/api/v2/urls.py | 160 ++ {passbook => authentik}/audit/__init__.py | 0 authentik/audit/api.py | 70 + authentik/audit/apps.py | 16 + authentik/audit/middleware.py | 85 + .../audit/migrations/0001_initial.py | 0 .../migrations/0002_auto_20200918_2116.py | 33 + .../migrations/0003_auto_20200917_1155.py | 64 + .../migrations/0004_auto_20200921_1829.py | 37 + .../migrations/0005_auto_20201005_2139.py | 37 + .../migrations/0006_auto_20201017_2024.py | 42 + .../audit/migrations/__init__.py | 0 authentik/audit/models.py | 199 ++ authentik/audit/signals.py | 107 + authentik/audit/templates/audit/list.html | 90 + .../audit/tests/__init__.py | 0 authentik/audit/tests/test_event.py | 33 + authentik/audit/urls.py | 9 + authentik/audit/views.py | 30 + {passbook => authentik}/core/__init__.py | 0 authentik/core/admin.py | 24 + {passbook => authentik}/core/api/__init__.py | 0 authentik/core/api/applications.py | 81 + authentik/core/api/groups.py | 21 + authentik/core/api/propertymappings.py | 30 + authentik/core/api/providers.py | 30 + authentik/core/api/sources.py | 31 + authentik/core/api/tokens.py | 37 + authentik/core/api/users.py | 44 + authentik/core/apps.py | 11 + authentik/core/channels.py | 32 + authentik/core/exceptions.py | 6 + authentik/core/expression.py | 21 + .../core/forms/__init__.py | 0 authentik/core/forms/applications.py | 50 + authentik/core/forms/groups.py | 38 + authentik/core/forms/token.py | 22 + authentik/core/forms/users.py | 15 + authentik/core/middleware.py | 56 + authentik/core/migrations/0001_initial.py | 356 ++++ .../migrations/0002_auto_20200523_1133.py | 55 + .../core/migrations/0003_default_user.py | 45 + .../migrations/0004_auto_20200703_2213.py | 28 + .../core/migrations/0005_token_intent.py | 24 + .../migrations/0006_auto_20200709_1608.py | 20 + .../migrations/0007_auto_20200815_1841.py | 20 + .../migrations/0008_auto_20200824_1532.py | 36 + .../migrations/0009_group_is_superuser.py | 61 + .../migrations/0010_auto_20200917_1021.py | 24 + .../migrations/0011_provider_name_temp.py | 19 + .../migrations/0012_auto_20201003_1737.py | 20 + .../migrations/0013_auto_20201003_2132.py | 35 + .../migrations/0014_auto_20201018_1158.py | 50 + .../core/migrations/0015_application_icon.py | 24 + .../migrations/0016_auto_20201202_2234.py | 36 + .../core/migrations/__init__.py | 0 authentik/core/models.py | 371 ++++ authentik/core/signals.py | 5 + authentik/core/tasks.py | 63 + authentik/core/templates/403_csrf.html | 27 + authentik/core/templates/base/page.html | 12 + authentik/core/templates/base/skeleton.html | 41 + authentik/core/templates/error/generic.html | 26 + .../templates/generic/autosubmit_form.html | 31 + .../generic/autosubmit_form_full.html | 34 + authentik/core/templates/generic/delete.html | 43 + authentik/core/templates/library.html | 53 + authentik/core/templates/login/base.html | 59 + authentik/core/templates/login/base_full.html | 75 + .../core/templates/login/form.html | 0 .../core/templates/login/form_with_user.html | 18 + authentik/core/templates/login/loading.html | 24 + authentik/core/templates/partials/form.html | 73 + .../templates/partials/form_horizontal.html | 108 + .../core/templates/partials/pagination.html | 42 + .../templates/partials/toolbar_search.html | 0 authentik/core/templates/shell.html | 5 + authentik/core/templates/user/settings.html | 78 + authentik/core/templates/user/token_list.html | 100 + .../core/templatetags/__init__.py | 0 .../templatetags/authentik_user_settings.py | 44 + .../core/tests/__init__.py | 0 authentik/core/tests/test_impersonation.py | 56 + authentik/core/tests/test_tasks.py | 18 + authentik/core/tests/test_views_overview.py | 42 + authentik/core/tests/test_views_user.py | 30 + authentik/core/types.py | 20 + authentik/core/urls.py | 39 + .../core/views/__init__.py | 0 authentik/core/views/error.py | 67 + authentik/core/views/impersonate.py | 58 + authentik/core/views/library.py | 23 + {passbook => authentik}/core/views/shell.py | 0 authentik/core/views/user.py | 137 ++ {passbook => authentik}/crypto/__init__.py | 0 authentik/crypto/api.py | 47 + authentik/crypto/apps.py | 10 + authentik/crypto/builder.py | 84 + authentik/crypto/forms.py | 57 + .../crypto/migrations/0001_initial.py | 0 .../migrations/0002_create_self_signed_kp.py | 26 + .../crypto/migrations/__init__.py | 0 authentik/crypto/models.py | 87 + authentik/crypto/tests.py | 50 + {passbook => authentik}/flows/__init__.py | 0 authentik/flows/api.py | 94 + authentik/flows/apps.py | 16 + {passbook => authentik}/flows/exceptions.py | 0 authentik/flows/forms.py | 69 + .../flows/management/__init__.py | 0 .../flows/management/commands/__init__.py | 0 .../flows/management/commands/apply_flow.py | 22 + .../flows/management/commands/benchmark.py | 117 ++ authentik/flows/markers.py | 57 + authentik/flows/migrations/0001_initial.py | 138 ++ .../migrations/0003_auto_20200523_1133.py | 29 + .../migrations/0006_auto_20200629_0857.py | 29 + .../migrations/0007_auto_20200703_2059.py | 47 + .../flows/migrations/0008_default_flows.py | 113 + .../flows/migrations/0009_source_flows.py | 158 ++ .../flows/migrations/0010_provider_flows.py | 48 + authentik/flows/migrations/0011_flow_title.py | 54 + .../migrations/0012_auto_20200908_1542.py | 28 + .../migrations/0013_auto_20200924_1605.py | 44 + .../migrations/0014_auto_20200925_2332.py | 51 + .../0015_flowstagebinding_evaluate_on_plan.py | 29 + .../migrations/0016_auto_20201202_1307.py | 50 + .../flows/migrations/__init__.py | 0 authentik/flows/models.py | 228 ++ authentik/flows/planner.py | 201 ++ authentik/flows/signals.py | 37 + authentik/flows/stage.py | 29 + .../flows/templates/flows/denied_shell.html | 57 + authentik/flows/templates/flows/error.html | 22 + authentik/flows/templates/flows/shell.html | 32 + .../flows/tests/__init__.py | 0 authentik/flows/tests/test_misc.py | 25 + authentik/flows/tests/test_models.py | 31 + authentik/flows/tests/test_planner.py | 189 ++ authentik/flows/tests/test_transfer.py | 136 ++ authentik/flows/tests/test_transfer_docs.py | 29 + authentik/flows/tests/test_views.py | 353 ++++ authentik/flows/tests/test_views_helper.py | 47 + .../flows/transfer/__init__.py | 0 authentik/flows/transfer/common.py | 68 + authentik/flows/transfer/exporter.py | 106 + authentik/flows/transfer/importer.py | 179 ++ authentik/flows/urls.py | 49 + authentik/flows/views.py | 326 +++ {passbook => authentik}/lib/__init__.py | 0 authentik/lib/apps.py | 10 + authentik/lib/config.py | 173 ++ authentik/lib/default.yml | 37 + .../lib/expression/__init__.py | 0 authentik/lib/expression/evaluator.py | 112 + authentik/lib/logging.py | 23 + {passbook => authentik}/lib/models.py | 0 authentik/lib/sentry.py | 64 + {passbook => authentik}/lib/tasks.py | 0 authentik/lib/templates/lib/arrayfield.html | 17 + .../lib/templatetags/__init__.py | 0 .../lib/templatetags/authentik_is_active.py | 55 + authentik/lib/templatetags/authentik_utils.py | 113 + authentik/lib/tests.py | 30 + {passbook => authentik}/lib/utils/__init__.py | 0 {passbook => authentik}/lib/utils/http.py | 0 authentik/lib/utils/reflection.py | 43 + authentik/lib/utils/template.py | 8 + {passbook => authentik}/lib/utils/time.py | 0 authentik/lib/utils/ui.py | 11 + {passbook => authentik}/lib/utils/urls.py | 0 authentik/lib/views.py | 41 + {passbook => authentik}/lib/widgets.py | 0 {passbook => authentik}/outposts/__init__.py | 0 authentik/outposts/api.py | 66 + authentik/outposts/apps.py | 74 + authentik/outposts/channels.py | 89 + .../outposts/controllers/__init__.py | 0 authentik/outposts/controllers/base.py | 46 + authentik/outposts/controllers/docker.py | 160 ++ .../outposts/controllers/k8s/__init__.py | 0 authentik/outposts/controllers/k8s/base.py | 126 ++ .../outposts/controllers/k8s/deployment.py | 134 ++ authentik/outposts/controllers/k8s/secret.py | 67 + authentik/outposts/controllers/k8s/service.py | 60 + authentik/outposts/controllers/kubernetes.py | 81 + authentik/outposts/docker_tls.py | 56 + authentik/outposts/forms.py | 88 + authentik/outposts/migrations/0001_initial.py | 40 + .../migrations/0002_auto_20200826_1306.py | 27 + .../migrations/0003_auto_20200827_2108.py | 34 + .../migrations/0004_auto_20200830_1056.py | 22 + .../migrations/0005_auto_20200909_1733.py | 22 + .../migrations/0006_auto_20201003_2239.py | 25 + .../0007_remove_outpost_channels.py | 17 + .../migrations/0008_auto_20201014_1547.py | 26 + .../0009_fix_missing_token_identifier.py | 36 + .../migrations/0010_service_connection.py | 168 ++ .../migrations/0011_docker_tls_auth.py | 45 + .../0012_service_connection_non_unique.py | 21 + .../migrations/0013_auto_20201203_2009.py | 30 + .../outposts/migrations/__init__.py | 0 authentik/outposts/models.py | 427 ++++ authentik/outposts/settings.py | 15 + authentik/outposts/signals.py | 36 + authentik/outposts/tasks.py | 165 ++ .../templates/outposts/deployment_modal.html | 43 + authentik/outposts/tests.py | 59 + authentik/outposts/urls.py | 11 + authentik/outposts/views.py | 89 + {passbook => authentik}/policies/__init__.py | 0 authentik/policies/api.py | 100 + authentik/policies/apps.py | 15 + .../policies/dummy/__init__.py | 0 authentik/policies/dummy/api.py | 21 + authentik/policies/dummy/apps.py | 11 + authentik/policies/dummy/forms.py | 20 + .../policies/dummy/migrations/0001_initial.py | 40 + .../policies/dummy/migrations/__init__.py | 0 authentik/policies/dummy/models.py | 50 + authentik/policies/dummy/tests.py | 39 + authentik/policies/engine.py | 135 ++ authentik/policies/exceptions.py | 6 + .../policies/expiry/__init__.py | 0 authentik/policies/expiry/api.py | 21 + authentik/policies/expiry/apps.py | 11 + authentik/policies/expiry/forms.py | 22 + .../expiry/migrations/0001_initial.py | 39 + .../policies/expiry/migrations/__init__.py | 0 authentik/policies/expiry/models.py | 62 + .../policies/expression/__init__.py | 0 authentik/policies/expression/api.py | 21 + authentik/policies/expression/apps.py | 11 + authentik/policies/expression/evaluator.py | 72 + authentik/policies/expression/forms.py | 31 + .../expression/migrations/0001_initial.py | 38 + .../migrations/0002_auto_20200926_1156.py | 28 + .../migrations/0003_auto_20201203_1223.py | 32 + .../expression/migrations/__init__.py | 0 authentik/policies/expression/models.py | 44 + .../templates/policy/expression/form.html | 14 + authentik/policies/expression/tests.py | 62 + authentik/policies/forms.py | 26 + .../policies/group_membership/__init__.py | 0 authentik/policies/group_membership/api.py | 23 + authentik/policies/group_membership/apps.py | 11 + authentik/policies/group_membership/forms.py | 20 + .../migrations/0001_initial.py | 47 + .../group_membership/migrations/__init__.py | 0 authentik/policies/group_membership/models.py | 39 + authentik/policies/group_membership/tests.py | 32 + .../policies/hibp/__init__.py | 0 authentik/policies/hibp/api.py | 21 + authentik/policies/hibp/apps.py | 11 + authentik/policies/hibp/forms.py | 19 + .../policies/hibp/migrations/0001_initial.py | 38 + ...002_haveibeenpwendpolicy_password_field.py | 21 + .../policies/hibp/migrations/__init__.py | 0 authentik/policies/hibp/models.py | 74 + authentik/policies/hibp/tests.py | 33 + authentik/policies/http.py | 43 + authentik/policies/migrations/0001_initial.py | 103 + .../migrations/0002_auto_20200528_1647.py | 70 + .../migrations/0003_auto_20200908_1542.py | 25 + .../policies/migrations/__init__.py | 0 authentik/policies/models.py | 102 + .../policies/password/__init__.py | 0 authentik/policies/password/api.py | 29 + authentik/policies/password/apps.py | 11 + authentik/policies/password/forms.py | 36 + .../password/migrations/0001_initial.py | 46 + .../0002_passwordpolicy_password_field.py | 21 + .../policies/password/migrations/__init__.py | 0 authentik/policies/password/models.py | 77 + authentik/policies/password/tests.py | 42 + authentik/policies/process.py | 87 + .../policies/reputation/__init__.py | 0 authentik/policies/reputation/api.py | 21 + authentik/policies/reputation/apps.py | 15 + authentik/policies/reputation/forms.py | 22 + .../reputation/migrations/0001_initial.py | 82 + .../reputation/migrations/__init__.py | 0 authentik/policies/reputation/models.py | 74 + authentik/policies/reputation/settings.py | 15 + authentik/policies/reputation/signals.py | 43 + authentik/policies/reputation/tasks.py | 50 + authentik/policies/reputation/tests.py | 55 + authentik/policies/signals.py | 25 + .../policies/templates/policies/denied.html | 57 + .../policies/tests/__init__.py | 0 authentik/policies/tests/test_engine.py | 84 + authentik/policies/tests/test_models.py | 30 + authentik/policies/types.py | 53 + {passbook => authentik}/policies/utils.py | 0 authentik/policies/views.py | 93 + {passbook => authentik}/providers/__init__.py | 0 .../providers/oauth2/__init__.py | 0 authentik/providers/oauth2/api.py | 51 + authentik/providers/oauth2/apps.py | 14 + .../providers/oauth2/constants.py | 0 .../providers/oauth2/errors.py | 0 authentik/providers/oauth2/forms.py | 100 + .../providers/oauth2/generators.py | 0 .../oauth2/migrations/0001_initial.py | 362 ++++ .../0002_oauth2provider_sub_mode.py | 33 + .../migrations/0003_auto_20200916_2129.py | 44 + ...auth2provider_post_logout_redirect_uris.py | 17 + .../migrations/0005_auto_20200920_1240.py | 36 + .../0006_remove_oauth2provider_name.py | 30 + .../migrations/0007_auto_20201016_1107.py | 20 + .../providers/oauth2/migrations/__init__.py | 0 authentik/providers/oauth2/models.py | 499 +++++ .../templates/providers/oauth2/consent.html | 0 .../providers/oauth2/end_session.html | 38 + .../oauth2/property_mapping_form.html | 14 + .../providers/oauth2/setup_url_modal.html | 50 + authentik/providers/oauth2/urls.py | 43 + authentik/providers/oauth2/urls_github.py | 45 + authentik/providers/oauth2/utils.py | 156 ++ .../providers/oauth2/views/__init__.py | 0 authentik/providers/oauth2/views/authorize.py | 382 ++++ authentik/providers/oauth2/views/github.py | 69 + .../providers/oauth2/views/introspection.py | 124 ++ authentik/providers/oauth2/views/jwks.py | 40 + authentik/providers/oauth2/views/provider.py | 74 + authentik/providers/oauth2/views/session.py | 22 + authentik/providers/oauth2/views/token.py | 256 +++ authentik/providers/oauth2/views/userinfo.py | 92 + .../providers/proxy/__init__.py | 0 authentik/providers/proxy/api.py | 118 ++ authentik/providers/proxy/apps.py | 10 + .../providers/proxy/controllers/__init__.py | 0 .../providers/proxy/controllers/docker.py | 34 + .../proxy/controllers/k8s/__init__.py | 0 .../proxy/controllers/k8s/ingress.py | 140 ++ .../providers/proxy/controllers/kubernetes.py | 17 + authentik/providers/proxy/forms.py | 50 + .../proxy/migrations/0001_initial.py | 58 + .../0002_proxyprovider_cookie_secret.py | 22 + .../0003_proxyprovider_certificate.py | 24 + .../migrations/0004_auto_20200913_1947.py | 37 + .../migrations/0005_auto_20200914_1536.py | 25 + .../0006_proxyprovider_skip_path_regex.py | 22 + .../migrations/0007_auto_20200923_1017.py | 29 + .../migrations/0008_auto_20200930_0810.py | 78 + .../migrations/0009_auto_20201007_1721.py | 31 + .../providers/proxy/migrations/__init__.py | 0 authentik/providers/proxy/models.py | 154 ++ .../providers/proxy/provider/__init__.py | 0 .../proxy/provider/kubernetes/__init__.py | 0 .../providers/saml/__init__.py | 0 authentik/providers/saml/api.py | 51 + authentik/providers/saml/apps.py | 12 + authentik/providers/saml/exceptions.py | 6 + authentik/providers/saml/forms.py | 85 + .../providers/saml/migrations/0001_initial.py | 140 ++ .../0002_default_saml_property_mappings.py | 63 + .../0003_samlprovider_sp_binding.py | 20 + .../migrations/0004_auto_20200620_1950.py | 22 + ...0005_remove_samlprovider_processor_path.py | 17 + .../0006_remove_samlprovider_name.py | 30 + .../0007_samlprovider_verification_kp.py | 28 + .../migrations/0008_auto_20201112_1036.py | 71 + .../migrations/0009_auto_20201112_2016.py | 69 + .../providers/saml/migrations/__init__.py | 0 authentik/providers/saml/models.py | 207 ++ .../providers/saml/processors/__init__.py | 0 .../providers/saml/processors/assertion.py | 263 +++ .../providers/saml/processors/metadata.py | 108 + .../saml/processors/request_parser.py | 169 ++ authentik/providers/saml/settings.py | 6 + .../providers/saml/admin_metadata_modal.html | 22 + .../templates/providers/saml/consent.html | 0 .../templates/providers/saml/logged_out.html | 0 .../providers/saml/property_mapping_form.html | 14 + .../providers/saml/tests/__init__.py | 0 .../saml/tests/test_auth_n_request.py | 211 ++ .../providers/saml/tests/test_utils_time.py | 27 + authentik/providers/saml/urls.py | 29 + .../providers/saml/utils/__init__.py | 0 .../providers/saml/utils/encoding.py | 0 .../providers/saml/utils/time.py | 0 authentik/providers/saml/views.py | 239 +++ {passbook => authentik}/recovery/__init__.py | 0 authentik/recovery/apps.py | 11 + .../recovery/management/__init__.py | 0 .../recovery/management/commands/__init__.py | 0 .../commands/create_recovery_key.py | 54 + authentik/recovery/tests.py | 34 + authentik/recovery/urls.py | 9 + authentik/recovery/views.py | 24 + {passbook => authentik}/root/__init__.py | 0 authentik/root/asgi.py | 148 ++ authentik/root/celery.py | 55 + .../root/messages/__init__.py | 0 .../root/messages/consumer.py | 0 .../root/messages/storage.py | 0 authentik/root/monitoring.py | 25 + authentik/root/settings.py | 460 +++++ authentik/root/test_runner.py | 38 + {passbook => authentik}/root/tests.py | 0 authentik/root/urls.py | 73 + authentik/root/websocket.py | 11 + {passbook => authentik}/sources/__init__.py | 0 .../sources/ldap/__init__.py | 0 authentik/sources/ldap/api.py | 54 + authentik/sources/ldap/apps.py | 15 + authentik/sources/ldap/auth.py | 76 + authentik/sources/ldap/forms.py | 83 + .../sources/ldap/migrations/0001_initial.py | 131 ++ .../migrations/0002_ldapsource_sync_users.py | 18 + .../0003_default_ldap_property_mappings.py | 37 + .../migrations/0004_auto_20200524_1146.py | 31 + .../migrations/0005_auto_20200913_1947.py | 27 + .../migrations/0006_auto_20200915_1919.py | 50 + .../0007_ldapsource_sync_users_password.py | 22 + .../sources/ldap/migrations/__init__.py | 0 authentik/sources/ldap/models.py | 132 ++ authentik/sources/ldap/password.py | 155 ++ authentik/sources/ldap/settings.py | 14 + authentik/sources/ldap/signals.py | 59 + authentik/sources/ldap/sync.py | 191 ++ authentik/sources/ldap/tasks.py | 45 + .../templates/ldap/property_mapping_form.html | 14 + .../templates/ldap/source_list_status.html | 0 .../sources/ldap/tests/__init__.py | 0 authentik/sources/ldap/tests/test_auth.py | 47 + authentik/sources/ldap/tests/test_password.py | 54 + authentik/sources/ldap/tests/test_sync.py | 51 + .../sources/ldap/tests/utils.py | 0 .../sources/oauth/__init__.py | 0 authentik/sources/oauth/api.py | 29 + authentik/sources/oauth/apps.py | 26 + authentik/sources/oauth/auth.py | 23 + .../sources/oauth/clients/__init__.py | 0 authentik/sources/oauth/clients/base.py | 75 + authentik/sources/oauth/clients/oauth1.py | 102 + authentik/sources/oauth/clients/oauth2.py | 113 + authentik/sources/oauth/exceptions.py | 6 + authentik/sources/oauth/forms.py | 131 ++ .../sources/oauth/migrations/0001_initial.py | 81 + .../migrations/0002_auto_20200520_1108.py | 50 + .../sources/oauth/migrations/__init__.py | 0 authentik/sources/oauth/models.py | 207 ++ authentik/sources/oauth/settings.py | 12 + .../oauth/templates/oauth_client/user.html | 24 + authentik/sources/oauth/tests.py | 38 + .../sources/oauth/types/__init__.py | 0 authentik/sources/oauth/types/azure_ad.py | 28 + authentik/sources/oauth/types/discord.py | 34 + authentik/sources/oauth/types/facebook.py | 47 + authentik/sources/oauth/types/github.py | 23 + authentik/sources/oauth/types/google.py | 34 + authentik/sources/oauth/types/manager.py | 64 + authentik/sources/oauth/types/oidc.py | 37 + authentik/sources/oauth/types/reddit.py | 50 + authentik/sources/oauth/types/twitter.py | 23 + authentik/sources/oauth/urls.py | 30 + .../sources/oauth/views/__init__.py | 0 authentik/sources/oauth/views/base.py | 27 + authentik/sources/oauth/views/callback.py | 234 +++ authentik/sources/oauth/views/dispatcher.py | 26 + authentik/sources/oauth/views/flows.py | 30 + authentik/sources/oauth/views/redirect.py | 45 + authentik/sources/oauth/views/user.py | 70 + .../sources/saml/__init__.py | 0 authentik/sources/saml/api.py | 33 + authentik/sources/saml/apps.py | 17 + authentik/sources/saml/exceptions.py | 18 + authentik/sources/saml/forms.py | 49 + .../sources/saml/migrations/0001_initial.py | 68 + .../migrations/0002_auto_20200523_2329.py | 30 + .../migrations/0003_auto_20200624_1957.py | 70 + .../migrations/0004_auto_20200708_1207.py | 26 + .../0005_samlsource_name_id_policy.py | 40 + .../0006_samlsource_allow_idp_initiated.py | 21 + .../migrations/0007_auto_20201112_1055.py | 51 + .../migrations/0008_auto_20201112_2016.py | 70 + .../sources/saml/migrations/__init__.py | 0 authentik/sources/saml/models.py | 181 ++ .../sources/saml/processors/__init__.py | 0 .../sources/saml/processors/constants.py | 0 authentik/sources/saml/processors/metadata.py | 93 + authentik/sources/saml/processors/request.py | 172 ++ authentik/sources/saml/processors/response.py | 215 ++ authentik/sources/saml/settings.py | 10 + authentik/sources/saml/signals.py | 22 + authentik/sources/saml/tasks.py | 42 + .../sources/saml/templates/saml/sp/login.html | 26 + authentik/sources/saml/tests.py | 26 + authentik/sources/saml/urls.py | 11 + authentik/sources/saml/views.py | 108 + {passbook => authentik}/stages/__init__.py | 0 .../stages/captcha/__init__.py | 0 authentik/stages/captcha/api.py | 21 + authentik/stages/captcha/apps.py | 10 + authentik/stages/captcha/forms.py | 25 + .../stages/captcha/migrations/0001_initial.py | 49 + .../stages/captcha/migrations/__init__.py | 0 authentik/stages/captcha/models.py | 51 + authentik/stages/captcha/settings.py | 9 + authentik/stages/captcha/stage.py | 24 + authentik/stages/captcha/tests.py | 55 + .../stages/consent/__init__.py | 0 authentik/stages/consent/api.py | 21 + authentik/stages/consent/apps.py | 10 + authentik/stages/consent/forms.py | 20 + .../stages/consent/migrations/0001_initial.py | 37 + .../migrations/0002_auto_20200720_0941.py | 83 + .../migrations/0003_auto_20200924_1403.py | 23 + .../stages/consent/migrations/__init__.py | 0 authentik/stages/consent/models.py | 81 + authentik/stages/consent/stage.py | 73 + .../templates/stages/consent/fallback.html | 0 authentik/stages/consent/tests.py | 135 ++ .../stages/dummy/__init__.py | 0 authentik/stages/dummy/api.py | 21 + authentik/stages/dummy/apps.py | 11 + authentik/stages/dummy/forms.py | 16 + .../stages/dummy/migrations/0001_initial.py | 37 + .../stages/dummy/migrations/__init__.py | 0 authentik/stages/dummy/models.py | 41 + authentik/stages/dummy/stage.py | 19 + authentik/stages/dummy/tests.py | 58 + .../stages/email/__init__.py | 0 authentik/stages/email/api.py | 36 + authentik/stages/email/apps.py | 15 + authentik/stages/email/forms.py | 44 + .../stages/email/migrations/0001_initial.py | 71 + .../stages/email/migrations/__init__.py | 0 authentik/stages/email/models.py | 85 + authentik/stages/email/stage.py | 90 + .../email/static/stages/email/css/base.css | 0 authentik/stages/email/tasks.py | 65 + .../email/for_email/account_confirmation.html | 38 + .../stages/email/for_email/base.html | 65 + .../stages/email/for_email/generic_email.html | 0 .../email/for_email/password_reset.html | 40 + .../stages/email/waiting_message.html | 0 .../stages/email/templatetags/__init__.py | 0 .../templatetags/authentik_stages_email.py | 31 + authentik/stages/email/tests.py | 126 ++ {passbook => authentik}/stages/email/utils.py | 0 .../stages/identification/__init__.py | 0 authentik/stages/identification/api.py | 29 + authentik/stages/identification/apps.py | 10 + authentik/stages/identification/forms.py | 73 + .../identification/migrations/0001_initial.py | 58 + .../migrations/0002_auto_20200530_2204.py | 41 + .../migrations/0003_auto_20200615_1641.py | 28 + ...ficationstage_case_insensitive_matching.py | 21 + .../migrations/0005_auto_20201003_1734.py | 29 + .../identification/migrations/__init__.py | 0 authentik/stages/identification/models.py | 96 + authentik/stages/identification/stage.py | 93 + .../stages/identification/login.html | 0 .../stages/identification/recovery.html | 0 authentik/stages/identification/tests.py | 131 ++ .../stages/invitation/__init__.py | 0 authentik/stages/invitation/api.py | 45 + authentik/stages/invitation/apps.py | 10 + authentik/stages/invitation/forms.py | 32 + .../invitation/migrations/0001_initial.py | 78 + .../stages/invitation/migrations/__init__.py | 0 authentik/stages/invitation/models.py | 72 + authentik/stages/invitation/signals.py | 7 + authentik/stages/invitation/stage.py | 30 + authentik/stages/invitation/tests.py | 132 ++ .../stages/otp_static/__init__.py | 0 authentik/stages/otp_static/api.py | 21 + authentik/stages/otp_static/apps.py | 11 + authentik/stages/otp_static/forms.py | 39 + .../otp_static/migrations/0001_initial.py | 38 + .../0002_otpstaticstage_configure_flow.py | 26 + .../migrations/0003_default_setup_flow.py | 48 + .../stages/otp_static/migrations/__init__.py | 0 authentik/stages/otp_static/models.py | 50 + .../stages/otp_static/settings.py | 0 authentik/stages/otp_static/stage.py | 62 + .../stages/otp_static/user_settings.html | 31 + authentik/stages/otp_static/urls.py | 11 + authentik/stages/otp_static/views.py | 44 + .../stages/otp_time/__init__.py | 0 authentik/stages/otp_time/api.py | 21 + authentik/stages/otp_time/apps.py | 11 + authentik/stages/otp_time/forms.py | 62 + .../otp_time/migrations/0001_initial.py | 38 + .../migrations/0002_auto_20200701_1900.py | 23 + .../0003_otptimestage_configure_flow.py | 26 + .../migrations/0004_default_setup_flow.py | 49 + .../stages/otp_time/migrations/__init__.py | 0 authentik/stages/otp_time/models.py | 57 + authentik/stages/otp_time/settings.py | 6 + authentik/stages/otp_time/stage.py | 66 + .../stages/otp_time/user_settings.html | 28 + authentik/stages/otp_time/urls.py | 11 + authentik/stages/otp_time/views.py | 41 + .../stages/otp_validate/__init__.py | 0 authentik/stages/otp_validate/api.py | 24 + authentik/stages/otp_validate/apps.py | 10 + authentik/stages/otp_validate/forms.py | 49 + .../otp_validate/migrations/0001_initial.py | 41 + .../otp_validate/migrations/__init__.py | 0 authentik/stages/otp_validate/models.py | 44 + .../stages/otp_validate/settings.py | 0 authentik/stages/otp_validate/stage.py | 46 + .../stages/password/__init__.py | 0 authentik/stages/password/api.py | 27 + authentik/stages/password/apps.py | 11 + authentik/stages/password/forms.py | 57 + .../password/migrations/0001_initial.py | 46 + .../0002_passwordstage_change_flow.py | 109 + ...wordstage_failed_attempts_before_cancel.py | 21 + .../migrations/0004_auto_20200925_1057.py | 34 + .../stages/password/migrations/__init__.py | 0 authentik/stages/password/models.py | 64 + authentik/stages/password/stage.py | 123 ++ .../templates/stages/password/flow-form.html | 10 + .../stages/password/user-settings-card.html | 17 + authentik/stages/password/tests.py | 195 ++ authentik/stages/password/urls.py | 12 + authentik/stages/password/views.py | 26 + .../stages/prompt/__init__.py | 0 authentik/stages/prompt/api.py | 53 + authentik/stages/prompt/apps.py | 10 + authentik/stages/prompt/forms.py | 157 ++ .../stages/prompt/migrations/0001_initial.py | 98 + .../migrations/0002_auto_20200920_1859.py | 42 + .../stages/prompt/migrations/__init__.py | 0 authentik/stages/prompt/models.py | 166 ++ authentik/stages/prompt/signals.py | 5 + authentik/stages/prompt/stage.py | 36 + authentik/stages/prompt/tests.py | 178 ++ .../stages/prompt/widgets.py | 0 .../stages/user_delete/__init__.py | 0 authentik/stages/user_delete/api.py | 24 + authentik/stages/user_delete/apps.py | 10 + authentik/stages/user_delete/forms.py | 20 + .../user_delete/migrations/0001_initial.py | 37 + .../stages/user_delete/migrations/__init__.py | 0 authentik/stages/user_delete/models.py | 40 + authentik/stages/user_delete/stage.py | 34 + authentik/stages/user_delete/tests.py | 95 + .../stages/user_login/__init__.py | 0 authentik/stages/user_login/api.py | 25 + authentik/stages/user_login/apps.py | 10 + authentik/stages/user_login/forms.py | 17 + .../user_login/migrations/0001_initial.py | 37 + .../0002_userloginstage_session_duration.py | 21 + .../migrations/0003_session_duration_delta.py | 38 + .../stages/user_login/migrations/__init__.py | 0 authentik/stages/user_login/models.py | 51 + authentik/stages/user_login/stage.py | 48 + authentik/stages/user_login/tests.py | 111 + .../stages/user_logout/__init__.py | 0 authentik/stages/user_logout/api.py | 24 + authentik/stages/user_logout/apps.py | 10 + authentik/stages/user_logout/forms.py | 16 + .../user_logout/migrations/0001_initial.py | 37 + .../stages/user_logout/migrations/__init__.py | 0 authentik/stages/user_logout/models.py | 39 + authentik/stages/user_logout/stage.py | 21 + authentik/stages/user_logout/tests.py | 60 + .../stages/user_write/__init__.py | 0 authentik/stages/user_write/api.py | 24 + authentik/stages/user_write/apps.py | 10 + authentik/stages/user_write/forms.py | 16 + .../user_write/migrations/0001_initial.py | 37 + .../migrations/0002_auto_20200918_1653.py | 27 + .../stages/user_write/migrations/__init__.py | 0 authentik/stages/user_write/models.py | 40 + authentik/stages/user_write/signals.py | 5 + authentik/stages/user_write/stage.py | 83 + authentik/stages/user_write/tests.py | 138 ++ azure-pipelines.yml | 21 +- docker-compose.yml | 24 +- helm/Chart.yaml | 10 +- helm/README.md | 14 +- helm/templates/NOTES.txt | 4 +- helm/templates/_helpers.tpl | 6 +- helm/templates/configmap.yaml | 2 +- helm/templates/ingress.yaml | 6 +- helm/templates/pvc.yaml | 6 +- helm/templates/secret.yaml | 2 +- helm/templates/service-account.yaml | 10 +- helm/templates/static-deployment.yaml | 22 +- helm/templates/static-service.yaml | 12 +- helm/templates/web-deployment.yaml | 56 +- helm/templates/web-service.yaml | 12 +- helm/templates/worker-deployment.yaml | 34 +- helm/values.test.yaml | 4 +- helm/values.yaml | 18 +- icons/authentik-working.ai | 1836 +++++++++++++++++ icons/brand.png | Bin 0 -> 7472 bytes icons/brand.svg | 1 + icons/icon.png | Bin 0 -> 15760 bytes icons/icon.svg | 1 + icons/icon_left_brand.png | Bin 0 -> 8434 bytes icons/icon_left_brand.svg | 1 + icons/icon_top_brand.png | Bin 0 -> 14971 bytes icons/icon_top_brand.svg | 1 + lifecycle/bootstrap.sh | 4 +- lifecycle/gunicorn.conf.py | 6 +- lifecycle/migrate.py | 2 +- lifecycle/system_migrations/to_0_10.py | 47 +- .../system_migrations/to_0_100_authentik.py | 102 + lifecycle/wait_for_db.py | 4 +- manage.py | 2 +- passbook/__init__.py | 2 - passbook/admin/api/overview.py | 79 - passbook/admin/api/overview_metrics.py | 79 - passbook/admin/api/tasks.py | 72 - passbook/admin/apps.py | 11 - passbook/admin/forms/policies.py | 12 - passbook/admin/forms/source.py | 17 - passbook/admin/forms/users.py | 22 - passbook/admin/mixins.py | 9 - passbook/admin/settings.py | 10 - passbook/admin/tasks.py | 30 - .../administration/application/list.html | 121 -- .../certificatekeypair/list.html | 116 -- .../templates/administration/flow/list.html | 135 -- .../templates/administration/group/list.html | 114 - .../administration/outpost/list.html | 149 -- .../outpost_service_connection/list.html | 154 -- .../templates/administration/overview.html | 230 --- .../templates/administration/policy/list.html | 148 -- .../administration/policy_binding/list.html | 119 -- .../administration/property_mapping/list.html | 139 -- .../administration/provider/list.html | 159 -- .../templates/administration/source/list.html | 153 -- .../templates/administration/stage/list.html | 148 -- .../administration/stage_binding/list.html | 125 -- .../administration/stage_invitation/list.html | 103 - .../administration/stage_prompt/list.html | 130 -- .../templates/administration/task/list.html | 84 - .../templates/administration/token/list.html | 102 - .../administration/user/disable.html | 42 - .../templates/administration/user/list.html | 125 -- .../admin/templates/fields/codemirror.html | 1 - passbook/admin/templates/generic/create.html | 18 - passbook/admin/templates/generic/form.html | 38 - .../templates/generic/form_non_model.html | 20 - passbook/admin/templates/generic/update.html | 18 - .../admin/templatetags/admin_reflection.py | 62 - passbook/admin/tests.py | 66 - passbook/admin/urls.py | 353 ---- passbook/admin/views/applications.py | 93 - passbook/admin/views/certificate_key_pair.py | 86 - passbook/admin/views/flows.py | 151 -- passbook/admin/views/groups.py | 83 - passbook/admin/views/outposts.py | 93 - .../views/outposts_service_connections.py | 83 - passbook/admin/views/overview.py | 85 - passbook/admin/views/policies.py | 129 -- passbook/admin/views/policies_bindings.py | 99 - passbook/admin/views/property_mappings.py | 83 - passbook/admin/views/providers.py | 83 - passbook/admin/views/sources.py | 81 - passbook/admin/views/stages.py | 79 - passbook/admin/views/stages_bindings.py | 79 - passbook/admin/views/stages_invitations.py | 76 - passbook/admin/views/stages_prompts.py | 88 - passbook/admin/views/tasks.py | 23 - passbook/admin/views/tokens.py | 45 - passbook/admin/views/users.py | 168 -- passbook/admin/views/utils.py | 124 -- passbook/api/apps.py | 12 - passbook/api/auth.py | 57 - .../api/templates/rest_framework/api.html | 7 - passbook/api/urls.py | 8 - passbook/api/v2/config.py | 46 - passbook/api/v2/urls.py | 157 -- passbook/audit/api.py | 70 - passbook/audit/apps.py | 16 - passbook/audit/middleware.py | 85 - .../migrations/0002_auto_20200918_2116.py | 33 - .../migrations/0003_auto_20200917_1155.py | 64 - .../migrations/0004_auto_20200921_1829.py | 37 - .../migrations/0005_auto_20201005_2139.py | 37 - .../migrations/0006_auto_20201017_2024.py | 42 - passbook/audit/models.py | 199 -- passbook/audit/signals.py | 107 - passbook/audit/templates/audit/list.html | 90 - passbook/audit/tests/test_event.py | 33 - passbook/audit/urls.py | 9 - passbook/audit/views.py | 30 - passbook/core/admin.py | 24 - passbook/core/api/applications.py | 81 - passbook/core/api/groups.py | 21 - passbook/core/api/propertymappings.py | 30 - passbook/core/api/providers.py | 30 - passbook/core/api/sources.py | 31 - passbook/core/api/tokens.py | 37 - passbook/core/api/users.py | 44 - passbook/core/apps.py | 11 - passbook/core/channels.py | 32 - passbook/core/exceptions.py | 6 - passbook/core/expression.py | 21 - passbook/core/forms/applications.py | 50 - passbook/core/forms/groups.py | 38 - passbook/core/forms/token.py | 22 - passbook/core/forms/users.py | 15 - passbook/core/middleware.py | 56 - passbook/core/migrations/0001_initial.py | 355 ---- .../migrations/0002_auto_20200523_1133.py | 55 - passbook/core/migrations/0003_default_user.py | 45 - .../migrations/0004_auto_20200703_2213.py | 28 - passbook/core/migrations/0005_token_intent.py | 24 - .../migrations/0006_auto_20200709_1608.py | 20 - .../migrations/0007_auto_20200815_1841.py | 20 - .../migrations/0008_auto_20200824_1532.py | 36 - .../migrations/0009_group_is_superuser.py | 61 - .../migrations/0010_auto_20200917_1021.py | 24 - .../migrations/0011_provider_name_temp.py | 19 - .../migrations/0012_auto_20201003_1737.py | 20 - .../migrations/0013_auto_20201003_2132.py | 35 - .../migrations/0014_auto_20201018_1158.py | 50 - .../core/migrations/0015_application_icon.py | 24 - passbook/core/models.py | 370 ---- passbook/core/signals.py | 5 - passbook/core/tasks.py | 63 - passbook/core/templates/403_csrf.html | 27 - passbook/core/templates/base/page.html | 12 - passbook/core/templates/base/skeleton.html | 41 - passbook/core/templates/error/generic.html | 26 - .../templates/generic/autosubmit_form.html | 31 - .../generic/autosubmit_form_full.html | 34 - passbook/core/templates/generic/delete.html | 43 - passbook/core/templates/library.html | 53 - passbook/core/templates/login/base.html | 59 - passbook/core/templates/login/base_full.html | 75 - .../core/templates/login/form_with_user.html | 18 - passbook/core/templates/login/loading.html | 24 - passbook/core/templates/partials/form.html | 73 - .../templates/partials/form_horizontal.html | 108 - .../core/templates/partials/pagination.html | 42 - passbook/core/templates/shell.html | 5 - passbook/core/templates/user/settings.html | 78 - passbook/core/templates/user/token_list.html | 100 - .../templatetags/passbook_user_settings.py | 44 - passbook/core/tests/test_impersonation.py | 55 - passbook/core/tests/test_tasks.py | 18 - passbook/core/tests/test_views_overview.py | 42 - passbook/core/tests/test_views_user.py | 30 - passbook/core/types.py | 20 - passbook/core/urls.py | 39 - passbook/core/views/error.py | 67 - passbook/core/views/impersonate.py | 58 - passbook/core/views/library.py | 23 - passbook/core/views/user.py | 137 -- passbook/crypto/api.py | 47 - passbook/crypto/apps.py | 10 - passbook/crypto/builder.py | 84 - passbook/crypto/forms.py | 57 - .../migrations/0002_create_self_signed_kp.py | 26 - passbook/crypto/models.py | 87 - passbook/crypto/tests.py | 50 - passbook/flows/api.py | 94 - passbook/flows/apps.py | 16 - passbook/flows/forms.py | 69 - .../flows/management/commands/apply_flow.py | 22 - .../flows/management/commands/benchmark.py | 117 -- passbook/flows/markers.py | 57 - passbook/flows/migrations/0001_initial.py | 138 -- .../migrations/0003_auto_20200523_1133.py | 29 - .../migrations/0006_auto_20200629_0857.py | 29 - .../migrations/0007_auto_20200703_2059.py | 47 - .../flows/migrations/0008_default_flows.py | 113 - .../flows/migrations/0009_source_flows.py | 158 -- .../flows/migrations/0010_provider_flows.py | 48 - passbook/flows/migrations/0011_flow_title.py | 54 - .../migrations/0012_auto_20200908_1542.py | 28 - .../migrations/0013_auto_20200924_1605.py | 44 - .../migrations/0014_auto_20200925_2332.py | 51 - .../0015_flowstagebinding_evaluate_on_plan.py | 29 - .../migrations/0016_auto_20201202_1307.py | 50 - passbook/flows/models.py | 228 -- passbook/flows/planner.py | 201 -- passbook/flows/signals.py | 37 - passbook/flows/stage.py | 29 - .../flows/templates/flows/denied_shell.html | 57 - passbook/flows/templates/flows/error.html | 22 - passbook/flows/templates/flows/shell.html | 32 - passbook/flows/tests/test_misc.py | 25 - passbook/flows/tests/test_models.py | 31 - passbook/flows/tests/test_planner.py | 189 -- passbook/flows/tests/test_transfer.py | 133 -- passbook/flows/tests/test_transfer_docs.py | 29 - passbook/flows/tests/test_views.py | 353 ---- passbook/flows/tests/test_views_helper.py | 47 - passbook/flows/transfer/common.py | 68 - passbook/flows/transfer/exporter.py | 102 - passbook/flows/transfer/importer.py | 179 -- passbook/flows/urls.py | 49 - passbook/flows/views.py | 324 --- passbook/lib/apps.py | 10 - passbook/lib/config.py | 173 -- passbook/lib/default.yml | 38 - passbook/lib/expression/evaluator.py | 112 - passbook/lib/logging.py | 23 - passbook/lib/sentry.py | 64 - passbook/lib/templates/lib/arrayfield.html | 17 - .../lib/templatetags/passbook_is_active.py | 55 - passbook/lib/templatetags/passbook_utils.py | 113 - passbook/lib/tests.py | 30 - passbook/lib/utils/reflection.py | 43 - passbook/lib/utils/template.py | 8 - passbook/lib/utils/ui.py | 11 - passbook/lib/views.py | 41 - passbook/outposts/api.py | 66 - passbook/outposts/apps.py | 74 - passbook/outposts/channels.py | 89 - passbook/outposts/controllers/base.py | 46 - passbook/outposts/controllers/docker.py | 160 -- passbook/outposts/controllers/k8s/base.py | 126 -- .../outposts/controllers/k8s/deployment.py | 134 -- passbook/outposts/controllers/k8s/secret.py | 67 - passbook/outposts/controllers/k8s/service.py | 60 - passbook/outposts/controllers/kubernetes.py | 81 - passbook/outposts/docker_tls.py | 56 - passbook/outposts/forms.py | 88 - passbook/outposts/migrations/0001_initial.py | 40 - .../migrations/0002_auto_20200826_1306.py | 27 - .../migrations/0003_auto_20200827_2108.py | 34 - .../migrations/0004_auto_20200830_1056.py | 22 - .../migrations/0005_auto_20200909_1733.py | 22 - .../migrations/0006_auto_20201003_2239.py | 25 - .../0007_remove_outpost_channels.py | 17 - .../migrations/0008_auto_20201014_1547.py | 26 - .../0009_fix_missing_token_identifier.py | 34 - .../migrations/0010_service_connection.py | 168 -- .../migrations/0011_docker_tls_auth.py | 45 - .../0012_service_connection_non_unique.py | 21 - passbook/outposts/models.py | 426 ---- passbook/outposts/settings.py | 15 - passbook/outposts/signals.py | 36 - passbook/outposts/tasks.py | 165 -- .../templates/outposts/deployment_modal.html | 43 - passbook/outposts/tests.py | 59 - passbook/outposts/urls.py | 11 - passbook/outposts/views.py | 89 - passbook/policies/api.py | 100 - passbook/policies/apps.py | 15 - passbook/policies/dummy/api.py | 21 - passbook/policies/dummy/apps.py | 11 - passbook/policies/dummy/forms.py | 20 - .../policies/dummy/migrations/0001_initial.py | 40 - passbook/policies/dummy/models.py | 50 - passbook/policies/dummy/tests.py | 39 - passbook/policies/engine.py | 135 -- passbook/policies/exceptions.py | 6 - passbook/policies/expiry/api.py | 21 - passbook/policies/expiry/apps.py | 11 - passbook/policies/expiry/forms.py | 22 - .../expiry/migrations/0001_initial.py | 39 - passbook/policies/expiry/models.py | 62 - passbook/policies/expression/api.py | 21 - passbook/policies/expression/apps.py | 11 - passbook/policies/expression/evaluator.py | 72 - passbook/policies/expression/forms.py | 31 - .../expression/migrations/0001_initial.py | 38 - .../migrations/0002_auto_20200926_1156.py | 28 - passbook/policies/expression/models.py | 44 - .../templates/policy/expression/form.html | 14 - passbook/policies/expression/tests.py | 62 - passbook/policies/forms.py | 26 - passbook/policies/group_membership/api.py | 23 - passbook/policies/group_membership/apps.py | 11 - passbook/policies/group_membership/forms.py | 20 - .../migrations/0001_initial.py | 47 - passbook/policies/group_membership/models.py | 39 - passbook/policies/group_membership/tests.py | 32 - passbook/policies/hibp/api.py | 21 - passbook/policies/hibp/apps.py | 11 - passbook/policies/hibp/forms.py | 19 - .../policies/hibp/migrations/0001_initial.py | 38 - ...002_haveibeenpwendpolicy_password_field.py | 21 - passbook/policies/hibp/models.py | 74 - passbook/policies/hibp/tests.py | 33 - passbook/policies/http.py | 43 - passbook/policies/migrations/0001_initial.py | 103 - .../migrations/0002_auto_20200528_1647.py | 70 - .../migrations/0003_auto_20200908_1542.py | 25 - passbook/policies/models.py | 102 - passbook/policies/password/api.py | 29 - passbook/policies/password/apps.py | 11 - passbook/policies/password/forms.py | 36 - .../password/migrations/0001_initial.py | 46 - .../0002_passwordpolicy_password_field.py | 21 - passbook/policies/password/models.py | 77 - passbook/policies/password/tests.py | 42 - passbook/policies/process.py | 87 - passbook/policies/reputation/api.py | 21 - passbook/policies/reputation/apps.py | 15 - passbook/policies/reputation/forms.py | 22 - .../reputation/migrations/0001_initial.py | 82 - passbook/policies/reputation/models.py | 74 - passbook/policies/reputation/settings.py | 15 - passbook/policies/reputation/signals.py | 43 - passbook/policies/reputation/tasks.py | 50 - passbook/policies/reputation/tests.py | 55 - passbook/policies/signals.py | 25 - .../policies/templates/policies/denied.html | 57 - passbook/policies/tests/test_engine.py | 84 - passbook/policies/tests/test_models.py | 30 - passbook/policies/types.py | 53 - passbook/policies/views.py | 93 - passbook/providers/oauth2/api.py | 51 - passbook/providers/oauth2/apps.py | 14 - passbook/providers/oauth2/forms.py | 96 - .../oauth2/migrations/0001_initial.py | 360 ---- .../0002_oauth2provider_sub_mode.py | 33 - .../migrations/0003_auto_20200916_2129.py | 44 - ...auth2provider_post_logout_redirect_uris.py | 17 - .../migrations/0005_auto_20200920_1240.py | 36 - .../0006_remove_oauth2provider_name.py | 30 - .../migrations/0007_auto_20201016_1107.py | 20 - passbook/providers/oauth2/models.py | 499 ----- .../providers/oauth2/end_session.html | 38 - .../oauth2/property_mapping_form.html | 14 - .../providers/oauth2/setup_url_modal.html | 50 - passbook/providers/oauth2/urls.py | 43 - passbook/providers/oauth2/urls_github.py | 45 - passbook/providers/oauth2/utils.py | 156 -- passbook/providers/oauth2/views/authorize.py | 382 ---- passbook/providers/oauth2/views/github.py | 69 - .../providers/oauth2/views/introspection.py | 124 -- passbook/providers/oauth2/views/jwks.py | 40 - passbook/providers/oauth2/views/provider.py | 74 - passbook/providers/oauth2/views/session.py | 22 - passbook/providers/oauth2/views/token.py | 256 --- passbook/providers/oauth2/views/userinfo.py | 92 - passbook/providers/proxy/api.py | 118 -- passbook/providers/proxy/apps.py | 10 - .../providers/proxy/controllers/docker.py | 34 - .../proxy/controllers/k8s/ingress.py | 140 -- .../providers/proxy/controllers/kubernetes.py | 17 - passbook/providers/proxy/forms.py | 50 - .../proxy/migrations/0001_initial.py | 58 - .../0002_proxyprovider_cookie_secret.py | 22 - .../0003_proxyprovider_certificate.py | 24 - .../migrations/0004_auto_20200913_1947.py | 37 - .../migrations/0005_auto_20200914_1536.py | 25 - .../0006_proxyprovider_skip_path_regex.py | 22 - .../migrations/0007_auto_20200923_1017.py | 29 - .../migrations/0008_auto_20200930_0810.py | 78 - .../migrations/0009_auto_20201007_1721.py | 31 - passbook/providers/proxy/models.py | 154 -- passbook/providers/saml/api.py | 51 - passbook/providers/saml/apps.py | 12 - passbook/providers/saml/exceptions.py | 6 - passbook/providers/saml/forms.py | 85 - .../providers/saml/migrations/0001_initial.py | 134 -- .../0002_default_saml_property_mappings.py | 63 - .../0003_samlprovider_sp_binding.py | 20 - .../migrations/0004_auto_20200620_1950.py | 22 - ...0005_remove_samlprovider_processor_path.py | 17 - .../0006_remove_samlprovider_name.py | 30 - .../0007_samlprovider_verification_kp.py | 28 - .../migrations/0008_auto_20201112_1036.py | 71 - .../migrations/0009_auto_20201112_2016.py | 69 - passbook/providers/saml/models.py | 205 -- .../providers/saml/processors/assertion.py | 263 --- .../providers/saml/processors/metadata.py | 108 - .../saml/processors/request_parser.py | 169 -- passbook/providers/saml/settings.py | 6 - .../providers/saml/admin_metadata_modal.html | 22 - .../providers/saml/property_mapping_form.html | 14 - .../saml/tests/test_auth_n_request.py | 211 -- .../providers/saml/tests/test_utils_time.py | 27 - passbook/providers/saml/urls.py | 29 - passbook/providers/saml/views.py | 239 --- passbook/recovery/apps.py | 11 - .../commands/create_recovery_key.py | 54 - passbook/recovery/tests.py | 34 - passbook/recovery/urls.py | 9 - passbook/recovery/views.py | 24 - passbook/root/asgi.py | 148 -- passbook/root/celery.py | 55 - passbook/root/monitoring.py | 25 - passbook/root/settings.py | 460 ----- passbook/root/test_runner.py | 38 - passbook/root/urls.py | 73 - passbook/root/websocket.py | 11 - passbook/sources/ldap/api.py | 54 - passbook/sources/ldap/apps.py | 15 - passbook/sources/ldap/auth.py | 76 - passbook/sources/ldap/forms.py | 83 - .../sources/ldap/migrations/0001_initial.py | 131 -- .../migrations/0002_ldapsource_sync_users.py | 18 - .../0003_default_ldap_property_mappings.py | 35 - .../migrations/0004_auto_20200524_1146.py | 31 - .../migrations/0005_auto_20200913_1947.py | 27 - .../migrations/0006_auto_20200915_1919.py | 48 - .../0007_ldapsource_sync_users_password.py | 22 - passbook/sources/ldap/models.py | 132 -- passbook/sources/ldap/password.py | 155 -- passbook/sources/ldap/settings.py | 14 - passbook/sources/ldap/signals.py | 59 - passbook/sources/ldap/sync.py | 191 -- passbook/sources/ldap/tasks.py | 45 - .../templates/ldap/property_mapping_form.html | 14 - passbook/sources/ldap/tests/test_auth.py | 47 - passbook/sources/ldap/tests/test_password.py | 54 - passbook/sources/ldap/tests/test_sync.py | 51 - passbook/sources/oauth/api.py | 29 - passbook/sources/oauth/apps.py | 26 - passbook/sources/oauth/auth.py | 23 - passbook/sources/oauth/clients/base.py | 75 - passbook/sources/oauth/clients/oauth1.py | 102 - passbook/sources/oauth/clients/oauth2.py | 113 - passbook/sources/oauth/exceptions.py | 6 - passbook/sources/oauth/forms.py | 131 -- .../sources/oauth/migrations/0001_initial.py | 81 - .../migrations/0002_auto_20200520_1108.py | 50 - passbook/sources/oauth/models.py | 207 -- passbook/sources/oauth/settings.py | 12 - .../oauth/templates/oauth_client/user.html | 24 - passbook/sources/oauth/tests.py | 38 - passbook/sources/oauth/types/azure_ad.py | 28 - passbook/sources/oauth/types/discord.py | 34 - passbook/sources/oauth/types/facebook.py | 47 - passbook/sources/oauth/types/github.py | 23 - passbook/sources/oauth/types/google.py | 34 - passbook/sources/oauth/types/manager.py | 64 - passbook/sources/oauth/types/oidc.py | 37 - passbook/sources/oauth/types/reddit.py | 50 - passbook/sources/oauth/types/twitter.py | 23 - passbook/sources/oauth/urls.py | 30 - passbook/sources/oauth/views/base.py | 27 - passbook/sources/oauth/views/callback.py | 234 --- passbook/sources/oauth/views/dispatcher.py | 26 - passbook/sources/oauth/views/flows.py | 30 - passbook/sources/oauth/views/redirect.py | 45 - passbook/sources/oauth/views/user.py | 70 - passbook/sources/saml/api.py | 33 - passbook/sources/saml/apps.py | 17 - passbook/sources/saml/exceptions.py | 18 - passbook/sources/saml/forms.py | 49 - .../sources/saml/migrations/0001_initial.py | 68 - .../migrations/0002_auto_20200523_2329.py | 30 - .../migrations/0003_auto_20200624_1957.py | 70 - .../migrations/0004_auto_20200708_1207.py | 26 - .../0005_samlsource_name_id_policy.py | 40 - .../0006_samlsource_allow_idp_initiated.py | 21 - .../migrations/0007_auto_20201112_1055.py | 51 - .../migrations/0008_auto_20201112_2016.py | 70 - passbook/sources/saml/models.py | 181 -- passbook/sources/saml/processors/metadata.py | 93 - passbook/sources/saml/processors/request.py | 172 -- passbook/sources/saml/processors/response.py | 215 -- passbook/sources/saml/settings.py | 10 - passbook/sources/saml/signals.py | 22 - passbook/sources/saml/tasks.py | 42 - .../sources/saml/templates/saml/sp/login.html | 26 - passbook/sources/saml/tests.py | 26 - passbook/sources/saml/urls.py | 11 - passbook/sources/saml/views.py | 108 - passbook/stages/captcha/api.py | 21 - passbook/stages/captcha/apps.py | 10 - passbook/stages/captcha/forms.py | 25 - .../stages/captcha/migrations/0001_initial.py | 49 - passbook/stages/captcha/models.py | 51 - passbook/stages/captcha/settings.py | 9 - passbook/stages/captcha/stage.py | 24 - passbook/stages/captcha/tests.py | 55 - passbook/stages/consent/api.py | 21 - passbook/stages/consent/apps.py | 10 - passbook/stages/consent/forms.py | 20 - .../stages/consent/migrations/0001_initial.py | 37 - .../migrations/0002_auto_20200720_0941.py | 83 - .../migrations/0003_auto_20200924_1403.py | 23 - passbook/stages/consent/models.py | 81 - passbook/stages/consent/stage.py | 73 - passbook/stages/consent/tests.py | 135 -- passbook/stages/dummy/api.py | 21 - passbook/stages/dummy/apps.py | 11 - passbook/stages/dummy/forms.py | 16 - .../stages/dummy/migrations/0001_initial.py | 37 - passbook/stages/dummy/models.py | 41 - passbook/stages/dummy/stage.py | 19 - passbook/stages/dummy/tests.py | 58 - passbook/stages/email/api.py | 36 - passbook/stages/email/apps.py | 15 - passbook/stages/email/forms.py | 44 - .../stages/email/migrations/0001_initial.py | 71 - passbook/stages/email/models.py | 85 - passbook/stages/email/stage.py | 90 - passbook/stages/email/tasks.py | 65 - .../email/for_email/account_confirmation.html | 38 - .../stages/email/for_email/base.html | 65 - .../email/for_email/password_reset.html | 40 - .../templatetags/passbook_stages_email.py | 31 - passbook/stages/email/tests.py | 125 -- passbook/stages/identification/api.py | 29 - passbook/stages/identification/apps.py | 10 - passbook/stages/identification/forms.py | 73 - .../identification/migrations/0001_initial.py | 58 - .../migrations/0002_auto_20200530_2204.py | 41 - .../migrations/0003_auto_20200615_1641.py | 28 - ...ficationstage_case_insensitive_matching.py | 21 - .../migrations/0005_auto_20201003_1734.py | 29 - passbook/stages/identification/models.py | 96 - passbook/stages/identification/stage.py | 93 - passbook/stages/identification/tests.py | 131 -- passbook/stages/invitation/api.py | 45 - passbook/stages/invitation/apps.py | 10 - passbook/stages/invitation/forms.py | 32 - .../invitation/migrations/0001_initial.py | 78 - passbook/stages/invitation/models.py | 72 - passbook/stages/invitation/signals.py | 7 - passbook/stages/invitation/stage.py | 30 - passbook/stages/invitation/tests.py | 132 -- passbook/stages/otp_static/api.py | 21 - passbook/stages/otp_static/apps.py | 11 - passbook/stages/otp_static/forms.py | 39 - .../otp_static/migrations/0001_initial.py | 38 - .../0002_otpstaticstage_configure_flow.py | 26 - .../migrations/0003_default_setup_flow.py | 48 - passbook/stages/otp_static/models.py | 50 - passbook/stages/otp_static/stage.py | 62 - .../stages/otp_static/user_settings.html | 31 - passbook/stages/otp_static/urls.py | 11 - passbook/stages/otp_static/views.py | 44 - passbook/stages/otp_time/api.py | 21 - passbook/stages/otp_time/apps.py | 11 - passbook/stages/otp_time/forms.py | 62 - .../otp_time/migrations/0001_initial.py | 38 - .../migrations/0002_auto_20200701_1900.py | 23 - .../0003_otptimestage_configure_flow.py | 26 - .../migrations/0004_default_setup_flow.py | 49 - passbook/stages/otp_time/models.py | 57 - passbook/stages/otp_time/settings.py | 6 - passbook/stages/otp_time/stage.py | 66 - .../stages/otp_time/user_settings.html | 28 - passbook/stages/otp_time/urls.py | 11 - passbook/stages/otp_time/views.py | 41 - passbook/stages/otp_validate/api.py | 24 - passbook/stages/otp_validate/apps.py | 10 - passbook/stages/otp_validate/forms.py | 49 - .../otp_validate/migrations/0001_initial.py | 41 - passbook/stages/otp_validate/models.py | 44 - passbook/stages/otp_validate/stage.py | 46 - passbook/stages/password/api.py | 27 - passbook/stages/password/apps.py | 11 - passbook/stages/password/forms.py | 57 - .../password/migrations/0001_initial.py | 46 - .../0002_passwordstage_change_flow.py | 109 - ...wordstage_failed_attempts_before_cancel.py | 21 - .../migrations/0004_auto_20200925_1057.py | 34 - passbook/stages/password/models.py | 64 - passbook/stages/password/stage.py | 123 -- .../templates/stages/password/flow-form.html | 10 - .../stages/password/user-settings-card.html | 17 - passbook/stages/password/tests.py | 194 -- passbook/stages/password/urls.py | 12 - passbook/stages/password/views.py | 25 - passbook/stages/prompt/api.py | 53 - passbook/stages/prompt/apps.py | 10 - passbook/stages/prompt/forms.py | 157 -- .../stages/prompt/migrations/0001_initial.py | 98 - .../migrations/0002_auto_20200920_1859.py | 42 - passbook/stages/prompt/models.py | 166 -- passbook/stages/prompt/signals.py | 5 - passbook/stages/prompt/stage.py | 36 - passbook/stages/prompt/tests.py | 177 -- passbook/stages/user_delete/api.py | 24 - passbook/stages/user_delete/apps.py | 10 - passbook/stages/user_delete/forms.py | 20 - .../user_delete/migrations/0001_initial.py | 37 - passbook/stages/user_delete/models.py | 40 - passbook/stages/user_delete/stage.py | 34 - passbook/stages/user_delete/tests.py | 95 - passbook/stages/user_login/api.py | 25 - passbook/stages/user_login/apps.py | 10 - passbook/stages/user_login/forms.py | 17 - .../user_login/migrations/0001_initial.py | 37 - .../0002_userloginstage_session_duration.py | 21 - .../migrations/0003_session_duration_delta.py | 38 - passbook/stages/user_login/models.py | 51 - passbook/stages/user_login/stage.py | 48 - passbook/stages/user_login/tests.py | 111 - passbook/stages/user_logout/api.py | 24 - passbook/stages/user_logout/apps.py | 10 - passbook/stages/user_logout/forms.py | 16 - .../user_logout/migrations/0001_initial.py | 37 - passbook/stages/user_logout/models.py | 39 - passbook/stages/user_logout/stage.py | 21 - passbook/stages/user_logout/tests.py | 60 - passbook/stages/user_write/api.py | 24 - passbook/stages/user_write/apps.py | 10 - passbook/stages/user_write/forms.py | 16 - .../user_write/migrations/0001_initial.py | 37 - .../migrations/0002_auto_20200918_1653.py | 27 - passbook/stages/user_write/models.py | 40 - passbook/stages/user_write/signals.py | 5 - passbook/stages/user_write/stage.py | 83 - passbook/stages/user_write/tests.py | 138 -- proxy/Dockerfile | 2 +- proxy/Makefile | 2 +- proxy/README.md | 18 +- proxy/azure-pipelines.yml | 4 +- proxy/cmd/server.go | 18 +- proxy/go.mod | 2 +- proxy/main.go | 2 +- proxy/pkg/proxy/claims.go | 2 +- proxy/pkg/server/api.go | 24 +- proxy/pkg/server/api_bundle.go | 6 +- proxy/pkg/server/api_ws.go | 10 +- proxy/pkg/server/cert.go | 4 +- proxy/pkg/server/server.go | 2 +- pyproject.toml | 34 +- pytest.ini | 2 +- scripts/ci.docker-compose.yml | 4 +- scripts/docker-compose.yml | 2 +- swagger.yaml | 24 +- tests/e2e/test_flows_enroll.py | 22 +- tests/e2e/test_flows_login.py | 2 +- tests/e2e/test_flows_otp.py | 20 +- tests/e2e/test_flows_stage_setup.py | 14 +- tests/e2e/test_provider_oauth2_github.py | 18 +- tests/e2e/test_provider_oauth2_grafana.py | 26 +- tests/e2e/test_provider_oauth2_oidc.py | 16 +- tests/e2e/test_provider_proxy.py | 24 +- tests/e2e/test_provider_saml.py | 40 +- tests/e2e/test_source_oauth.py | 24 +- tests/e2e/test_source_saml.py | 18 +- tests/e2e/utils.py | 12 +- tests/integration/test_outposts_kubernetes.py | 18 +- tests/integration/test_proxy_kubernetes.py | 12 +- tests/setup.sh | 4 +- .../sources/azure-ad.svg | 0 .../sources/discord.svg | 0 .../sources/dropbox.svg | 0 .../sources/facebook.svg | 0 .../sources/github.svg | 0 .../sources/gitlab.svg | 0 .../sources/google.svg | 0 .../sources/openid-connect.svg | 0 .../sources/twitter.svg | 0 web/azure-pipelines.yml | 21 +- web/rollup.config.js | 3 +- web/src/api/config.ts | 6 +- web/src/assets/fonts/DINEngschriftStd.woff | Bin 16768 -> 0 bytes web/src/assets/fonts/DINEngschriftStd.woff2 | Bin 12200 -> 0 bytes web/src/assets/images/logo.png | Bin 10833 -> 0 bytes web/src/assets/images/logo.svg | 55 - .../{user-default.png => user_default.png} | Bin web/src/authentik.css | 87 + web/src/common/styles.ts | 2 +- web/src/elements/AdminLoginsChart.ts | 2 +- web/src/elements/CodeMirror.ts | 2 +- web/src/elements/Messages.ts | 18 +- web/src/elements/Spinner.ts | 2 +- web/src/elements/Tabs.ts | 7 +- web/src/elements/buttons/ActionButton.ts | 4 +- web/src/elements/buttons/Dropdown.ts | 2 +- web/src/elements/buttons/ModalButton.ts | 12 +- web/src/elements/buttons/SpinnerButton.ts | 2 +- web/src/elements/buttons/TokenCopyButton.ts | 2 +- web/src/elements/cards/AggregateCard.ts | 5 +- .../elements/cards/AggregatePromiseCard.ts | 4 +- web/src/elements/sidebar/Sidebar.ts | 40 +- web/src/elements/sidebar/SidebarBrand.ts | 37 +- web/src/elements/sidebar/SidebarUser.ts | 2 +- web/src/elements/table/Table.ts | 24 +- web/src/elements/table/TablePagination.ts | 40 +- web/src/index.html | 20 +- web/src/interfaces/AdminInterface.ts | 2 +- web/src/interfaces/Interface.ts | 10 +- web/src/pages/LibraryPage.ts | 14 +- .../pages/admin-overview/AdminOverviewPage.ts | 61 +- .../admin-overview/TopApplicationsTable.ts | 4 +- .../pages/applications/ApplicationListPage.ts | 20 +- .../pages/applications/ApplicationViewPage.ts | 34 +- web/src/pages/generic/FlowShellCard.ts | 22 +- web/src/pages/generic/SiteShell.ts | 19 +- web/src/pages/router/Route.ts | 2 +- web/src/pages/router/RouterOutlet.ts | 14 +- web/src/passbook.css | 111 - web/src/routes.ts | 8 +- web/src/utils.ts | 2 +- .../docs/development/local-dev-environment.md | 6 +- website/docs/expressions/index.md | 12 +- .../docs/expressions/reference/user-object.md | 8 +- website/docs/flow/flows.md | 2 +- website/docs/flow/stages/email/index.md | 2 +- website/docs/flow/stages/prompt/validation.md | 2 +- website/docs/index.md | 6 +- website/docs/installation/docker-compose.md | 18 +- website/docs/installation/index.md | 2 +- website/docs/installation/kubernetes.md | 28 +- website/docs/installation/reverse-proxy.md | 6 +- .../docs/integrations/services/aws/index.md | 18 +- .../integrations/services/awx-tower/index.md | 79 + .../integrations/services/gitlab/index.md | 8 +- .../integrations/services/harbor/harbor.png | Bin 355923 -> 121658 bytes .../integrations/services/harbor/index.md | 4 +- .../services/home-assistant/index.md | 22 +- .../integrations/services/rancher/index.md | 6 +- .../integrations/services/rancher/rancher.png | Bin 537809 -> 562618 bytes .../integrations/services/sentry/index.md | 10 +- .../integrations/services/sonarr/index.md | 10 +- .../integrations/services/tautulli/index.md | 12 +- .../integrations/services/tower-awx/index.md | 79 - .../services/ubuntu-landscape/index.md | 10 +- ...passbook_setup.png => authentik_setup.png} | Bin .../services/vmware-vcenter/index.md | 12 +- .../vmware-vcenter/vcenter_post_setup.png | Bin 90927 -> 100666 bytes .../{03_pb_status.png => 03_ak_status.png} | Bin .../sources/active-directory/index.md | 14 +- website/docs/maintenance/backups/index.md | 16 +- .../outposts/manual-deploy-docker-compose.md | 10 +- .../docs/outposts/manual-deploy-kubernetes.md | 56 +- website/docs/outposts/outposts.md | 10 +- website/docs/outposts/outposts.png | Bin 125222 -> 326013 bytes website/docs/outposts/upgrading.md | 2 +- website/docs/policies/expression.md | 10 +- website/docs/policies/index.md | 4 +- website/docs/property-mappings/index.md | 6 +- website/docs/providers/proxy.md | 2 +- website/docs/sources.md | 8 +- website/docs/terminology.md | 6 +- website/docs/troubleshooting/access.md | 4 +- ...ser_debug.png => authentik_user_debug.png} | Bin website/docs/upgrading/to-0.10.md | 12 +- website/docs/upgrading/to-0.11.md | 4 +- website/docs/upgrading/to-0.12.md | 20 +- website/docs/upgrading/to-0.13.md | 58 + website/docs/upgrading/to-0.13.md_ | 21 - website/docs/upgrading/to-0.9.md | 22 +- website/docusaurus.config.js | 20 +- website/sidebars.js | 3 +- website/src/css/custom.css | 27 +- website/src/pages/index.js | 41 +- website/src/pages/styles.module.css | 11 +- .../static/flows/enrollment-2-stage.pbflow | 30 +- .../enrollment-email-verification.pbflow | 38 +- website/static/flows/login-2fa.pbflow | 20 +- .../flows/login-conditional-captcha.pbflow | 24 +- .../flows/recovery-email-verification.pbflow | 32 +- website/static/flows/unenrollment.pbflow | 6 +- website/static/fonts/DINEngschriftStd.woff | Bin 16768 -> 0 bytes website/static/fonts/DINEngschriftStd.woff2 | Bin 12200 -> 0 bytes website/static/img/brand.svg | 2 - website/static/img/brand_inverted.svg | 2 - website/static/img/icon.png | Bin 0 -> 15760 bytes website/static/img/icon.svg | 1 + website/static/img/icon_left_brand.svg | 1 + website/static/img/icon_top_brand.svg | 1 + website/static/img/logo.png | Bin 10833 -> 0 bytes website/static/img/logo.svg | 55 - website/static/img/screen_admin.png | Bin 378070 -> 338594 bytes website/static/img/screen_apps.png | Bin 507435 -> 546262 bytes 1542 files changed, 39648 insertions(+), 37635 deletions(-) delete mode 100644 .coveragerc create mode 100644 authentik/__init__.py rename {passbook => authentik}/admin/__init__.py (100%) rename {passbook => authentik}/admin/api/__init__.py (100%) create mode 100644 authentik/admin/api/overview.py create mode 100644 authentik/admin/api/overview_metrics.py create mode 100644 authentik/admin/api/tasks.py create mode 100644 authentik/admin/apps.py rename {passbook => authentik}/admin/fields.py (100%) rename {passbook => authentik}/admin/forms/__init__.py (100%) rename {passbook => authentik}/admin/forms/overview.py (100%) create mode 100644 authentik/admin/forms/policies.py create mode 100644 authentik/admin/forms/source.py create mode 100644 authentik/admin/forms/users.py create mode 100644 authentik/admin/mixins.py create mode 100644 authentik/admin/settings.py create mode 100644 authentik/admin/tasks.py create mode 100644 authentik/admin/templates/administration/application/list.html rename {passbook => authentik}/admin/templates/administration/base.html (100%) create mode 100644 authentik/admin/templates/administration/certificatekeypair/list.html rename {passbook => authentik}/admin/templates/administration/flow/import.html (100%) create mode 100644 authentik/admin/templates/administration/flow/list.html create mode 100644 authentik/admin/templates/administration/group/list.html create mode 100644 authentik/admin/templates/administration/outpost/list.html create mode 100644 authentik/admin/templates/administration/outpost_service_connection/list.html create mode 100644 authentik/admin/templates/administration/overview.html create mode 100644 authentik/admin/templates/administration/policy/list.html rename {passbook => authentik}/admin/templates/administration/policy/test.html (100%) create mode 100644 authentik/admin/templates/administration/policy_binding/list.html create mode 100644 authentik/admin/templates/administration/property_mapping/list.html create mode 100644 authentik/admin/templates/administration/provider/list.html create mode 100644 authentik/admin/templates/administration/source/list.html create mode 100644 authentik/admin/templates/administration/stage/list.html create mode 100644 authentik/admin/templates/administration/stage_binding/list.html create mode 100644 authentik/admin/templates/administration/stage_invitation/list.html create mode 100644 authentik/admin/templates/administration/stage_prompt/list.html create mode 100644 authentik/admin/templates/administration/task/list.html create mode 100644 authentik/admin/templates/administration/token/list.html create mode 100644 authentik/admin/templates/administration/user/disable.html create mode 100644 authentik/admin/templates/administration/user/list.html create mode 100644 authentik/admin/templates/fields/codemirror.html create mode 100644 authentik/admin/templates/generic/create.html create mode 100644 authentik/admin/templates/generic/form.html create mode 100644 authentik/admin/templates/generic/form_non_model.html create mode 100644 authentik/admin/templates/generic/update.html rename {passbook => authentik}/admin/templatetags/__init__.py (100%) create mode 100644 authentik/admin/templatetags/admin_reflection.py create mode 100644 authentik/admin/tests.py create mode 100644 authentik/admin/urls.py rename {passbook => authentik}/admin/views/__init__.py (100%) create mode 100644 authentik/admin/views/applications.py create mode 100644 authentik/admin/views/certificate_key_pair.py create mode 100644 authentik/admin/views/flows.py create mode 100644 authentik/admin/views/groups.py create mode 100644 authentik/admin/views/outposts.py create mode 100644 authentik/admin/views/outposts_service_connections.py create mode 100644 authentik/admin/views/overview.py create mode 100644 authentik/admin/views/policies.py create mode 100644 authentik/admin/views/policies_bindings.py create mode 100644 authentik/admin/views/property_mappings.py create mode 100644 authentik/admin/views/providers.py create mode 100644 authentik/admin/views/sources.py create mode 100644 authentik/admin/views/stages.py create mode 100644 authentik/admin/views/stages_bindings.py create mode 100644 authentik/admin/views/stages_invitations.py create mode 100644 authentik/admin/views/stages_prompts.py create mode 100644 authentik/admin/views/tasks.py create mode 100644 authentik/admin/views/tokens.py create mode 100644 authentik/admin/views/users.py create mode 100644 authentik/admin/views/utils.py rename {passbook => authentik}/api/__init__.py (100%) create mode 100644 authentik/api/apps.py create mode 100644 authentik/api/auth.py rename {passbook => authentik}/api/pagination.py (100%) create mode 100644 authentik/api/templates/rest_framework/api.html create mode 100644 authentik/api/urls.py rename {passbook => authentik}/api/v2/__init__.py (100%) create mode 100644 authentik/api/v2/config.py rename {passbook => authentik}/api/v2/messages.py (100%) create mode 100644 authentik/api/v2/urls.py rename {passbook => authentik}/audit/__init__.py (100%) create mode 100644 authentik/audit/api.py create mode 100644 authentik/audit/apps.py create mode 100644 authentik/audit/middleware.py rename {passbook => authentik}/audit/migrations/0001_initial.py (100%) create mode 100644 authentik/audit/migrations/0002_auto_20200918_2116.py create mode 100644 authentik/audit/migrations/0003_auto_20200917_1155.py create mode 100644 authentik/audit/migrations/0004_auto_20200921_1829.py create mode 100644 authentik/audit/migrations/0005_auto_20201005_2139.py create mode 100644 authentik/audit/migrations/0006_auto_20201017_2024.py rename {passbook => authentik}/audit/migrations/__init__.py (100%) create mode 100644 authentik/audit/models.py create mode 100644 authentik/audit/signals.py create mode 100644 authentik/audit/templates/audit/list.html rename {passbook => authentik}/audit/tests/__init__.py (100%) create mode 100644 authentik/audit/tests/test_event.py create mode 100644 authentik/audit/urls.py create mode 100644 authentik/audit/views.py rename {passbook => authentik}/core/__init__.py (100%) create mode 100644 authentik/core/admin.py rename {passbook => authentik}/core/api/__init__.py (100%) create mode 100644 authentik/core/api/applications.py create mode 100644 authentik/core/api/groups.py create mode 100644 authentik/core/api/propertymappings.py create mode 100644 authentik/core/api/providers.py create mode 100644 authentik/core/api/sources.py create mode 100644 authentik/core/api/tokens.py create mode 100644 authentik/core/api/users.py create mode 100644 authentik/core/apps.py create mode 100644 authentik/core/channels.py create mode 100644 authentik/core/exceptions.py create mode 100644 authentik/core/expression.py rename {passbook => authentik}/core/forms/__init__.py (100%) create mode 100644 authentik/core/forms/applications.py create mode 100644 authentik/core/forms/groups.py create mode 100644 authentik/core/forms/token.py create mode 100644 authentik/core/forms/users.py create mode 100644 authentik/core/middleware.py create mode 100644 authentik/core/migrations/0001_initial.py create mode 100644 authentik/core/migrations/0002_auto_20200523_1133.py create mode 100644 authentik/core/migrations/0003_default_user.py create mode 100644 authentik/core/migrations/0004_auto_20200703_2213.py create mode 100644 authentik/core/migrations/0005_token_intent.py create mode 100644 authentik/core/migrations/0006_auto_20200709_1608.py create mode 100644 authentik/core/migrations/0007_auto_20200815_1841.py create mode 100644 authentik/core/migrations/0008_auto_20200824_1532.py create mode 100644 authentik/core/migrations/0009_group_is_superuser.py create mode 100644 authentik/core/migrations/0010_auto_20200917_1021.py create mode 100644 authentik/core/migrations/0011_provider_name_temp.py create mode 100644 authentik/core/migrations/0012_auto_20201003_1737.py create mode 100644 authentik/core/migrations/0013_auto_20201003_2132.py create mode 100644 authentik/core/migrations/0014_auto_20201018_1158.py create mode 100644 authentik/core/migrations/0015_application_icon.py create mode 100644 authentik/core/migrations/0016_auto_20201202_2234.py rename {passbook => authentik}/core/migrations/__init__.py (100%) create mode 100644 authentik/core/models.py create mode 100644 authentik/core/signals.py create mode 100644 authentik/core/tasks.py create mode 100644 authentik/core/templates/403_csrf.html create mode 100644 authentik/core/templates/base/page.html create mode 100644 authentik/core/templates/base/skeleton.html create mode 100644 authentik/core/templates/error/generic.html create mode 100644 authentik/core/templates/generic/autosubmit_form.html create mode 100644 authentik/core/templates/generic/autosubmit_form_full.html create mode 100644 authentik/core/templates/generic/delete.html create mode 100644 authentik/core/templates/library.html create mode 100644 authentik/core/templates/login/base.html create mode 100644 authentik/core/templates/login/base_full.html rename {passbook => authentik}/core/templates/login/form.html (100%) create mode 100644 authentik/core/templates/login/form_with_user.html create mode 100644 authentik/core/templates/login/loading.html create mode 100644 authentik/core/templates/partials/form.html create mode 100644 authentik/core/templates/partials/form_horizontal.html create mode 100644 authentik/core/templates/partials/pagination.html rename {passbook => authentik}/core/templates/partials/toolbar_search.html (100%) create mode 100644 authentik/core/templates/shell.html create mode 100644 authentik/core/templates/user/settings.html create mode 100644 authentik/core/templates/user/token_list.html rename {passbook => authentik}/core/templatetags/__init__.py (100%) create mode 100644 authentik/core/templatetags/authentik_user_settings.py rename {passbook => authentik}/core/tests/__init__.py (100%) create mode 100644 authentik/core/tests/test_impersonation.py create mode 100644 authentik/core/tests/test_tasks.py create mode 100644 authentik/core/tests/test_views_overview.py create mode 100644 authentik/core/tests/test_views_user.py create mode 100644 authentik/core/types.py create mode 100644 authentik/core/urls.py rename {passbook => authentik}/core/views/__init__.py (100%) create mode 100644 authentik/core/views/error.py create mode 100644 authentik/core/views/impersonate.py create mode 100644 authentik/core/views/library.py rename {passbook => authentik}/core/views/shell.py (100%) create mode 100644 authentik/core/views/user.py rename {passbook => authentik}/crypto/__init__.py (100%) create mode 100644 authentik/crypto/api.py create mode 100644 authentik/crypto/apps.py create mode 100644 authentik/crypto/builder.py create mode 100644 authentik/crypto/forms.py rename {passbook => authentik}/crypto/migrations/0001_initial.py (100%) create mode 100644 authentik/crypto/migrations/0002_create_self_signed_kp.py rename {passbook => authentik}/crypto/migrations/__init__.py (100%) create mode 100644 authentik/crypto/models.py create mode 100644 authentik/crypto/tests.py rename {passbook => authentik}/flows/__init__.py (100%) create mode 100644 authentik/flows/api.py create mode 100644 authentik/flows/apps.py rename {passbook => authentik}/flows/exceptions.py (100%) create mode 100644 authentik/flows/forms.py rename {passbook => authentik}/flows/management/__init__.py (100%) rename {passbook => authentik}/flows/management/commands/__init__.py (100%) create mode 100644 authentik/flows/management/commands/apply_flow.py create mode 100644 authentik/flows/management/commands/benchmark.py create mode 100644 authentik/flows/markers.py create mode 100644 authentik/flows/migrations/0001_initial.py create mode 100644 authentik/flows/migrations/0003_auto_20200523_1133.py create mode 100644 authentik/flows/migrations/0006_auto_20200629_0857.py create mode 100644 authentik/flows/migrations/0007_auto_20200703_2059.py create mode 100644 authentik/flows/migrations/0008_default_flows.py create mode 100644 authentik/flows/migrations/0009_source_flows.py create mode 100644 authentik/flows/migrations/0010_provider_flows.py create mode 100644 authentik/flows/migrations/0011_flow_title.py create mode 100644 authentik/flows/migrations/0012_auto_20200908_1542.py create mode 100644 authentik/flows/migrations/0013_auto_20200924_1605.py create mode 100644 authentik/flows/migrations/0014_auto_20200925_2332.py create mode 100644 authentik/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py create mode 100644 authentik/flows/migrations/0016_auto_20201202_1307.py rename {passbook => authentik}/flows/migrations/__init__.py (100%) create mode 100644 authentik/flows/models.py create mode 100644 authentik/flows/planner.py create mode 100644 authentik/flows/signals.py create mode 100644 authentik/flows/stage.py create mode 100644 authentik/flows/templates/flows/denied_shell.html create mode 100644 authentik/flows/templates/flows/error.html create mode 100644 authentik/flows/templates/flows/shell.html rename {passbook => authentik}/flows/tests/__init__.py (100%) create mode 100644 authentik/flows/tests/test_misc.py create mode 100644 authentik/flows/tests/test_models.py create mode 100644 authentik/flows/tests/test_planner.py create mode 100644 authentik/flows/tests/test_transfer.py create mode 100644 authentik/flows/tests/test_transfer_docs.py create mode 100644 authentik/flows/tests/test_views.py create mode 100644 authentik/flows/tests/test_views_helper.py rename {passbook => authentik}/flows/transfer/__init__.py (100%) create mode 100644 authentik/flows/transfer/common.py create mode 100644 authentik/flows/transfer/exporter.py create mode 100644 authentik/flows/transfer/importer.py create mode 100644 authentik/flows/urls.py create mode 100644 authentik/flows/views.py rename {passbook => authentik}/lib/__init__.py (100%) create mode 100644 authentik/lib/apps.py create mode 100644 authentik/lib/config.py create mode 100644 authentik/lib/default.yml rename {passbook => authentik}/lib/expression/__init__.py (100%) create mode 100644 authentik/lib/expression/evaluator.py create mode 100644 authentik/lib/logging.py rename {passbook => authentik}/lib/models.py (100%) create mode 100644 authentik/lib/sentry.py rename {passbook => authentik}/lib/tasks.py (100%) create mode 100644 authentik/lib/templates/lib/arrayfield.html rename {passbook => authentik}/lib/templatetags/__init__.py (100%) create mode 100644 authentik/lib/templatetags/authentik_is_active.py create mode 100644 authentik/lib/templatetags/authentik_utils.py create mode 100644 authentik/lib/tests.py rename {passbook => authentik}/lib/utils/__init__.py (100%) rename {passbook => authentik}/lib/utils/http.py (100%) create mode 100644 authentik/lib/utils/reflection.py create mode 100644 authentik/lib/utils/template.py rename {passbook => authentik}/lib/utils/time.py (100%) create mode 100644 authentik/lib/utils/ui.py rename {passbook => authentik}/lib/utils/urls.py (100%) create mode 100644 authentik/lib/views.py rename {passbook => authentik}/lib/widgets.py (100%) rename {passbook => authentik}/outposts/__init__.py (100%) create mode 100644 authentik/outposts/api.py create mode 100644 authentik/outposts/apps.py create mode 100644 authentik/outposts/channels.py rename {passbook => authentik}/outposts/controllers/__init__.py (100%) create mode 100644 authentik/outposts/controllers/base.py create mode 100644 authentik/outposts/controllers/docker.py rename {passbook => authentik}/outposts/controllers/k8s/__init__.py (100%) create mode 100644 authentik/outposts/controllers/k8s/base.py create mode 100644 authentik/outposts/controllers/k8s/deployment.py create mode 100644 authentik/outposts/controllers/k8s/secret.py create mode 100644 authentik/outposts/controllers/k8s/service.py create mode 100644 authentik/outposts/controllers/kubernetes.py create mode 100644 authentik/outposts/docker_tls.py create mode 100644 authentik/outposts/forms.py create mode 100644 authentik/outposts/migrations/0001_initial.py create mode 100644 authentik/outposts/migrations/0002_auto_20200826_1306.py create mode 100644 authentik/outposts/migrations/0003_auto_20200827_2108.py create mode 100644 authentik/outposts/migrations/0004_auto_20200830_1056.py create mode 100644 authentik/outposts/migrations/0005_auto_20200909_1733.py create mode 100644 authentik/outposts/migrations/0006_auto_20201003_2239.py create mode 100644 authentik/outposts/migrations/0007_remove_outpost_channels.py create mode 100644 authentik/outposts/migrations/0008_auto_20201014_1547.py create mode 100644 authentik/outposts/migrations/0009_fix_missing_token_identifier.py create mode 100644 authentik/outposts/migrations/0010_service_connection.py create mode 100644 authentik/outposts/migrations/0011_docker_tls_auth.py create mode 100644 authentik/outposts/migrations/0012_service_connection_non_unique.py create mode 100644 authentik/outposts/migrations/0013_auto_20201203_2009.py rename {passbook => authentik}/outposts/migrations/__init__.py (100%) create mode 100644 authentik/outposts/models.py create mode 100644 authentik/outposts/settings.py create mode 100644 authentik/outposts/signals.py create mode 100644 authentik/outposts/tasks.py create mode 100644 authentik/outposts/templates/outposts/deployment_modal.html create mode 100644 authentik/outposts/tests.py create mode 100644 authentik/outposts/urls.py create mode 100644 authentik/outposts/views.py rename {passbook => authentik}/policies/__init__.py (100%) create mode 100644 authentik/policies/api.py create mode 100644 authentik/policies/apps.py rename {passbook => authentik}/policies/dummy/__init__.py (100%) create mode 100644 authentik/policies/dummy/api.py create mode 100644 authentik/policies/dummy/apps.py create mode 100644 authentik/policies/dummy/forms.py create mode 100644 authentik/policies/dummy/migrations/0001_initial.py rename {passbook => authentik}/policies/dummy/migrations/__init__.py (100%) create mode 100644 authentik/policies/dummy/models.py create mode 100644 authentik/policies/dummy/tests.py create mode 100644 authentik/policies/engine.py create mode 100644 authentik/policies/exceptions.py rename {passbook => authentik}/policies/expiry/__init__.py (100%) create mode 100644 authentik/policies/expiry/api.py create mode 100644 authentik/policies/expiry/apps.py create mode 100644 authentik/policies/expiry/forms.py create mode 100644 authentik/policies/expiry/migrations/0001_initial.py rename {passbook => authentik}/policies/expiry/migrations/__init__.py (100%) create mode 100644 authentik/policies/expiry/models.py rename {passbook => authentik}/policies/expression/__init__.py (100%) create mode 100644 authentik/policies/expression/api.py create mode 100644 authentik/policies/expression/apps.py create mode 100644 authentik/policies/expression/evaluator.py create mode 100644 authentik/policies/expression/forms.py create mode 100644 authentik/policies/expression/migrations/0001_initial.py create mode 100644 authentik/policies/expression/migrations/0002_auto_20200926_1156.py create mode 100644 authentik/policies/expression/migrations/0003_auto_20201203_1223.py rename {passbook => authentik}/policies/expression/migrations/__init__.py (100%) create mode 100644 authentik/policies/expression/models.py create mode 100644 authentik/policies/expression/templates/policy/expression/form.html create mode 100644 authentik/policies/expression/tests.py create mode 100644 authentik/policies/forms.py rename {passbook => authentik}/policies/group_membership/__init__.py (100%) create mode 100644 authentik/policies/group_membership/api.py create mode 100644 authentik/policies/group_membership/apps.py create mode 100644 authentik/policies/group_membership/forms.py create mode 100644 authentik/policies/group_membership/migrations/0001_initial.py rename {passbook => authentik}/policies/group_membership/migrations/__init__.py (100%) create mode 100644 authentik/policies/group_membership/models.py create mode 100644 authentik/policies/group_membership/tests.py rename {passbook => authentik}/policies/hibp/__init__.py (100%) create mode 100644 authentik/policies/hibp/api.py create mode 100644 authentik/policies/hibp/apps.py create mode 100644 authentik/policies/hibp/forms.py create mode 100644 authentik/policies/hibp/migrations/0001_initial.py create mode 100644 authentik/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py rename {passbook => authentik}/policies/hibp/migrations/__init__.py (100%) create mode 100644 authentik/policies/hibp/models.py create mode 100644 authentik/policies/hibp/tests.py create mode 100644 authentik/policies/http.py create mode 100644 authentik/policies/migrations/0001_initial.py create mode 100644 authentik/policies/migrations/0002_auto_20200528_1647.py create mode 100644 authentik/policies/migrations/0003_auto_20200908_1542.py rename {passbook => authentik}/policies/migrations/__init__.py (100%) create mode 100644 authentik/policies/models.py rename {passbook => authentik}/policies/password/__init__.py (100%) create mode 100644 authentik/policies/password/api.py create mode 100644 authentik/policies/password/apps.py create mode 100644 authentik/policies/password/forms.py create mode 100644 authentik/policies/password/migrations/0001_initial.py create mode 100644 authentik/policies/password/migrations/0002_passwordpolicy_password_field.py rename {passbook => authentik}/policies/password/migrations/__init__.py (100%) create mode 100644 authentik/policies/password/models.py create mode 100644 authentik/policies/password/tests.py create mode 100644 authentik/policies/process.py rename {passbook => authentik}/policies/reputation/__init__.py (100%) create mode 100644 authentik/policies/reputation/api.py create mode 100644 authentik/policies/reputation/apps.py create mode 100644 authentik/policies/reputation/forms.py create mode 100644 authentik/policies/reputation/migrations/0001_initial.py rename {passbook => authentik}/policies/reputation/migrations/__init__.py (100%) create mode 100644 authentik/policies/reputation/models.py create mode 100644 authentik/policies/reputation/settings.py create mode 100644 authentik/policies/reputation/signals.py create mode 100644 authentik/policies/reputation/tasks.py create mode 100644 authentik/policies/reputation/tests.py create mode 100644 authentik/policies/signals.py create mode 100644 authentik/policies/templates/policies/denied.html rename {passbook => authentik}/policies/tests/__init__.py (100%) create mode 100644 authentik/policies/tests/test_engine.py create mode 100644 authentik/policies/tests/test_models.py create mode 100644 authentik/policies/types.py rename {passbook => authentik}/policies/utils.py (100%) create mode 100644 authentik/policies/views.py rename {passbook => authentik}/providers/__init__.py (100%) rename {passbook => authentik}/providers/oauth2/__init__.py (100%) create mode 100644 authentik/providers/oauth2/api.py create mode 100644 authentik/providers/oauth2/apps.py rename {passbook => authentik}/providers/oauth2/constants.py (100%) rename {passbook => authentik}/providers/oauth2/errors.py (100%) create mode 100644 authentik/providers/oauth2/forms.py rename {passbook => authentik}/providers/oauth2/generators.py (100%) create mode 100644 authentik/providers/oauth2/migrations/0001_initial.py create mode 100644 authentik/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py create mode 100644 authentik/providers/oauth2/migrations/0003_auto_20200916_2129.py create mode 100644 authentik/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py create mode 100644 authentik/providers/oauth2/migrations/0005_auto_20200920_1240.py create mode 100644 authentik/providers/oauth2/migrations/0006_remove_oauth2provider_name.py create mode 100644 authentik/providers/oauth2/migrations/0007_auto_20201016_1107.py rename {passbook => authentik}/providers/oauth2/migrations/__init__.py (100%) create mode 100644 authentik/providers/oauth2/models.py rename {passbook => authentik}/providers/oauth2/templates/providers/oauth2/consent.html (100%) create mode 100644 authentik/providers/oauth2/templates/providers/oauth2/end_session.html create mode 100644 authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html create mode 100644 authentik/providers/oauth2/templates/providers/oauth2/setup_url_modal.html create mode 100644 authentik/providers/oauth2/urls.py create mode 100644 authentik/providers/oauth2/urls_github.py create mode 100644 authentik/providers/oauth2/utils.py rename {passbook => authentik}/providers/oauth2/views/__init__.py (100%) create mode 100644 authentik/providers/oauth2/views/authorize.py create mode 100644 authentik/providers/oauth2/views/github.py create mode 100644 authentik/providers/oauth2/views/introspection.py create mode 100644 authentik/providers/oauth2/views/jwks.py create mode 100644 authentik/providers/oauth2/views/provider.py create mode 100644 authentik/providers/oauth2/views/session.py create mode 100644 authentik/providers/oauth2/views/token.py create mode 100644 authentik/providers/oauth2/views/userinfo.py rename {passbook => authentik}/providers/proxy/__init__.py (100%) create mode 100644 authentik/providers/proxy/api.py create mode 100644 authentik/providers/proxy/apps.py rename {passbook => authentik}/providers/proxy/controllers/__init__.py (100%) create mode 100644 authentik/providers/proxy/controllers/docker.py rename {passbook => authentik}/providers/proxy/controllers/k8s/__init__.py (100%) create mode 100644 authentik/providers/proxy/controllers/k8s/ingress.py create mode 100644 authentik/providers/proxy/controllers/kubernetes.py create mode 100644 authentik/providers/proxy/forms.py create mode 100644 authentik/providers/proxy/migrations/0001_initial.py create mode 100644 authentik/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py create mode 100644 authentik/providers/proxy/migrations/0003_proxyprovider_certificate.py create mode 100644 authentik/providers/proxy/migrations/0004_auto_20200913_1947.py create mode 100644 authentik/providers/proxy/migrations/0005_auto_20200914_1536.py create mode 100644 authentik/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py create mode 100644 authentik/providers/proxy/migrations/0007_auto_20200923_1017.py create mode 100644 authentik/providers/proxy/migrations/0008_auto_20200930_0810.py create mode 100644 authentik/providers/proxy/migrations/0009_auto_20201007_1721.py rename {passbook => authentik}/providers/proxy/migrations/__init__.py (100%) create mode 100644 authentik/providers/proxy/models.py rename {passbook => authentik}/providers/proxy/provider/__init__.py (100%) rename {passbook => authentik}/providers/proxy/provider/kubernetes/__init__.py (100%) rename {passbook => authentik}/providers/saml/__init__.py (100%) create mode 100644 authentik/providers/saml/api.py create mode 100644 authentik/providers/saml/apps.py create mode 100644 authentik/providers/saml/exceptions.py create mode 100644 authentik/providers/saml/forms.py create mode 100644 authentik/providers/saml/migrations/0001_initial.py create mode 100644 authentik/providers/saml/migrations/0002_default_saml_property_mappings.py create mode 100644 authentik/providers/saml/migrations/0003_samlprovider_sp_binding.py create mode 100644 authentik/providers/saml/migrations/0004_auto_20200620_1950.py create mode 100644 authentik/providers/saml/migrations/0005_remove_samlprovider_processor_path.py create mode 100644 authentik/providers/saml/migrations/0006_remove_samlprovider_name.py create mode 100644 authentik/providers/saml/migrations/0007_samlprovider_verification_kp.py create mode 100644 authentik/providers/saml/migrations/0008_auto_20201112_1036.py create mode 100644 authentik/providers/saml/migrations/0009_auto_20201112_2016.py rename {passbook => authentik}/providers/saml/migrations/__init__.py (100%) create mode 100644 authentik/providers/saml/models.py rename {passbook => authentik}/providers/saml/processors/__init__.py (100%) create mode 100644 authentik/providers/saml/processors/assertion.py create mode 100644 authentik/providers/saml/processors/metadata.py create mode 100644 authentik/providers/saml/processors/request_parser.py create mode 100644 authentik/providers/saml/settings.py create mode 100644 authentik/providers/saml/templates/providers/saml/admin_metadata_modal.html rename {passbook => authentik}/providers/saml/templates/providers/saml/consent.html (100%) rename {passbook => authentik}/providers/saml/templates/providers/saml/logged_out.html (100%) create mode 100644 authentik/providers/saml/templates/providers/saml/property_mapping_form.html rename {passbook => authentik}/providers/saml/tests/__init__.py (100%) create mode 100644 authentik/providers/saml/tests/test_auth_n_request.py create mode 100644 authentik/providers/saml/tests/test_utils_time.py create mode 100644 authentik/providers/saml/urls.py rename {passbook => authentik}/providers/saml/utils/__init__.py (100%) rename {passbook => authentik}/providers/saml/utils/encoding.py (100%) rename {passbook => authentik}/providers/saml/utils/time.py (100%) create mode 100644 authentik/providers/saml/views.py rename {passbook => authentik}/recovery/__init__.py (100%) create mode 100644 authentik/recovery/apps.py rename {passbook => authentik}/recovery/management/__init__.py (100%) rename {passbook => authentik}/recovery/management/commands/__init__.py (100%) create mode 100644 authentik/recovery/management/commands/create_recovery_key.py create mode 100644 authentik/recovery/tests.py create mode 100644 authentik/recovery/urls.py create mode 100644 authentik/recovery/views.py rename {passbook => authentik}/root/__init__.py (100%) create mode 100644 authentik/root/asgi.py create mode 100644 authentik/root/celery.py rename {passbook => authentik}/root/messages/__init__.py (100%) rename {passbook => authentik}/root/messages/consumer.py (100%) rename {passbook => authentik}/root/messages/storage.py (100%) create mode 100644 authentik/root/monitoring.py create mode 100644 authentik/root/settings.py create mode 100644 authentik/root/test_runner.py rename {passbook => authentik}/root/tests.py (100%) create mode 100644 authentik/root/urls.py create mode 100644 authentik/root/websocket.py rename {passbook => authentik}/sources/__init__.py (100%) rename {passbook => authentik}/sources/ldap/__init__.py (100%) create mode 100644 authentik/sources/ldap/api.py create mode 100644 authentik/sources/ldap/apps.py create mode 100644 authentik/sources/ldap/auth.py create mode 100644 authentik/sources/ldap/forms.py create mode 100644 authentik/sources/ldap/migrations/0001_initial.py create mode 100644 authentik/sources/ldap/migrations/0002_ldapsource_sync_users.py create mode 100644 authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py create mode 100644 authentik/sources/ldap/migrations/0004_auto_20200524_1146.py create mode 100644 authentik/sources/ldap/migrations/0005_auto_20200913_1947.py create mode 100644 authentik/sources/ldap/migrations/0006_auto_20200915_1919.py create mode 100644 authentik/sources/ldap/migrations/0007_ldapsource_sync_users_password.py rename {passbook => authentik}/sources/ldap/migrations/__init__.py (100%) create mode 100644 authentik/sources/ldap/models.py create mode 100644 authentik/sources/ldap/password.py create mode 100644 authentik/sources/ldap/settings.py create mode 100644 authentik/sources/ldap/signals.py create mode 100644 authentik/sources/ldap/sync.py create mode 100644 authentik/sources/ldap/tasks.py create mode 100644 authentik/sources/ldap/templates/ldap/property_mapping_form.html rename {passbook => authentik}/sources/ldap/templates/ldap/source_list_status.html (100%) rename {passbook => authentik}/sources/ldap/tests/__init__.py (100%) create mode 100644 authentik/sources/ldap/tests/test_auth.py create mode 100644 authentik/sources/ldap/tests/test_password.py create mode 100644 authentik/sources/ldap/tests/test_sync.py rename {passbook => authentik}/sources/ldap/tests/utils.py (100%) rename {passbook => authentik}/sources/oauth/__init__.py (100%) create mode 100644 authentik/sources/oauth/api.py create mode 100644 authentik/sources/oauth/apps.py create mode 100644 authentik/sources/oauth/auth.py rename {passbook => authentik}/sources/oauth/clients/__init__.py (100%) create mode 100644 authentik/sources/oauth/clients/base.py create mode 100644 authentik/sources/oauth/clients/oauth1.py create mode 100644 authentik/sources/oauth/clients/oauth2.py create mode 100644 authentik/sources/oauth/exceptions.py create mode 100644 authentik/sources/oauth/forms.py create mode 100644 authentik/sources/oauth/migrations/0001_initial.py create mode 100644 authentik/sources/oauth/migrations/0002_auto_20200520_1108.py rename {passbook => authentik}/sources/oauth/migrations/__init__.py (100%) create mode 100644 authentik/sources/oauth/models.py create mode 100644 authentik/sources/oauth/settings.py create mode 100644 authentik/sources/oauth/templates/oauth_client/user.html create mode 100644 authentik/sources/oauth/tests.py rename {passbook => authentik}/sources/oauth/types/__init__.py (100%) create mode 100644 authentik/sources/oauth/types/azure_ad.py create mode 100644 authentik/sources/oauth/types/discord.py create mode 100644 authentik/sources/oauth/types/facebook.py create mode 100644 authentik/sources/oauth/types/github.py create mode 100644 authentik/sources/oauth/types/google.py create mode 100644 authentik/sources/oauth/types/manager.py create mode 100644 authentik/sources/oauth/types/oidc.py create mode 100644 authentik/sources/oauth/types/reddit.py create mode 100644 authentik/sources/oauth/types/twitter.py create mode 100644 authentik/sources/oauth/urls.py rename {passbook => authentik}/sources/oauth/views/__init__.py (100%) create mode 100644 authentik/sources/oauth/views/base.py create mode 100644 authentik/sources/oauth/views/callback.py create mode 100644 authentik/sources/oauth/views/dispatcher.py create mode 100644 authentik/sources/oauth/views/flows.py create mode 100644 authentik/sources/oauth/views/redirect.py create mode 100644 authentik/sources/oauth/views/user.py rename {passbook => authentik}/sources/saml/__init__.py (100%) create mode 100644 authentik/sources/saml/api.py create mode 100644 authentik/sources/saml/apps.py create mode 100644 authentik/sources/saml/exceptions.py create mode 100644 authentik/sources/saml/forms.py create mode 100644 authentik/sources/saml/migrations/0001_initial.py create mode 100644 authentik/sources/saml/migrations/0002_auto_20200523_2329.py create mode 100644 authentik/sources/saml/migrations/0003_auto_20200624_1957.py create mode 100644 authentik/sources/saml/migrations/0004_auto_20200708_1207.py create mode 100644 authentik/sources/saml/migrations/0005_samlsource_name_id_policy.py create mode 100644 authentik/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py create mode 100644 authentik/sources/saml/migrations/0007_auto_20201112_1055.py create mode 100644 authentik/sources/saml/migrations/0008_auto_20201112_2016.py rename {passbook => authentik}/sources/saml/migrations/__init__.py (100%) create mode 100644 authentik/sources/saml/models.py rename {passbook => authentik}/sources/saml/processors/__init__.py (100%) rename {passbook => authentik}/sources/saml/processors/constants.py (100%) create mode 100644 authentik/sources/saml/processors/metadata.py create mode 100644 authentik/sources/saml/processors/request.py create mode 100644 authentik/sources/saml/processors/response.py create mode 100644 authentik/sources/saml/settings.py create mode 100644 authentik/sources/saml/signals.py create mode 100644 authentik/sources/saml/tasks.py create mode 100644 authentik/sources/saml/templates/saml/sp/login.html create mode 100644 authentik/sources/saml/tests.py create mode 100644 authentik/sources/saml/urls.py create mode 100644 authentik/sources/saml/views.py rename {passbook => authentik}/stages/__init__.py (100%) rename {passbook => authentik}/stages/captcha/__init__.py (100%) create mode 100644 authentik/stages/captcha/api.py create mode 100644 authentik/stages/captcha/apps.py create mode 100644 authentik/stages/captcha/forms.py create mode 100644 authentik/stages/captcha/migrations/0001_initial.py rename {passbook => authentik}/stages/captcha/migrations/__init__.py (100%) create mode 100644 authentik/stages/captcha/models.py create mode 100644 authentik/stages/captcha/settings.py create mode 100644 authentik/stages/captcha/stage.py create mode 100644 authentik/stages/captcha/tests.py rename {passbook => authentik}/stages/consent/__init__.py (100%) create mode 100644 authentik/stages/consent/api.py create mode 100644 authentik/stages/consent/apps.py create mode 100644 authentik/stages/consent/forms.py create mode 100644 authentik/stages/consent/migrations/0001_initial.py create mode 100644 authentik/stages/consent/migrations/0002_auto_20200720_0941.py create mode 100644 authentik/stages/consent/migrations/0003_auto_20200924_1403.py rename {passbook => authentik}/stages/consent/migrations/__init__.py (100%) create mode 100644 authentik/stages/consent/models.py create mode 100644 authentik/stages/consent/stage.py rename {passbook => authentik}/stages/consent/templates/stages/consent/fallback.html (100%) create mode 100644 authentik/stages/consent/tests.py rename {passbook => authentik}/stages/dummy/__init__.py (100%) create mode 100644 authentik/stages/dummy/api.py create mode 100644 authentik/stages/dummy/apps.py create mode 100644 authentik/stages/dummy/forms.py create mode 100644 authentik/stages/dummy/migrations/0001_initial.py rename {passbook => authentik}/stages/dummy/migrations/__init__.py (100%) create mode 100644 authentik/stages/dummy/models.py create mode 100644 authentik/stages/dummy/stage.py create mode 100644 authentik/stages/dummy/tests.py rename {passbook => authentik}/stages/email/__init__.py (100%) create mode 100644 authentik/stages/email/api.py create mode 100644 authentik/stages/email/apps.py create mode 100644 authentik/stages/email/forms.py create mode 100644 authentik/stages/email/migrations/0001_initial.py rename {passbook => authentik}/stages/email/migrations/__init__.py (100%) create mode 100644 authentik/stages/email/models.py create mode 100644 authentik/stages/email/stage.py rename {passbook => authentik}/stages/email/static/stages/email/css/base.css (100%) create mode 100644 authentik/stages/email/tasks.py create mode 100644 authentik/stages/email/templates/stages/email/for_email/account_confirmation.html create mode 100644 authentik/stages/email/templates/stages/email/for_email/base.html rename {passbook => authentik}/stages/email/templates/stages/email/for_email/generic_email.html (100%) create mode 100644 authentik/stages/email/templates/stages/email/for_email/password_reset.html rename {passbook => authentik}/stages/email/templates/stages/email/waiting_message.html (100%) rename {passbook => authentik}/stages/email/templatetags/__init__.py (100%) create mode 100644 authentik/stages/email/templatetags/authentik_stages_email.py create mode 100644 authentik/stages/email/tests.py rename {passbook => authentik}/stages/email/utils.py (100%) rename {passbook => authentik}/stages/identification/__init__.py (100%) create mode 100644 authentik/stages/identification/api.py create mode 100644 authentik/stages/identification/apps.py create mode 100644 authentik/stages/identification/forms.py create mode 100644 authentik/stages/identification/migrations/0001_initial.py create mode 100644 authentik/stages/identification/migrations/0002_auto_20200530_2204.py create mode 100644 authentik/stages/identification/migrations/0003_auto_20200615_1641.py create mode 100644 authentik/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py create mode 100644 authentik/stages/identification/migrations/0005_auto_20201003_1734.py rename {passbook => authentik}/stages/identification/migrations/__init__.py (100%) create mode 100644 authentik/stages/identification/models.py create mode 100644 authentik/stages/identification/stage.py rename {passbook => authentik}/stages/identification/templates/stages/identification/login.html (100%) rename {passbook => authentik}/stages/identification/templates/stages/identification/recovery.html (100%) create mode 100644 authentik/stages/identification/tests.py rename {passbook => authentik}/stages/invitation/__init__.py (100%) create mode 100644 authentik/stages/invitation/api.py create mode 100644 authentik/stages/invitation/apps.py create mode 100644 authentik/stages/invitation/forms.py create mode 100644 authentik/stages/invitation/migrations/0001_initial.py rename {passbook => authentik}/stages/invitation/migrations/__init__.py (100%) create mode 100644 authentik/stages/invitation/models.py create mode 100644 authentik/stages/invitation/signals.py create mode 100644 authentik/stages/invitation/stage.py create mode 100644 authentik/stages/invitation/tests.py rename {passbook => authentik}/stages/otp_static/__init__.py (100%) create mode 100644 authentik/stages/otp_static/api.py create mode 100644 authentik/stages/otp_static/apps.py create mode 100644 authentik/stages/otp_static/forms.py create mode 100644 authentik/stages/otp_static/migrations/0001_initial.py create mode 100644 authentik/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py create mode 100644 authentik/stages/otp_static/migrations/0003_default_setup_flow.py rename {passbook => authentik}/stages/otp_static/migrations/__init__.py (100%) create mode 100644 authentik/stages/otp_static/models.py rename {passbook => authentik}/stages/otp_static/settings.py (100%) create mode 100644 authentik/stages/otp_static/stage.py create mode 100644 authentik/stages/otp_static/templates/stages/otp_static/user_settings.html create mode 100644 authentik/stages/otp_static/urls.py create mode 100644 authentik/stages/otp_static/views.py rename {passbook => authentik}/stages/otp_time/__init__.py (100%) create mode 100644 authentik/stages/otp_time/api.py create mode 100644 authentik/stages/otp_time/apps.py create mode 100644 authentik/stages/otp_time/forms.py create mode 100644 authentik/stages/otp_time/migrations/0001_initial.py create mode 100644 authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py create mode 100644 authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py create mode 100644 authentik/stages/otp_time/migrations/0004_default_setup_flow.py rename {passbook => authentik}/stages/otp_time/migrations/__init__.py (100%) create mode 100644 authentik/stages/otp_time/models.py create mode 100644 authentik/stages/otp_time/settings.py create mode 100644 authentik/stages/otp_time/stage.py create mode 100644 authentik/stages/otp_time/templates/stages/otp_time/user_settings.html create mode 100644 authentik/stages/otp_time/urls.py create mode 100644 authentik/stages/otp_time/views.py rename {passbook => authentik}/stages/otp_validate/__init__.py (100%) create mode 100644 authentik/stages/otp_validate/api.py create mode 100644 authentik/stages/otp_validate/apps.py create mode 100644 authentik/stages/otp_validate/forms.py create mode 100644 authentik/stages/otp_validate/migrations/0001_initial.py rename {passbook => authentik}/stages/otp_validate/migrations/__init__.py (100%) create mode 100644 authentik/stages/otp_validate/models.py rename {passbook => authentik}/stages/otp_validate/settings.py (100%) create mode 100644 authentik/stages/otp_validate/stage.py rename {passbook => authentik}/stages/password/__init__.py (100%) create mode 100644 authentik/stages/password/api.py create mode 100644 authentik/stages/password/apps.py create mode 100644 authentik/stages/password/forms.py create mode 100644 authentik/stages/password/migrations/0001_initial.py create mode 100644 authentik/stages/password/migrations/0002_passwordstage_change_flow.py create mode 100644 authentik/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py create mode 100644 authentik/stages/password/migrations/0004_auto_20200925_1057.py rename {passbook => authentik}/stages/password/migrations/__init__.py (100%) create mode 100644 authentik/stages/password/models.py create mode 100644 authentik/stages/password/stage.py create mode 100644 authentik/stages/password/templates/stages/password/flow-form.html create mode 100644 authentik/stages/password/templates/stages/password/user-settings-card.html create mode 100644 authentik/stages/password/tests.py create mode 100644 authentik/stages/password/urls.py create mode 100644 authentik/stages/password/views.py rename {passbook => authentik}/stages/prompt/__init__.py (100%) create mode 100644 authentik/stages/prompt/api.py create mode 100644 authentik/stages/prompt/apps.py create mode 100644 authentik/stages/prompt/forms.py create mode 100644 authentik/stages/prompt/migrations/0001_initial.py create mode 100644 authentik/stages/prompt/migrations/0002_auto_20200920_1859.py rename {passbook => authentik}/stages/prompt/migrations/__init__.py (100%) create mode 100644 authentik/stages/prompt/models.py create mode 100644 authentik/stages/prompt/signals.py create mode 100644 authentik/stages/prompt/stage.py create mode 100644 authentik/stages/prompt/tests.py rename {passbook => authentik}/stages/prompt/widgets.py (100%) rename {passbook => authentik}/stages/user_delete/__init__.py (100%) create mode 100644 authentik/stages/user_delete/api.py create mode 100644 authentik/stages/user_delete/apps.py create mode 100644 authentik/stages/user_delete/forms.py create mode 100644 authentik/stages/user_delete/migrations/0001_initial.py rename {passbook => authentik}/stages/user_delete/migrations/__init__.py (100%) create mode 100644 authentik/stages/user_delete/models.py create mode 100644 authentik/stages/user_delete/stage.py create mode 100644 authentik/stages/user_delete/tests.py rename {passbook => authentik}/stages/user_login/__init__.py (100%) create mode 100644 authentik/stages/user_login/api.py create mode 100644 authentik/stages/user_login/apps.py create mode 100644 authentik/stages/user_login/forms.py create mode 100644 authentik/stages/user_login/migrations/0001_initial.py create mode 100644 authentik/stages/user_login/migrations/0002_userloginstage_session_duration.py create mode 100644 authentik/stages/user_login/migrations/0003_session_duration_delta.py rename {passbook => authentik}/stages/user_login/migrations/__init__.py (100%) create mode 100644 authentik/stages/user_login/models.py create mode 100644 authentik/stages/user_login/stage.py create mode 100644 authentik/stages/user_login/tests.py rename {passbook => authentik}/stages/user_logout/__init__.py (100%) create mode 100644 authentik/stages/user_logout/api.py create mode 100644 authentik/stages/user_logout/apps.py create mode 100644 authentik/stages/user_logout/forms.py create mode 100644 authentik/stages/user_logout/migrations/0001_initial.py rename {passbook => authentik}/stages/user_logout/migrations/__init__.py (100%) create mode 100644 authentik/stages/user_logout/models.py create mode 100644 authentik/stages/user_logout/stage.py create mode 100644 authentik/stages/user_logout/tests.py rename {passbook => authentik}/stages/user_write/__init__.py (100%) create mode 100644 authentik/stages/user_write/api.py create mode 100644 authentik/stages/user_write/apps.py create mode 100644 authentik/stages/user_write/forms.py create mode 100644 authentik/stages/user_write/migrations/0001_initial.py create mode 100644 authentik/stages/user_write/migrations/0002_auto_20200918_1653.py rename {passbook => authentik}/stages/user_write/migrations/__init__.py (100%) create mode 100644 authentik/stages/user_write/models.py create mode 100644 authentik/stages/user_write/signals.py create mode 100644 authentik/stages/user_write/stage.py create mode 100644 authentik/stages/user_write/tests.py create mode 100644 icons/authentik-working.ai create mode 100644 icons/brand.png create mode 100644 icons/brand.svg create mode 100644 icons/icon.png create mode 100644 icons/icon.svg create mode 100644 icons/icon_left_brand.png create mode 100644 icons/icon_left_brand.svg create mode 100644 icons/icon_top_brand.png create mode 100644 icons/icon_top_brand.svg create mode 100644 lifecycle/system_migrations/to_0_100_authentik.py delete mode 100644 passbook/__init__.py delete mode 100644 passbook/admin/api/overview.py delete mode 100644 passbook/admin/api/overview_metrics.py delete mode 100644 passbook/admin/api/tasks.py delete mode 100644 passbook/admin/apps.py delete mode 100644 passbook/admin/forms/policies.py delete mode 100644 passbook/admin/forms/source.py delete mode 100644 passbook/admin/forms/users.py delete mode 100644 passbook/admin/mixins.py delete mode 100644 passbook/admin/settings.py delete mode 100644 passbook/admin/tasks.py delete mode 100644 passbook/admin/templates/administration/application/list.html delete mode 100644 passbook/admin/templates/administration/certificatekeypair/list.html delete mode 100644 passbook/admin/templates/administration/flow/list.html delete mode 100644 passbook/admin/templates/administration/group/list.html delete mode 100644 passbook/admin/templates/administration/outpost/list.html delete mode 100644 passbook/admin/templates/administration/outpost_service_connection/list.html delete mode 100644 passbook/admin/templates/administration/overview.html delete mode 100644 passbook/admin/templates/administration/policy/list.html delete mode 100644 passbook/admin/templates/administration/policy_binding/list.html delete mode 100644 passbook/admin/templates/administration/property_mapping/list.html delete mode 100644 passbook/admin/templates/administration/provider/list.html delete mode 100644 passbook/admin/templates/administration/source/list.html delete mode 100644 passbook/admin/templates/administration/stage/list.html delete mode 100644 passbook/admin/templates/administration/stage_binding/list.html delete mode 100644 passbook/admin/templates/administration/stage_invitation/list.html delete mode 100644 passbook/admin/templates/administration/stage_prompt/list.html delete mode 100644 passbook/admin/templates/administration/task/list.html delete mode 100644 passbook/admin/templates/administration/token/list.html delete mode 100644 passbook/admin/templates/administration/user/disable.html delete mode 100644 passbook/admin/templates/administration/user/list.html delete mode 100644 passbook/admin/templates/fields/codemirror.html delete mode 100644 passbook/admin/templates/generic/create.html delete mode 100644 passbook/admin/templates/generic/form.html delete mode 100644 passbook/admin/templates/generic/form_non_model.html delete mode 100644 passbook/admin/templates/generic/update.html delete mode 100644 passbook/admin/templatetags/admin_reflection.py delete mode 100644 passbook/admin/tests.py delete mode 100644 passbook/admin/urls.py delete mode 100644 passbook/admin/views/applications.py delete mode 100644 passbook/admin/views/certificate_key_pair.py delete mode 100644 passbook/admin/views/flows.py delete mode 100644 passbook/admin/views/groups.py delete mode 100644 passbook/admin/views/outposts.py delete mode 100644 passbook/admin/views/outposts_service_connections.py delete mode 100644 passbook/admin/views/overview.py delete mode 100644 passbook/admin/views/policies.py delete mode 100644 passbook/admin/views/policies_bindings.py delete mode 100644 passbook/admin/views/property_mappings.py delete mode 100644 passbook/admin/views/providers.py delete mode 100644 passbook/admin/views/sources.py delete mode 100644 passbook/admin/views/stages.py delete mode 100644 passbook/admin/views/stages_bindings.py delete mode 100644 passbook/admin/views/stages_invitations.py delete mode 100644 passbook/admin/views/stages_prompts.py delete mode 100644 passbook/admin/views/tasks.py delete mode 100644 passbook/admin/views/tokens.py delete mode 100644 passbook/admin/views/users.py delete mode 100644 passbook/admin/views/utils.py delete mode 100644 passbook/api/apps.py delete mode 100644 passbook/api/auth.py delete mode 100644 passbook/api/templates/rest_framework/api.html delete mode 100644 passbook/api/urls.py delete mode 100644 passbook/api/v2/config.py delete mode 100644 passbook/api/v2/urls.py delete mode 100644 passbook/audit/api.py delete mode 100644 passbook/audit/apps.py delete mode 100644 passbook/audit/middleware.py delete mode 100644 passbook/audit/migrations/0002_auto_20200918_2116.py delete mode 100644 passbook/audit/migrations/0003_auto_20200917_1155.py delete mode 100644 passbook/audit/migrations/0004_auto_20200921_1829.py delete mode 100644 passbook/audit/migrations/0005_auto_20201005_2139.py delete mode 100644 passbook/audit/migrations/0006_auto_20201017_2024.py delete mode 100644 passbook/audit/models.py delete mode 100644 passbook/audit/signals.py delete mode 100644 passbook/audit/templates/audit/list.html delete mode 100644 passbook/audit/tests/test_event.py delete mode 100644 passbook/audit/urls.py delete mode 100644 passbook/audit/views.py delete mode 100644 passbook/core/admin.py delete mode 100644 passbook/core/api/applications.py delete mode 100644 passbook/core/api/groups.py delete mode 100644 passbook/core/api/propertymappings.py delete mode 100644 passbook/core/api/providers.py delete mode 100644 passbook/core/api/sources.py delete mode 100644 passbook/core/api/tokens.py delete mode 100644 passbook/core/api/users.py delete mode 100644 passbook/core/apps.py delete mode 100644 passbook/core/channels.py delete mode 100644 passbook/core/exceptions.py delete mode 100644 passbook/core/expression.py delete mode 100644 passbook/core/forms/applications.py delete mode 100644 passbook/core/forms/groups.py delete mode 100644 passbook/core/forms/token.py delete mode 100644 passbook/core/forms/users.py delete mode 100644 passbook/core/middleware.py delete mode 100644 passbook/core/migrations/0001_initial.py delete mode 100644 passbook/core/migrations/0002_auto_20200523_1133.py delete mode 100644 passbook/core/migrations/0003_default_user.py delete mode 100644 passbook/core/migrations/0004_auto_20200703_2213.py delete mode 100644 passbook/core/migrations/0005_token_intent.py delete mode 100644 passbook/core/migrations/0006_auto_20200709_1608.py delete mode 100644 passbook/core/migrations/0007_auto_20200815_1841.py delete mode 100644 passbook/core/migrations/0008_auto_20200824_1532.py delete mode 100644 passbook/core/migrations/0009_group_is_superuser.py delete mode 100644 passbook/core/migrations/0010_auto_20200917_1021.py delete mode 100644 passbook/core/migrations/0011_provider_name_temp.py delete mode 100644 passbook/core/migrations/0012_auto_20201003_1737.py delete mode 100644 passbook/core/migrations/0013_auto_20201003_2132.py delete mode 100644 passbook/core/migrations/0014_auto_20201018_1158.py delete mode 100644 passbook/core/migrations/0015_application_icon.py delete mode 100644 passbook/core/models.py delete mode 100644 passbook/core/signals.py delete mode 100644 passbook/core/tasks.py delete mode 100644 passbook/core/templates/403_csrf.html delete mode 100644 passbook/core/templates/base/page.html delete mode 100644 passbook/core/templates/base/skeleton.html delete mode 100644 passbook/core/templates/error/generic.html delete mode 100644 passbook/core/templates/generic/autosubmit_form.html delete mode 100644 passbook/core/templates/generic/autosubmit_form_full.html delete mode 100644 passbook/core/templates/generic/delete.html delete mode 100644 passbook/core/templates/library.html delete mode 100644 passbook/core/templates/login/base.html delete mode 100644 passbook/core/templates/login/base_full.html delete mode 100644 passbook/core/templates/login/form_with_user.html delete mode 100644 passbook/core/templates/login/loading.html delete mode 100644 passbook/core/templates/partials/form.html delete mode 100644 passbook/core/templates/partials/form_horizontal.html delete mode 100644 passbook/core/templates/partials/pagination.html delete mode 100644 passbook/core/templates/shell.html delete mode 100644 passbook/core/templates/user/settings.html delete mode 100644 passbook/core/templates/user/token_list.html delete mode 100644 passbook/core/templatetags/passbook_user_settings.py delete mode 100644 passbook/core/tests/test_impersonation.py delete mode 100644 passbook/core/tests/test_tasks.py delete mode 100644 passbook/core/tests/test_views_overview.py delete mode 100644 passbook/core/tests/test_views_user.py delete mode 100644 passbook/core/types.py delete mode 100644 passbook/core/urls.py delete mode 100644 passbook/core/views/error.py delete mode 100644 passbook/core/views/impersonate.py delete mode 100644 passbook/core/views/library.py delete mode 100644 passbook/core/views/user.py delete mode 100644 passbook/crypto/api.py delete mode 100644 passbook/crypto/apps.py delete mode 100644 passbook/crypto/builder.py delete mode 100644 passbook/crypto/forms.py delete mode 100644 passbook/crypto/migrations/0002_create_self_signed_kp.py delete mode 100644 passbook/crypto/models.py delete mode 100644 passbook/crypto/tests.py delete mode 100644 passbook/flows/api.py delete mode 100644 passbook/flows/apps.py delete mode 100644 passbook/flows/forms.py delete mode 100644 passbook/flows/management/commands/apply_flow.py delete mode 100644 passbook/flows/management/commands/benchmark.py delete mode 100644 passbook/flows/markers.py delete mode 100644 passbook/flows/migrations/0001_initial.py delete mode 100644 passbook/flows/migrations/0003_auto_20200523_1133.py delete mode 100644 passbook/flows/migrations/0006_auto_20200629_0857.py delete mode 100644 passbook/flows/migrations/0007_auto_20200703_2059.py delete mode 100644 passbook/flows/migrations/0008_default_flows.py delete mode 100644 passbook/flows/migrations/0009_source_flows.py delete mode 100644 passbook/flows/migrations/0010_provider_flows.py delete mode 100644 passbook/flows/migrations/0011_flow_title.py delete mode 100644 passbook/flows/migrations/0012_auto_20200908_1542.py delete mode 100644 passbook/flows/migrations/0013_auto_20200924_1605.py delete mode 100644 passbook/flows/migrations/0014_auto_20200925_2332.py delete mode 100644 passbook/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py delete mode 100644 passbook/flows/migrations/0016_auto_20201202_1307.py delete mode 100644 passbook/flows/models.py delete mode 100644 passbook/flows/planner.py delete mode 100644 passbook/flows/signals.py delete mode 100644 passbook/flows/stage.py delete mode 100644 passbook/flows/templates/flows/denied_shell.html delete mode 100644 passbook/flows/templates/flows/error.html delete mode 100644 passbook/flows/templates/flows/shell.html delete mode 100644 passbook/flows/tests/test_misc.py delete mode 100644 passbook/flows/tests/test_models.py delete mode 100644 passbook/flows/tests/test_planner.py delete mode 100644 passbook/flows/tests/test_transfer.py delete mode 100644 passbook/flows/tests/test_transfer_docs.py delete mode 100644 passbook/flows/tests/test_views.py delete mode 100644 passbook/flows/tests/test_views_helper.py delete mode 100644 passbook/flows/transfer/common.py delete mode 100644 passbook/flows/transfer/exporter.py delete mode 100644 passbook/flows/transfer/importer.py delete mode 100644 passbook/flows/urls.py delete mode 100644 passbook/flows/views.py delete mode 100644 passbook/lib/apps.py delete mode 100644 passbook/lib/config.py delete mode 100644 passbook/lib/default.yml delete mode 100644 passbook/lib/expression/evaluator.py delete mode 100644 passbook/lib/logging.py delete mode 100644 passbook/lib/sentry.py delete mode 100644 passbook/lib/templates/lib/arrayfield.html delete mode 100644 passbook/lib/templatetags/passbook_is_active.py delete mode 100644 passbook/lib/templatetags/passbook_utils.py delete mode 100644 passbook/lib/tests.py delete mode 100644 passbook/lib/utils/reflection.py delete mode 100644 passbook/lib/utils/template.py delete mode 100644 passbook/lib/utils/ui.py delete mode 100644 passbook/lib/views.py delete mode 100644 passbook/outposts/api.py delete mode 100644 passbook/outposts/apps.py delete mode 100644 passbook/outposts/channels.py delete mode 100644 passbook/outposts/controllers/base.py delete mode 100644 passbook/outposts/controllers/docker.py delete mode 100644 passbook/outposts/controllers/k8s/base.py delete mode 100644 passbook/outposts/controllers/k8s/deployment.py delete mode 100644 passbook/outposts/controllers/k8s/secret.py delete mode 100644 passbook/outposts/controllers/k8s/service.py delete mode 100644 passbook/outposts/controllers/kubernetes.py delete mode 100644 passbook/outposts/docker_tls.py delete mode 100644 passbook/outposts/forms.py delete mode 100644 passbook/outposts/migrations/0001_initial.py delete mode 100644 passbook/outposts/migrations/0002_auto_20200826_1306.py delete mode 100644 passbook/outposts/migrations/0003_auto_20200827_2108.py delete mode 100644 passbook/outposts/migrations/0004_auto_20200830_1056.py delete mode 100644 passbook/outposts/migrations/0005_auto_20200909_1733.py delete mode 100644 passbook/outposts/migrations/0006_auto_20201003_2239.py delete mode 100644 passbook/outposts/migrations/0007_remove_outpost_channels.py delete mode 100644 passbook/outposts/migrations/0008_auto_20201014_1547.py delete mode 100644 passbook/outposts/migrations/0009_fix_missing_token_identifier.py delete mode 100644 passbook/outposts/migrations/0010_service_connection.py delete mode 100644 passbook/outposts/migrations/0011_docker_tls_auth.py delete mode 100644 passbook/outposts/migrations/0012_service_connection_non_unique.py delete mode 100644 passbook/outposts/models.py delete mode 100644 passbook/outposts/settings.py delete mode 100644 passbook/outposts/signals.py delete mode 100644 passbook/outposts/tasks.py delete mode 100644 passbook/outposts/templates/outposts/deployment_modal.html delete mode 100644 passbook/outposts/tests.py delete mode 100644 passbook/outposts/urls.py delete mode 100644 passbook/outposts/views.py delete mode 100644 passbook/policies/api.py delete mode 100644 passbook/policies/apps.py delete mode 100644 passbook/policies/dummy/api.py delete mode 100644 passbook/policies/dummy/apps.py delete mode 100644 passbook/policies/dummy/forms.py delete mode 100644 passbook/policies/dummy/migrations/0001_initial.py delete mode 100644 passbook/policies/dummy/models.py delete mode 100644 passbook/policies/dummy/tests.py delete mode 100644 passbook/policies/engine.py delete mode 100644 passbook/policies/exceptions.py delete mode 100644 passbook/policies/expiry/api.py delete mode 100644 passbook/policies/expiry/apps.py delete mode 100644 passbook/policies/expiry/forms.py delete mode 100644 passbook/policies/expiry/migrations/0001_initial.py delete mode 100644 passbook/policies/expiry/models.py delete mode 100644 passbook/policies/expression/api.py delete mode 100644 passbook/policies/expression/apps.py delete mode 100644 passbook/policies/expression/evaluator.py delete mode 100644 passbook/policies/expression/forms.py delete mode 100644 passbook/policies/expression/migrations/0001_initial.py delete mode 100644 passbook/policies/expression/migrations/0002_auto_20200926_1156.py delete mode 100644 passbook/policies/expression/models.py delete mode 100644 passbook/policies/expression/templates/policy/expression/form.html delete mode 100644 passbook/policies/expression/tests.py delete mode 100644 passbook/policies/forms.py delete mode 100644 passbook/policies/group_membership/api.py delete mode 100644 passbook/policies/group_membership/apps.py delete mode 100644 passbook/policies/group_membership/forms.py delete mode 100644 passbook/policies/group_membership/migrations/0001_initial.py delete mode 100644 passbook/policies/group_membership/models.py delete mode 100644 passbook/policies/group_membership/tests.py delete mode 100644 passbook/policies/hibp/api.py delete mode 100644 passbook/policies/hibp/apps.py delete mode 100644 passbook/policies/hibp/forms.py delete mode 100644 passbook/policies/hibp/migrations/0001_initial.py delete mode 100644 passbook/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py delete mode 100644 passbook/policies/hibp/models.py delete mode 100644 passbook/policies/hibp/tests.py delete mode 100644 passbook/policies/http.py delete mode 100644 passbook/policies/migrations/0001_initial.py delete mode 100644 passbook/policies/migrations/0002_auto_20200528_1647.py delete mode 100644 passbook/policies/migrations/0003_auto_20200908_1542.py delete mode 100644 passbook/policies/models.py delete mode 100644 passbook/policies/password/api.py delete mode 100644 passbook/policies/password/apps.py delete mode 100644 passbook/policies/password/forms.py delete mode 100644 passbook/policies/password/migrations/0001_initial.py delete mode 100644 passbook/policies/password/migrations/0002_passwordpolicy_password_field.py delete mode 100644 passbook/policies/password/models.py delete mode 100644 passbook/policies/password/tests.py delete mode 100644 passbook/policies/process.py delete mode 100644 passbook/policies/reputation/api.py delete mode 100644 passbook/policies/reputation/apps.py delete mode 100644 passbook/policies/reputation/forms.py delete mode 100644 passbook/policies/reputation/migrations/0001_initial.py delete mode 100644 passbook/policies/reputation/models.py delete mode 100644 passbook/policies/reputation/settings.py delete mode 100644 passbook/policies/reputation/signals.py delete mode 100644 passbook/policies/reputation/tasks.py delete mode 100644 passbook/policies/reputation/tests.py delete mode 100644 passbook/policies/signals.py delete mode 100644 passbook/policies/templates/policies/denied.html delete mode 100644 passbook/policies/tests/test_engine.py delete mode 100644 passbook/policies/tests/test_models.py delete mode 100644 passbook/policies/types.py delete mode 100644 passbook/policies/views.py delete mode 100644 passbook/providers/oauth2/api.py delete mode 100644 passbook/providers/oauth2/apps.py delete mode 100644 passbook/providers/oauth2/forms.py delete mode 100644 passbook/providers/oauth2/migrations/0001_initial.py delete mode 100644 passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py delete mode 100644 passbook/providers/oauth2/migrations/0003_auto_20200916_2129.py delete mode 100644 passbook/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py delete mode 100644 passbook/providers/oauth2/migrations/0005_auto_20200920_1240.py delete mode 100644 passbook/providers/oauth2/migrations/0006_remove_oauth2provider_name.py delete mode 100644 passbook/providers/oauth2/migrations/0007_auto_20201016_1107.py delete mode 100644 passbook/providers/oauth2/models.py delete mode 100644 passbook/providers/oauth2/templates/providers/oauth2/end_session.html delete mode 100644 passbook/providers/oauth2/templates/providers/oauth2/property_mapping_form.html delete mode 100644 passbook/providers/oauth2/templates/providers/oauth2/setup_url_modal.html delete mode 100644 passbook/providers/oauth2/urls.py delete mode 100644 passbook/providers/oauth2/urls_github.py delete mode 100644 passbook/providers/oauth2/utils.py delete mode 100644 passbook/providers/oauth2/views/authorize.py delete mode 100644 passbook/providers/oauth2/views/github.py delete mode 100644 passbook/providers/oauth2/views/introspection.py delete mode 100644 passbook/providers/oauth2/views/jwks.py delete mode 100644 passbook/providers/oauth2/views/provider.py delete mode 100644 passbook/providers/oauth2/views/session.py delete mode 100644 passbook/providers/oauth2/views/token.py delete mode 100644 passbook/providers/oauth2/views/userinfo.py delete mode 100644 passbook/providers/proxy/api.py delete mode 100644 passbook/providers/proxy/apps.py delete mode 100644 passbook/providers/proxy/controllers/docker.py delete mode 100644 passbook/providers/proxy/controllers/k8s/ingress.py delete mode 100644 passbook/providers/proxy/controllers/kubernetes.py delete mode 100644 passbook/providers/proxy/forms.py delete mode 100644 passbook/providers/proxy/migrations/0001_initial.py delete mode 100644 passbook/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py delete mode 100644 passbook/providers/proxy/migrations/0003_proxyprovider_certificate.py delete mode 100644 passbook/providers/proxy/migrations/0004_auto_20200913_1947.py delete mode 100644 passbook/providers/proxy/migrations/0005_auto_20200914_1536.py delete mode 100644 passbook/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py delete mode 100644 passbook/providers/proxy/migrations/0007_auto_20200923_1017.py delete mode 100644 passbook/providers/proxy/migrations/0008_auto_20200930_0810.py delete mode 100644 passbook/providers/proxy/migrations/0009_auto_20201007_1721.py delete mode 100644 passbook/providers/proxy/models.py delete mode 100644 passbook/providers/saml/api.py delete mode 100644 passbook/providers/saml/apps.py delete mode 100644 passbook/providers/saml/exceptions.py delete mode 100644 passbook/providers/saml/forms.py delete mode 100644 passbook/providers/saml/migrations/0001_initial.py delete mode 100644 passbook/providers/saml/migrations/0002_default_saml_property_mappings.py delete mode 100644 passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py delete mode 100644 passbook/providers/saml/migrations/0004_auto_20200620_1950.py delete mode 100644 passbook/providers/saml/migrations/0005_remove_samlprovider_processor_path.py delete mode 100644 passbook/providers/saml/migrations/0006_remove_samlprovider_name.py delete mode 100644 passbook/providers/saml/migrations/0007_samlprovider_verification_kp.py delete mode 100644 passbook/providers/saml/migrations/0008_auto_20201112_1036.py delete mode 100644 passbook/providers/saml/migrations/0009_auto_20201112_2016.py delete mode 100644 passbook/providers/saml/models.py delete mode 100644 passbook/providers/saml/processors/assertion.py delete mode 100644 passbook/providers/saml/processors/metadata.py delete mode 100644 passbook/providers/saml/processors/request_parser.py delete mode 100644 passbook/providers/saml/settings.py delete mode 100644 passbook/providers/saml/templates/providers/saml/admin_metadata_modal.html delete mode 100644 passbook/providers/saml/templates/providers/saml/property_mapping_form.html delete mode 100644 passbook/providers/saml/tests/test_auth_n_request.py delete mode 100644 passbook/providers/saml/tests/test_utils_time.py delete mode 100644 passbook/providers/saml/urls.py delete mode 100644 passbook/providers/saml/views.py delete mode 100644 passbook/recovery/apps.py delete mode 100644 passbook/recovery/management/commands/create_recovery_key.py delete mode 100644 passbook/recovery/tests.py delete mode 100644 passbook/recovery/urls.py delete mode 100644 passbook/recovery/views.py delete mode 100644 passbook/root/asgi.py delete mode 100644 passbook/root/celery.py delete mode 100644 passbook/root/monitoring.py delete mode 100644 passbook/root/settings.py delete mode 100644 passbook/root/test_runner.py delete mode 100644 passbook/root/urls.py delete mode 100644 passbook/root/websocket.py delete mode 100644 passbook/sources/ldap/api.py delete mode 100644 passbook/sources/ldap/apps.py delete mode 100644 passbook/sources/ldap/auth.py delete mode 100644 passbook/sources/ldap/forms.py delete mode 100644 passbook/sources/ldap/migrations/0001_initial.py delete mode 100644 passbook/sources/ldap/migrations/0002_ldapsource_sync_users.py delete mode 100644 passbook/sources/ldap/migrations/0003_default_ldap_property_mappings.py delete mode 100644 passbook/sources/ldap/migrations/0004_auto_20200524_1146.py delete mode 100644 passbook/sources/ldap/migrations/0005_auto_20200913_1947.py delete mode 100644 passbook/sources/ldap/migrations/0006_auto_20200915_1919.py delete mode 100644 passbook/sources/ldap/migrations/0007_ldapsource_sync_users_password.py delete mode 100644 passbook/sources/ldap/models.py delete mode 100644 passbook/sources/ldap/password.py delete mode 100644 passbook/sources/ldap/settings.py delete mode 100644 passbook/sources/ldap/signals.py delete mode 100644 passbook/sources/ldap/sync.py delete mode 100644 passbook/sources/ldap/tasks.py delete mode 100644 passbook/sources/ldap/templates/ldap/property_mapping_form.html delete mode 100644 passbook/sources/ldap/tests/test_auth.py delete mode 100644 passbook/sources/ldap/tests/test_password.py delete mode 100644 passbook/sources/ldap/tests/test_sync.py delete mode 100644 passbook/sources/oauth/api.py delete mode 100644 passbook/sources/oauth/apps.py delete mode 100644 passbook/sources/oauth/auth.py delete mode 100644 passbook/sources/oauth/clients/base.py delete mode 100644 passbook/sources/oauth/clients/oauth1.py delete mode 100644 passbook/sources/oauth/clients/oauth2.py delete mode 100644 passbook/sources/oauth/exceptions.py delete mode 100644 passbook/sources/oauth/forms.py delete mode 100644 passbook/sources/oauth/migrations/0001_initial.py delete mode 100644 passbook/sources/oauth/migrations/0002_auto_20200520_1108.py delete mode 100644 passbook/sources/oauth/models.py delete mode 100644 passbook/sources/oauth/settings.py delete mode 100644 passbook/sources/oauth/templates/oauth_client/user.html delete mode 100644 passbook/sources/oauth/tests.py delete mode 100644 passbook/sources/oauth/types/azure_ad.py delete mode 100644 passbook/sources/oauth/types/discord.py delete mode 100644 passbook/sources/oauth/types/facebook.py delete mode 100644 passbook/sources/oauth/types/github.py delete mode 100644 passbook/sources/oauth/types/google.py delete mode 100644 passbook/sources/oauth/types/manager.py delete mode 100644 passbook/sources/oauth/types/oidc.py delete mode 100644 passbook/sources/oauth/types/reddit.py delete mode 100644 passbook/sources/oauth/types/twitter.py delete mode 100644 passbook/sources/oauth/urls.py delete mode 100644 passbook/sources/oauth/views/base.py delete mode 100644 passbook/sources/oauth/views/callback.py delete mode 100644 passbook/sources/oauth/views/dispatcher.py delete mode 100644 passbook/sources/oauth/views/flows.py delete mode 100644 passbook/sources/oauth/views/redirect.py delete mode 100644 passbook/sources/oauth/views/user.py delete mode 100644 passbook/sources/saml/api.py delete mode 100644 passbook/sources/saml/apps.py delete mode 100644 passbook/sources/saml/exceptions.py delete mode 100644 passbook/sources/saml/forms.py delete mode 100644 passbook/sources/saml/migrations/0001_initial.py delete mode 100644 passbook/sources/saml/migrations/0002_auto_20200523_2329.py delete mode 100644 passbook/sources/saml/migrations/0003_auto_20200624_1957.py delete mode 100644 passbook/sources/saml/migrations/0004_auto_20200708_1207.py delete mode 100644 passbook/sources/saml/migrations/0005_samlsource_name_id_policy.py delete mode 100644 passbook/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py delete mode 100644 passbook/sources/saml/migrations/0007_auto_20201112_1055.py delete mode 100644 passbook/sources/saml/migrations/0008_auto_20201112_2016.py delete mode 100644 passbook/sources/saml/models.py delete mode 100644 passbook/sources/saml/processors/metadata.py delete mode 100644 passbook/sources/saml/processors/request.py delete mode 100644 passbook/sources/saml/processors/response.py delete mode 100644 passbook/sources/saml/settings.py delete mode 100644 passbook/sources/saml/signals.py delete mode 100644 passbook/sources/saml/tasks.py delete mode 100644 passbook/sources/saml/templates/saml/sp/login.html delete mode 100644 passbook/sources/saml/tests.py delete mode 100644 passbook/sources/saml/urls.py delete mode 100644 passbook/sources/saml/views.py delete mode 100644 passbook/stages/captcha/api.py delete mode 100644 passbook/stages/captcha/apps.py delete mode 100644 passbook/stages/captcha/forms.py delete mode 100644 passbook/stages/captcha/migrations/0001_initial.py delete mode 100644 passbook/stages/captcha/models.py delete mode 100644 passbook/stages/captcha/settings.py delete mode 100644 passbook/stages/captcha/stage.py delete mode 100644 passbook/stages/captcha/tests.py delete mode 100644 passbook/stages/consent/api.py delete mode 100644 passbook/stages/consent/apps.py delete mode 100644 passbook/stages/consent/forms.py delete mode 100644 passbook/stages/consent/migrations/0001_initial.py delete mode 100644 passbook/stages/consent/migrations/0002_auto_20200720_0941.py delete mode 100644 passbook/stages/consent/migrations/0003_auto_20200924_1403.py delete mode 100644 passbook/stages/consent/models.py delete mode 100644 passbook/stages/consent/stage.py delete mode 100644 passbook/stages/consent/tests.py delete mode 100644 passbook/stages/dummy/api.py delete mode 100644 passbook/stages/dummy/apps.py delete mode 100644 passbook/stages/dummy/forms.py delete mode 100644 passbook/stages/dummy/migrations/0001_initial.py delete mode 100644 passbook/stages/dummy/models.py delete mode 100644 passbook/stages/dummy/stage.py delete mode 100644 passbook/stages/dummy/tests.py delete mode 100644 passbook/stages/email/api.py delete mode 100644 passbook/stages/email/apps.py delete mode 100644 passbook/stages/email/forms.py delete mode 100644 passbook/stages/email/migrations/0001_initial.py delete mode 100644 passbook/stages/email/models.py delete mode 100644 passbook/stages/email/stage.py delete mode 100644 passbook/stages/email/tasks.py delete mode 100644 passbook/stages/email/templates/stages/email/for_email/account_confirmation.html delete mode 100644 passbook/stages/email/templates/stages/email/for_email/base.html delete mode 100644 passbook/stages/email/templates/stages/email/for_email/password_reset.html delete mode 100644 passbook/stages/email/templatetags/passbook_stages_email.py delete mode 100644 passbook/stages/email/tests.py delete mode 100644 passbook/stages/identification/api.py delete mode 100644 passbook/stages/identification/apps.py delete mode 100644 passbook/stages/identification/forms.py delete mode 100644 passbook/stages/identification/migrations/0001_initial.py delete mode 100644 passbook/stages/identification/migrations/0002_auto_20200530_2204.py delete mode 100644 passbook/stages/identification/migrations/0003_auto_20200615_1641.py delete mode 100644 passbook/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py delete mode 100644 passbook/stages/identification/migrations/0005_auto_20201003_1734.py delete mode 100644 passbook/stages/identification/models.py delete mode 100644 passbook/stages/identification/stage.py delete mode 100644 passbook/stages/identification/tests.py delete mode 100644 passbook/stages/invitation/api.py delete mode 100644 passbook/stages/invitation/apps.py delete mode 100644 passbook/stages/invitation/forms.py delete mode 100644 passbook/stages/invitation/migrations/0001_initial.py delete mode 100644 passbook/stages/invitation/models.py delete mode 100644 passbook/stages/invitation/signals.py delete mode 100644 passbook/stages/invitation/stage.py delete mode 100644 passbook/stages/invitation/tests.py delete mode 100644 passbook/stages/otp_static/api.py delete mode 100644 passbook/stages/otp_static/apps.py delete mode 100644 passbook/stages/otp_static/forms.py delete mode 100644 passbook/stages/otp_static/migrations/0001_initial.py delete mode 100644 passbook/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py delete mode 100644 passbook/stages/otp_static/migrations/0003_default_setup_flow.py delete mode 100644 passbook/stages/otp_static/models.py delete mode 100644 passbook/stages/otp_static/stage.py delete mode 100644 passbook/stages/otp_static/templates/stages/otp_static/user_settings.html delete mode 100644 passbook/stages/otp_static/urls.py delete mode 100644 passbook/stages/otp_static/views.py delete mode 100644 passbook/stages/otp_time/api.py delete mode 100644 passbook/stages/otp_time/apps.py delete mode 100644 passbook/stages/otp_time/forms.py delete mode 100644 passbook/stages/otp_time/migrations/0001_initial.py delete mode 100644 passbook/stages/otp_time/migrations/0002_auto_20200701_1900.py delete mode 100644 passbook/stages/otp_time/migrations/0003_otptimestage_configure_flow.py delete mode 100644 passbook/stages/otp_time/migrations/0004_default_setup_flow.py delete mode 100644 passbook/stages/otp_time/models.py delete mode 100644 passbook/stages/otp_time/settings.py delete mode 100644 passbook/stages/otp_time/stage.py delete mode 100644 passbook/stages/otp_time/templates/stages/otp_time/user_settings.html delete mode 100644 passbook/stages/otp_time/urls.py delete mode 100644 passbook/stages/otp_time/views.py delete mode 100644 passbook/stages/otp_validate/api.py delete mode 100644 passbook/stages/otp_validate/apps.py delete mode 100644 passbook/stages/otp_validate/forms.py delete mode 100644 passbook/stages/otp_validate/migrations/0001_initial.py delete mode 100644 passbook/stages/otp_validate/models.py delete mode 100644 passbook/stages/otp_validate/stage.py delete mode 100644 passbook/stages/password/api.py delete mode 100644 passbook/stages/password/apps.py delete mode 100644 passbook/stages/password/forms.py delete mode 100644 passbook/stages/password/migrations/0001_initial.py delete mode 100644 passbook/stages/password/migrations/0002_passwordstage_change_flow.py delete mode 100644 passbook/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py delete mode 100644 passbook/stages/password/migrations/0004_auto_20200925_1057.py delete mode 100644 passbook/stages/password/models.py delete mode 100644 passbook/stages/password/stage.py delete mode 100644 passbook/stages/password/templates/stages/password/flow-form.html delete mode 100644 passbook/stages/password/templates/stages/password/user-settings-card.html delete mode 100644 passbook/stages/password/tests.py delete mode 100644 passbook/stages/password/urls.py delete mode 100644 passbook/stages/password/views.py delete mode 100644 passbook/stages/prompt/api.py delete mode 100644 passbook/stages/prompt/apps.py delete mode 100644 passbook/stages/prompt/forms.py delete mode 100644 passbook/stages/prompt/migrations/0001_initial.py delete mode 100644 passbook/stages/prompt/migrations/0002_auto_20200920_1859.py delete mode 100644 passbook/stages/prompt/models.py delete mode 100644 passbook/stages/prompt/signals.py delete mode 100644 passbook/stages/prompt/stage.py delete mode 100644 passbook/stages/prompt/tests.py delete mode 100644 passbook/stages/user_delete/api.py delete mode 100644 passbook/stages/user_delete/apps.py delete mode 100644 passbook/stages/user_delete/forms.py delete mode 100644 passbook/stages/user_delete/migrations/0001_initial.py delete mode 100644 passbook/stages/user_delete/models.py delete mode 100644 passbook/stages/user_delete/stage.py delete mode 100644 passbook/stages/user_delete/tests.py delete mode 100644 passbook/stages/user_login/api.py delete mode 100644 passbook/stages/user_login/apps.py delete mode 100644 passbook/stages/user_login/forms.py delete mode 100644 passbook/stages/user_login/migrations/0001_initial.py delete mode 100644 passbook/stages/user_login/migrations/0002_userloginstage_session_duration.py delete mode 100644 passbook/stages/user_login/migrations/0003_session_duration_delta.py delete mode 100644 passbook/stages/user_login/models.py delete mode 100644 passbook/stages/user_login/stage.py delete mode 100644 passbook/stages/user_login/tests.py delete mode 100644 passbook/stages/user_logout/api.py delete mode 100644 passbook/stages/user_logout/apps.py delete mode 100644 passbook/stages/user_logout/forms.py delete mode 100644 passbook/stages/user_logout/migrations/0001_initial.py delete mode 100644 passbook/stages/user_logout/models.py delete mode 100644 passbook/stages/user_logout/stage.py delete mode 100644 passbook/stages/user_logout/tests.py delete mode 100644 passbook/stages/user_write/api.py delete mode 100644 passbook/stages/user_write/apps.py delete mode 100644 passbook/stages/user_write/forms.py delete mode 100644 passbook/stages/user_write/migrations/0001_initial.py delete mode 100644 passbook/stages/user_write/migrations/0002_auto_20200918_1653.py delete mode 100644 passbook/stages/user_write/models.py delete mode 100644 passbook/stages/user_write/signals.py delete mode 100644 passbook/stages/user_write/stage.py delete mode 100644 passbook/stages/user_write/tests.py rename web/{passbook => authentik}/sources/azure-ad.svg (100%) rename web/{passbook => authentik}/sources/discord.svg (100%) rename web/{passbook => authentik}/sources/dropbox.svg (100%) rename web/{passbook => authentik}/sources/facebook.svg (100%) rename web/{passbook => authentik}/sources/github.svg (100%) rename web/{passbook => authentik}/sources/gitlab.svg (100%) rename web/{passbook => authentik}/sources/google.svg (100%) rename web/{passbook => authentik}/sources/openid-connect.svg (100%) rename web/{passbook => authentik}/sources/twitter.svg (100%) delete mode 100644 web/src/assets/fonts/DINEngschriftStd.woff delete mode 100644 web/src/assets/fonts/DINEngschriftStd.woff2 delete mode 100644 web/src/assets/images/logo.png delete mode 100644 web/src/assets/images/logo.svg rename web/src/assets/images/{user-default.png => user_default.png} (100%) create mode 100644 web/src/authentik.css delete mode 100644 web/src/passbook.css create mode 100644 website/docs/integrations/services/awx-tower/index.md delete mode 100644 website/docs/integrations/services/tower-awx/index.md rename website/docs/integrations/services/vmware-vcenter/{passbook_setup.png => authentik_setup.png} (100%) rename website/docs/integrations/sources/active-directory/{03_pb_status.png => 03_ak_status.png} (100%) rename website/docs/troubleshooting/{passbook_user_debug.png => authentik_user_debug.png} (100%) create mode 100644 website/docs/upgrading/to-0.13.md delete mode 100644 website/docs/upgrading/to-0.13.md_ delete mode 100644 website/static/fonts/DINEngschriftStd.woff delete mode 100644 website/static/fonts/DINEngschriftStd.woff2 delete mode 100644 website/static/img/brand.svg delete mode 100644 website/static/img/brand_inverted.svg create mode 100644 website/static/img/icon.png create mode 100644 website/static/img/icon.svg create mode 100644 website/static/img/icon_left_brand.svg create mode 100644 website/static/img/icon_top_brand.svg delete mode 100644 website/static/img/logo.png delete mode 100644 website/static/img/logo.svg diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2d2f6839..9756a3f6 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -27,7 +27,7 @@ values = [bumpversion:file:.github/workflows/release.yml] -[bumpversion:file:passbook/__init__.py] +[bumpversion:file:authentik/__init__.py] [bumpversion:file:proxy/pkg/version.go] diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 01241f2d..00000000 --- a/.coveragerc +++ /dev/null @@ -1,33 +0,0 @@ -[run] -source = passbook -relative_files = true -omit = - */asgi.py - manage.py - */migrations/* - */apps.py - website/ - -[report] -sort = Cover -skip_covered = True -precision = 2 -exclude_lines = - pragma: no cover - - # Don't complain about missing debug-only code: - def __unicode__ - def __str__ - def __repr__ - if self\.debug - if TYPE_CHECKING - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError - - # Don't complain if non-runnable code isn't run: - if 0: - if __name__ == .__main__.: - -show_missing = True diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 21e7a1a8..79b68b54 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem. Output of docker-compose logs or kubectl logs respectively **Version and Deployment (please complete the following information):** - - passbook version: [e.g. 0.10.0-stable] + - authentik version: [e.g. 0.10.0-stable] - Deployment: [e.g. docker-compose, helm] **Additional context** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cdacf52..cfc172e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: passbook-on-release +name: authentik-on-release on: release: @@ -18,13 +18,13 @@ jobs: - name: Building Docker Image run: docker build --no-cache - -t beryju/passbook:0.12.11-stable - -t beryju/passbook:latest + -t beryju/authentik:0.12.11-stable + -t beryju/authentik:latest -f Dockerfile . - name: Push Docker Container to Registry (versioned) - run: docker push beryju/passbook:0.12.11-stable + run: docker push beryju/authentik:0.12.11-stable - name: Push Docker Container to Registry (latest) - run: docker push beryju/passbook:latest + run: docker push beryju/authentik:latest build-proxy: runs-on: ubuntu-latest steps: @@ -36,7 +36,7 @@ jobs: run: | cd proxy go get -u github.com/go-swagger/go-swagger/cmd/swagger - swagger generate client -f ../swagger.yaml -A passbook -t pkg/ + swagger generate client -f ../swagger.yaml -A authentik -t pkg/ go build -v . - name: Docker Login Registry env: @@ -48,13 +48,13 @@ jobs: cd proxy/ docker build \ --no-cache \ - -t beryju/passbook-proxy:0.12.11-stable \ - -t beryju/passbook-proxy:latest \ + -t beryju/authentik-proxy:0.12.11-stable \ + -t beryju/authentik-proxy:latest \ -f Dockerfile . - name: Push Docker Container to Registry (versioned) - run: docker push beryju/passbook-proxy:0.12.11-stable + run: docker push beryju/authentik-proxy:0.12.11-stable - name: Push Docker Container to Registry (latest) - run: docker push beryju/passbook-proxy:latest + run: docker push beryju/authentik-proxy:latest build-static: runs-on: ubuntu-latest steps: @@ -69,13 +69,13 @@ jobs: cd web/ docker build \ --no-cache \ - -t beryju/passbook-static:0.12.11-stable \ - -t beryju/passbook-static:latest \ + -t beryju/authentik-static:0.12.11-stable \ + -t beryju/authentik-static:latest \ -f Dockerfile . - name: Push Docker Container to Registry (versioned) - run: docker push beryju/passbook-static:0.12.11-stable + run: docker push beryju/authentik-static:0.12.11-stable - name: Push Docker Container to Registry (latest) - run: docker push beryju/passbook-static:latest + run: docker push beryju/authentik-static:latest test-release: needs: - build-server @@ -87,11 +87,11 @@ jobs: run: | sudo apt-get install -y pwgen echo "PG_PASS=$(pwgen 40 1)" >> .env - echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env + echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env docker-compose pull -q docker-compose up --no-start docker-compose start postgresql redis - docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook" + docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik" sentry-release: needs: - test-release @@ -103,7 +103,7 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: beryjuorg - SENTRY_PROJECT: passbook + SENTRY_PROJECT: authentik SENTRY_URL: https://sentry.beryju.org with: tagName: 0.12.11-stable diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 49b31670..58e94afa 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -1,4 +1,4 @@ -name: passbook-on-tag +name: authentik-on-tag on: push: @@ -14,17 +14,17 @@ jobs: - name: Pre-release test run: | sudo apt-get install -y pwgen - echo "PASSBOOK_TAG=latest" >> .env + echo "AUTHENTIK_TAG=latest" >> .env echo "PG_PASS=$(pwgen 40 1)" >> .env - echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env + echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env docker-compose pull -q docker build \ --no-cache \ - -t beryju/passbook:latest \ + -t beryju/authentik:latest \ -f Dockerfile . docker-compose up --no-start docker-compose start postgresql redis - docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook" + docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik" - name: Install Helm run: | apt update && apt install -y curl @@ -33,7 +33,7 @@ jobs: run: | helm dependency update helm/ helm package helm/ - mv passbook-*.tgz passbook-chart.tgz + mv authentik-*.tgz authentik-chart.tgz - name: Extract version number id: get_version uses: actions/github-script@0.2.0 @@ -58,6 +58,6 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./passbook-chart.tgz - asset_name: passbook-chart.tgz + asset_path: ./authentik-chart.tgz + asset_name: authentik-chart.tgz asset_content_type: application/gzip diff --git a/Dockerfile b/Dockerfile index 1ab59048..d2d001c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,18 +30,18 @@ RUN apt-get update && \ # but then we have to drop permmissions later groupadd -g 998 docker_998 && \ groupadd -g 999 docker_999 && \ - adduser --system --no-create-home --uid 1000 --group --home /passbook passbook && \ - usermod -a -G docker_998 passbook && \ - usermod -a -G docker_999 passbook && \ + adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ + usermod -a -G docker_998 authentik && \ + usermod -a -G docker_999 authentik && \ mkdir /backups && \ - chown passbook:passbook /backups + chown authentik:authentik /backups -COPY ./passbook/ /passbook +COPY ./authentik/ /authentik COPY ./pytest.ini / COPY ./manage.py / COPY ./lifecycle/ /lifecycle -USER passbook +USER authentik STOPSIGNAL SIGINT ENV TMPDIR /dev/shm/ ENTRYPOINT [ "/lifecycle/bootstrap.sh" ] diff --git a/Makefile b/Makefile index 3356d25e..0d5abc4a 100644 --- a/Makefile +++ b/Makefile @@ -9,30 +9,30 @@ test-e2e: coverage run manage.py test --failfast -v 3 tests/e2e coverage: - coverage run manage.py test --failfast -v 3 passbook + coverage run manage.py test --failfast -v 3 authentik coverage html coverage report lint-fix: - isort -rc . - black passbook tests lifecycle + isort -rc authentik tests lifecycle + black authentik tests lifecycle lint: - pyright passbook tests lifecycle - bandit -r passbook tests lifecycle -x node_modules - pylint passbook tests lifecycle + pyright authentik tests lifecycle + bandit -r authentik tests lifecycle -x node_modules + pylint authentik tests lifecycle prospector gen: coverage ./manage.py generate_swagger -o swagger.yaml -f yaml local-stack: - export PASSBOOK_TAG=testing - docker build -t beryju/passbook:testng . + export AUTHENTIK_TAG=testing + docker build -t beryju/authentik:testng . docker-compose up -d docker-compose run --rm server migrate build-static: docker-compose -f scripts/ci.docker-compose.yml up -d - docker build -t beryju/passbook-static -f static.Dockerfile --network=scripts_default . + docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default . docker-compose -f scripts/ci.docker-compose.yml down -v diff --git a/README.md b/README.md index 483d0e37..7842cf89 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ -passbook logopassbook +authentik logo -[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/passbook/1?style=flat-square)](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1) -![Tests](https://img.shields.io/azure-devops/tests/beryjuorg/passbook/1?compact_message&style=flat-square) -[![Code Coverage](https://img.shields.io/codecov/c/gh/beryju/passbook?style=flat-square)](https://codecov.io/gh/BeryJu/passbook) -![Docker pulls](https://img.shields.io/docker/pulls/beryju/passbook.svg?style=flat-square) -![Latest version](https://img.shields.io/docker/v/beryju/passbook?sort=semver&style=flat-square) -![LGTM Grade](https://img.shields.io/lgtm/grade/python/github/BeryJu/passbook?style=flat-square) +--- -## What is passbook? +[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/authentik/1?style=flat-square)](https://dev.azure.com/beryjuorg/authentik/_build?definitionId=1) +[![Tests](https://img.shields.io/azure-devops/tests/beryjuorg/authentik/1?compact_message&style=flat-square)](https://dev.azure.com/beryjuorg/authentik/_build?definitionId=1) +[![Code Coverage](https://img.shields.io/codecov/c/gh/beryju/authentik?style=flat-square)](https://codecov.io/gh/BeryJu/authentik) +![Docker pulls](https://img.shields.io/docker/pulls/beryju/authentik.svg?style=flat-square) +![Latest version](https://img.shields.io/docker/v/beryju/authentik?sort=semver&style=flat-square) +![LGTM Grade](https://img.shields.io/lgtm/grade/python/github/BeryJu/authentik?style=flat-square) -passbook is an open-source Identity Provider focused on flexibility and versatility. You can use passbook in an existing environment to add support for new protocols. passbook is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it. +## What is authentik? + +authentik is an open-source Identity Provider focused on flexibility and versatility. You can use authentik in an existing environment to add support for new protocols. authentik is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it. ## Installation -For small/test setups it is recommended to use docker-compose, see the [documentation](https://passbook.beryju.org/docs/installation/docker-compose/) +For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/) -For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://passbook.beryju.org/docs/installation/kubernetes/) +For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://goauthentik.io/docs/installation/kubernetes/) ## Screenshots @@ -24,7 +26,7 @@ For bigger setups, there is a Helm Chart in the `helm/` directory. This is docum ## Development -See [Development Documentation](https://passbook.beryju.org/docs/development/local-dev-environment) +See [Development Documentation](https://goauthentik.io/docs/development/local-dev-environment) ## Security diff --git a/SECURITY.md b/SECURITY.md index 03e71856..b88f63f3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -As passbook is currently in a pre-stable, only the latest "stable" version is supported. After passbook 1.0, this will change. +As authentik is currently in a pre-stable, only the latest "stable" version is supported. After authentik 1.0, this will change. | Version | Supported | | -------- | ------------------ | diff --git a/authentik/__init__.py b/authentik/__init__.py new file mode 100644 index 00000000..3570122f --- /dev/null +++ b/authentik/__init__.py @@ -0,0 +1,2 @@ +"""authentik""" +__version__ = "0.12.11-stable" diff --git a/passbook/admin/__init__.py b/authentik/admin/__init__.py similarity index 100% rename from passbook/admin/__init__.py rename to authentik/admin/__init__.py diff --git a/passbook/admin/api/__init__.py b/authentik/admin/api/__init__.py similarity index 100% rename from passbook/admin/api/__init__.py rename to authentik/admin/api/__init__.py diff --git a/authentik/admin/api/overview.py b/authentik/admin/api/overview.py new file mode 100644 index 00000000..e61068ab --- /dev/null +++ b/authentik/admin/api/overview.py @@ -0,0 +1,79 @@ +"""authentik administration overview""" +from django.core.cache import cache +from drf_yasg2.utils import swagger_auto_schema +from rest_framework.fields import SerializerMethodField +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from rest_framework.viewsets import ViewSet + +from authentik import __version__ +from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version +from authentik.core.models import Provider +from authentik.policies.models import Policy +from authentik.root.celery import CELERY_APP + + +class AdministrationOverviewSerializer(Serializer): + """Overview View""" + + version = SerializerMethodField() + version_latest = SerializerMethodField() + worker_count = SerializerMethodField() + providers_without_application = SerializerMethodField() + policies_without_binding = SerializerMethodField() + cached_policies = SerializerMethodField() + cached_flows = SerializerMethodField() + + def get_version(self, _) -> str: + """Get current version""" + return __version__ + + def get_version_latest(self, _) -> str: + """Get latest version from cache""" + version_in_cache = cache.get(VERSION_CACHE_KEY) + if not version_in_cache: + update_latest_version.delay() + return __version__ + return version_in_cache + + def get_worker_count(self, _) -> int: + """Ping workers""" + return len(CELERY_APP.control.ping(timeout=0.5)) + + def get_providers_without_application(self, _) -> int: + """Count of providers without application""" + return len(Provider.objects.filter(application=None)) + + def get_policies_without_binding(self, _) -> int: + """Count of policies not bound or use in prompt stages""" + return len( + Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True) + ) + + def get_cached_policies(self, _) -> int: + """Get cached policy count""" + return len(cache.keys("policy_*")) + + def get_cached_flows(self, _) -> int: + """Get cached flow count""" + return len(cache.keys("flow_*")) + + def create(self, request: Request) -> Response: + raise NotImplementedError + + def update(self, request: Request) -> Response: + raise NotImplementedError + + +class AdministrationOverviewViewSet(ViewSet): + """Return single instance of AdministrationOverviewSerializer""" + + permission_classes = [IsAdminUser] + + @swagger_auto_schema(responses={200: AdministrationOverviewSerializer(many=True)}) + def list(self, request: Request) -> Response: + """Return single instance of AdministrationOverviewSerializer""" + serializer = AdministrationOverviewSerializer(True) + return Response(serializer.data) diff --git a/authentik/admin/api/overview_metrics.py b/authentik/admin/api/overview_metrics.py new file mode 100644 index 00000000..e1c29e54 --- /dev/null +++ b/authentik/admin/api/overview_metrics.py @@ -0,0 +1,79 @@ +"""authentik administration overview""" +import time +from collections import Counter +from datetime import timedelta +from typing import Dict, List + +from django.db.models import Count, ExpressionWrapper, F +from django.db.models.fields import DurationField +from django.db.models.functions import ExtractHour +from django.http import response +from django.utils.timezone import now +from drf_yasg2.utils import swagger_auto_schema +from rest_framework.fields import SerializerMethodField +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from rest_framework.viewsets import ViewSet + +from authentik.audit.models import Event, EventAction + + +def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]: + """Get event count by hour in the last day, fill with zeros""" + date_from = now() - timedelta(days=1) + result = ( + Event.objects.filter(created__gte=date_from, **filter_kwargs) + .annotate( + age=ExpressionWrapper(now() - F("created"), output_field=DurationField()) + ) + .annotate(age_hours=ExtractHour("age")) + .values("age_hours") + .annotate(count=Count("pk")) + .order_by("age_hours") + ) + data = Counter({d["age_hours"]: d["count"] for d in result}) + results = [] + _now = now() + for hour in range(0, -24, -1): + results.append( + { + "x": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000, + "y": data[hour * -1], + } + ) + return results + + +class AdministrationMetricsSerializer(Serializer): + """Overview View""" + + logins_per_1h = SerializerMethodField() + logins_failed_per_1h = SerializerMethodField() + + def get_logins_per_1h(self, _): + """Get successful logins per hour for the last 24 hours""" + return get_events_per_1h(action=EventAction.LOGIN) + + def get_logins_failed_per_1h(self, _): + """Get failed logins per hour for the last 24 hours""" + return get_events_per_1h(action=EventAction.LOGIN_FAILED) + + def create(self, request: Request) -> response: + raise NotImplementedError + + def update(self, request: Request) -> Response: + raise NotImplementedError + + +class AdministrationMetricsViewSet(ViewSet): + """Return single instance of AdministrationMetricsSerializer""" + + permission_classes = [IsAdminUser] + + @swagger_auto_schema(responses={200: AdministrationMetricsSerializer(many=True)}) + def list(self, request: Request) -> Response: + """Return single instance of AdministrationMetricsSerializer""" + serializer = AdministrationMetricsSerializer(True) + return Response(serializer.data) diff --git a/authentik/admin/api/tasks.py b/authentik/admin/api/tasks.py new file mode 100644 index 00000000..dbaf5d5e --- /dev/null +++ b/authentik/admin/api/tasks.py @@ -0,0 +1,72 @@ +"""Tasks API""" +from importlib import import_module + +from django.contrib import messages +from django.http.response import Http404 +from django.utils.translation import gettext_lazy as _ +from drf_yasg2.utils import swagger_auto_schema +from rest_framework.decorators import action +from rest_framework.fields import CharField, DateTimeField, IntegerField, ListField +from rest_framework.permissions import IsAdminUser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from rest_framework.viewsets import ViewSet + +from authentik.lib.tasks import TaskInfo + + +class TaskSerializer(Serializer): + """Serialize TaskInfo and TaskResult""" + + task_name = CharField() + task_description = CharField() + task_finish_timestamp = DateTimeField(source="finish_timestamp") + + status = IntegerField(source="result.status.value") + messages = ListField(source="result.messages") + + def create(self, request: Request) -> Response: + raise NotImplementedError + + def update(self, request: Request) -> Response: + raise NotImplementedError + + +class TaskViewSet(ViewSet): + """Read-only view set that returns all background tasks""" + + permission_classes = [IsAdminUser] + + @swagger_auto_schema(responses={200: TaskSerializer(many=True)}) + def list(self, request: Request) -> Response: + """List current messages and pass into Serializer""" + return Response(TaskSerializer(TaskInfo.all().values(), many=True).data) + + @action(detail=True, methods=["post"]) + # pylint: disable=invalid-name + def retry(self, request: Request, pk=None) -> Response: + """Retry task""" + task = TaskInfo.by_name(pk) + if not task: + raise Http404 + try: + task_module = import_module(task.task_call_module) + task_func = getattr(task_module, task.task_call_func) + task_func.delay(*task.task_call_args, **task.task_call_kwargs) + messages.success( + self.request, + _( + "Successfully re-scheduled Task %(name)s!" + % {"name": task.task_name} + ), + ) + return Response( + { + "successful": True, + } + ) + except ImportError: + # if we get an import error, the module path has probably changed + task.delete() + return Response({"successful": False}) diff --git a/authentik/admin/apps.py b/authentik/admin/apps.py new file mode 100644 index 00000000..db05f4da --- /dev/null +++ b/authentik/admin/apps.py @@ -0,0 +1,11 @@ +"""authentik admin app config""" +from django.apps import AppConfig + + +class AuthentikAdminConfig(AppConfig): + """authentik admin app config""" + + name = "authentik.admin" + label = "authentik_admin" + mountpoint = "administration/" + verbose_name = "authentik Admin" diff --git a/passbook/admin/fields.py b/authentik/admin/fields.py similarity index 100% rename from passbook/admin/fields.py rename to authentik/admin/fields.py diff --git a/passbook/admin/forms/__init__.py b/authentik/admin/forms/__init__.py similarity index 100% rename from passbook/admin/forms/__init__.py rename to authentik/admin/forms/__init__.py diff --git a/passbook/admin/forms/overview.py b/authentik/admin/forms/overview.py similarity index 100% rename from passbook/admin/forms/overview.py rename to authentik/admin/forms/overview.py diff --git a/authentik/admin/forms/policies.py b/authentik/admin/forms/policies.py new file mode 100644 index 00000000..17112b58 --- /dev/null +++ b/authentik/admin/forms/policies.py @@ -0,0 +1,12 @@ +"""authentik administration forms""" +from django import forms + +from authentik.admin.fields import CodeMirrorWidget, YAMLField +from authentik.core.models import User + + +class PolicyTestForm(forms.Form): + """Form to test policies against user""" + + user = forms.ModelChoiceField(queryset=User.objects.all()) + context = YAMLField(widget=CodeMirrorWidget(), required=False, initial=dict) diff --git a/authentik/admin/forms/source.py b/authentik/admin/forms/source.py new file mode 100644 index 00000000..5e5d0439 --- /dev/null +++ b/authentik/admin/forms/source.py @@ -0,0 +1,17 @@ +"""authentik core source form fields""" + +SOURCE_FORM_FIELDS = [ + "name", + "slug", + "enabled", + "authentication_flow", + "enrollment_flow", +] +SOURCE_SERIALIZER_FIELDS = [ + "pk", + "name", + "slug", + "enabled", + "authentication_flow", + "enrollment_flow", +] diff --git a/authentik/admin/forms/users.py b/authentik/admin/forms/users.py new file mode 100644 index 00000000..b7c3cc8d --- /dev/null +++ b/authentik/admin/forms/users.py @@ -0,0 +1,22 @@ +"""authentik administrative user forms""" + +from django import forms + +from authentik.admin.fields import CodeMirrorWidget, YAMLField +from authentik.core.models import User + + +class UserForm(forms.ModelForm): + """Update User Details""" + + class Meta: + + model = User + fields = ["username", "name", "email", "is_active", "attributes"] + widgets = { + "name": forms.TextInput, + "attributes": CodeMirrorWidget, + } + field_classes = { + "attributes": YAMLField, + } diff --git a/authentik/admin/mixins.py b/authentik/admin/mixins.py new file mode 100644 index 00000000..97ee3c53 --- /dev/null +++ b/authentik/admin/mixins.py @@ -0,0 +1,9 @@ +"""authentik admin mixins""" +from django.contrib.auth.mixins import UserPassesTestMixin + + +class AdminRequiredMixin(UserPassesTestMixin): + """Make sure user is administrator""" + + def test_func(self): + return self.request.user.is_superuser diff --git a/authentik/admin/settings.py b/authentik/admin/settings.py new file mode 100644 index 00000000..57e0de6e --- /dev/null +++ b/authentik/admin/settings.py @@ -0,0 +1,10 @@ +"""authentik admin settings""" +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + "admin_latest_version": { + "task": "authentik.admin.tasks.update_latest_version", + "schedule": crontab(minute=0), # Run every hour + "options": {"queue": "authentik_scheduled"}, + } +} diff --git a/authentik/admin/tasks.py b/authentik/admin/tasks.py new file mode 100644 index 00000000..2bff1ecb --- /dev/null +++ b/authentik/admin/tasks.py @@ -0,0 +1,30 @@ +"""authentik admin tasks""" +from django.core.cache import cache +from requests import RequestException, get +from structlog import get_logger + +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.root.celery import CELERY_APP + +LOGGER = get_logger() +VERSION_CACHE_KEY = "authentik_latest_version" +VERSION_CACHE_TIMEOUT = 2 * 60 * 60 # 2 hours + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def update_latest_version(self: MonitoredTask): + """Update latest version info""" + try: + response = get("https://api.github.com/repos/beryju/authentik/releases/latest") + response.raise_for_status() + data = response.json() + tag_name = data.get("tag_name") + cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT) + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] + ) + ) + except (RequestException, IndexError) as exc: + cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) + self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) diff --git a/authentik/admin/templates/administration/application/list.html b/authentik/admin/templates/administration/application/list.html new file mode 100644 index 00000000..843fa6c1 --- /dev/null +++ b/authentik/admin/templates/administration/application/list.html @@ -0,0 +1,121 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Applications' %} +

+

{% trans "External Applications which use authentik as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for application in object_list %} + + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Slug' %}{% trans 'Provider' %}{% trans 'Provider Type' %}
+ +
{{ application.name }}
+ {% if application.meta_publisher %} + {{ application.meta_publisher }} + {% endif %} +
+
+ {{ application.slug }} + + + {{ application.get_provider }} + + + + {{ application.get_provider|verbose_name }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Applications.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any application." %} + {% else %} + {% trans 'Currently no applications exist. Click the button below to create one.' %} + {% endif %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/passbook/admin/templates/administration/base.html b/authentik/admin/templates/administration/base.html similarity index 100% rename from passbook/admin/templates/administration/base.html rename to authentik/admin/templates/administration/base.html diff --git a/authentik/admin/templates/administration/certificatekeypair/list.html b/authentik/admin/templates/administration/certificatekeypair/list.html new file mode 100644 index 00000000..8acfcb01 --- /dev/null +++ b/authentik/admin/templates/administration/certificatekeypair/list.html @@ -0,0 +1,116 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Certificate-Key Pairs' %} +

+

{% trans "Import certificates of external providers or create certificates to sign requests with." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + {% for kp in object_list %} + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Private Key available' %}{% trans 'Fingerprint' %}
+
+
{{ kp.name }}
+
+
+ + {% if kp.key_data is not None %} + {% trans 'Yes' %} + {% else %} + {% trans 'No' %} + {% endif %} + + + {{ kp.fingerprint }} + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Certificates.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any certificates." %} + {% else %} + {% trans 'Currently no certificates exist. Click the button below to create one.' %} + {% endif %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/passbook/admin/templates/administration/flow/import.html b/authentik/admin/templates/administration/flow/import.html similarity index 100% rename from passbook/admin/templates/administration/flow/import.html rename to authentik/admin/templates/administration/flow/import.html diff --git a/authentik/admin/templates/administration/flow/list.html b/authentik/admin/templates/administration/flow/list.html new file mode 100644 index 00000000..f1a27a0d --- /dev/null +++ b/authentik/admin/templates/administration/flow/list.html @@ -0,0 +1,135 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Flows' %} +

+

{% trans "Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ + + {% trans 'Import' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for flow in object_list %} + + + + + + + + {% endfor %} + +
{% trans 'Identifier' %}{% trans 'Designation' %}{% trans 'Stages' %}{% trans 'Policies' %}
+
+
{{ flow.slug }}
+ {{ flow.name }} +
+
+ + {{ flow.designation }} + + + + {{ flow.stages.all|length }} + + + + {{ flow.policies.all|length }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+ {% trans 'Execute' %} + {% trans 'Export' %} +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Flows.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any flows." %} + {% else %} + {% trans 'Currently no flows exist. Click the button below to create one.' %} + {% endif %} +
+ + + {% trans 'Create' %} + +
+
+ + + {% trans 'Import' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/group/list.html b/authentik/admin/templates/administration/group/list.html new file mode 100644 index 00000000..3d1b8eb0 --- /dev/null +++ b/authentik/admin/templates/administration/group/list.html @@ -0,0 +1,114 @@ +{% extends "administration/base.html" %} + +{% load i18n %} + +{% block content %} +
+
+

+ + {% trans 'Groups' %} +

+

{% trans "Group users together and give them permissions based on the membership." %} +

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + {% for group in object_list %} + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Parent' %}{% trans 'Members' %}
+ + {{ group.name }} + + + + {{ group.parent }} + + + + {{ group.users.all|length }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Groups.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any groups." %} + {% else %} + {% trans 'Currently no group exist. Click the button below to create one.' %} + {% endif %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/outpost/list.html b/authentik/admin/templates/administration/outpost/list.html new file mode 100644 index 00000000..2af84939 --- /dev/null +++ b/authentik/admin/templates/administration/outpost/list.html @@ -0,0 +1,149 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load humanize %} +{% load authentik_utils %} +{% load admin_reflection %} + +{% block content %} +
+
+

+ + {% trans 'Outposts' %} +

+

{% trans "Outposts are deployments of authentik components to support different environments and protocols, like reverse proxies." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for outpost in object_list %} + + + + {% with states=outpost.state %} + {% if states|length > 0 %} + + + {% else %} + + + {% endif %} + {% endwith %} + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Providers' %}{% trans 'Health' %}{% trans 'Version' %}
+ {{ outpost.name }} + + + {{ outpost.providers.all.select_subclasses|join:", " }} + + + {% for state in states %} +
+ {% if state.last_seen %} + {{ state.last_seen|naturaltime }} + {% else %} + {% trans 'Unhealthy' %} + {% endif %} +
+ {% endfor %} +
+ {% for state in states %} +
+ {% if not state.version %} + + {% elif state.version_outdated %} + {% blocktrans with is=state.version should=state.version_should %}{{ is }}, should be {{ should }}{% endblocktrans %} + {% else %} + {{ state.version }} + {% endif %} +
+ {% endfor %} +
+ + + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+ {% get_htmls outpost as htmls %} + {% for html in htmls %} + {{ html|safe }} + {% endfor %} +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Outposts.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any outposts." %} + {% else %} + {% trans 'Currently no outposts exist. Click the button below to create one.' %} + {% endif %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/outpost_service_connection/list.html b/authentik/admin/templates/administration/outpost_service_connection/list.html new file mode 100644 index 00000000..79cee25d --- /dev/null +++ b/authentik/admin/templates/administration/outpost_service_connection/list.html @@ -0,0 +1,154 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load humanize %} +{% load authentik_utils %} +{% load admin_reflection %} + +{% block content %} +
+
+

+ + {% trans 'Outpost Service-Connections' %} +

+

{% trans "Outpost Service-Connections define how authentik connects to external platforms to manage and deploy Outposts." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + + + +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for sc in object_list %} + + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Type' %}{% trans 'Local?' %}{% trans 'Status' %}
+ {{ sc.name }} + + + {{ sc|verbose_name }} + + + + {{ sc.local|yesno:"Yes,No" }} + + + + {% if sc.state.healthy %} + {{ sc.state.version }} + {% else %} + {% trans 'Unhealthy' %} + {% endif %} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Outpost Service Connections.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any outposts." %} + {% else %} + {% trans 'Currently no service connections exist. Click the button below to create one.' %} + {% endif %} +
+ + + + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/overview.html b/authentik/admin/templates/administration/overview.html new file mode 100644 index 00000000..ee38a957 --- /dev/null +++ b/authentik/admin/templates/administration/overview.html @@ -0,0 +1,230 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load static %} + +{% block content %} +
+
+

{% trans 'System Overview' %}

+
+
+
+ +
+{% endblock %} diff --git a/authentik/admin/templates/administration/policy/list.html b/authentik/admin/templates/administration/policy/list.html new file mode 100644 index 00000000..618013c5 --- /dev/null +++ b/authentik/admin/templates/administration/policy/list.html @@ -0,0 +1,148 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Policies' %} +

+

{% trans "Allow users to use Applications based on properties, enforce Password Criteria and selectively apply Stages." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + + +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + {% for policy in object_list %} + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Type' %}
+
+
{{ policy.name }}
+ {% if not policy.bindings.exists and not policy.promptstage_set.exists %} + + {% trans 'Warning: Policy is not assigned.' %} + {% else %} + + {% blocktrans with object_count=policy.bindings.all|length %}Assigned to {{ object_count }} objects.{% endblocktrans %} + {% endif %} +
+
+ + {{ policy|verbose_name }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Test' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Policies.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any policies." %} + {% else %} + {% trans 'Currently no policies exist. Click the button below to create one.' %} + {% endif %} +
+ + + + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/passbook/admin/templates/administration/policy/test.html b/authentik/admin/templates/administration/policy/test.html similarity index 100% rename from passbook/admin/templates/administration/policy/test.html rename to authentik/admin/templates/administration/policy/test.html diff --git a/authentik/admin/templates/administration/policy_binding/list.html b/authentik/admin/templates/administration/policy_binding/list.html new file mode 100644 index 00000000..ee581d6c --- /dev/null +++ b/authentik/admin/templates/administration/policy_binding/list.html @@ -0,0 +1,119 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Policy Bindings' %} +

+

{% trans "Bind existing Policies to Models accepting policies." %}

+
+
+
+
+ {% if object_list %} +
+
+
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for pbm in object_list %} + + + + + + + + {% for binding in pbm.bindings %} + + + + + + + + {% endfor %} + {% endfor %} + +
{% trans 'Policy' %}{% trans 'Enabled' %}{% trans 'Order' %}{% trans 'Timeout' %}
+ {{ pbm }} + + {{ pbm|fieldtype }} + +
+
{{ binding.policy }}
+ + {{ binding.policy|fieldtype }} + +
+
{{ binding.enabled }}
+
+
{{ binding.order }}
+
+
{{ binding.timeout }}
+
+ + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ +

+ {% trans 'No Policy Bindings.' %} +

+
+ {% trans 'Currently no policy bindings exist. Click the button below to create one.' %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/property_mapping/list.html b/authentik/admin/templates/administration/property_mapping/list.html new file mode 100644 index 00000000..6b1f72ec --- /dev/null +++ b/authentik/admin/templates/administration/property_mapping/list.html @@ -0,0 +1,139 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Property Mappings' %} +

+

{% trans "Control how authentik exposes and interprets information." %} +

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + + + +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + {% for property_mapping in object_list %} + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Type' %}
+ + {{ property_mapping.name }} + + + + {{ property_mapping|verbose_name }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Property Mappings.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any property mappings." %} + {% else %} + {% trans 'Currently no property mappings exist. Click the button below to create one.' %} + {% endif %} +
+ + + + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/provider/list.html b/authentik/admin/templates/administration/provider/list.html new file mode 100644 index 00000000..572dc7ca --- /dev/null +++ b/authentik/admin/templates/administration/provider/list.html @@ -0,0 +1,159 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} +{% load admin_reflection %} + +{% block content %} +
+
+

+ + {% trans 'Providers' %} +

+

{% trans "Provide support for protocols like SAML and OAuth to assigned applications." %} +

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + + + +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + {% for provider in object_list %} + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Type' %}
+
+
{{ provider.name }}
+ {% if not provider.application %} + + {% trans 'Warning: Provider not assigned to any application.' %} + {% else %} + + + {% blocktrans with app=provider.application %} + Assigned to application {{ app }}. + {% endblocktrans %} + + {% endif %} +
+
+ + {{ provider|verbose_name }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+ {% get_links provider as links %} + {% for name, href in links.items %} + {% trans name %} + {% endfor %} + {% get_htmls provider as htmls %} + {% for html in htmls %} + {{ html|safe }} + {% endfor %} +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Providers.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any providers." %} + {% else %} + {% trans 'Currently no providers exist. Click the button below to create one.' %} + {% endif %} +
+ + + + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/source/list.html b/authentik/admin/templates/administration/source/list.html new file mode 100644 index 00000000..a4a01442 --- /dev/null +++ b/authentik/admin/templates/administration/source/list.html @@ -0,0 +1,153 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} +{% load admin_reflection %} + +{% block content %} +
+
+

+ + {% trans 'Source' %} +

+

{% trans "External Sources which can be used to get Identities into authentik, for example Social Providers like Twiter and GitHub or Enterprise Providers like ADFS and LDAP." %} +

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + + + +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + {% for source in object_list %} + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Type' %}{% trans 'Additional Info' %}
+
+
{{ source.name }}
+ {% if not source.enabled %} + {% trans 'Disabled' %} + {% endif %} +
+
+ + {{ source|fieldtype }} + + + + {{ source.ui_additional_info|default:""|safe }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+ {% get_links source as links %} + {% for name, href in links %} + {% trans name %} + {% endfor %} +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Sources.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any sources." %} + {% else %} + {% trans 'Currently no sources exist. Click the button below to create one.' %} + {% endif %} +
+ + + + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/stage/list.html b/authentik/admin/templates/administration/stage/list.html new file mode 100644 index 00000000..4fe3d48f --- /dev/null +++ b/authentik/admin/templates/administration/stage/list.html @@ -0,0 +1,148 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} +{% load admin_reflection %} + +{% block content %} +
+
+

+ + {% trans 'Stages' %} +

+

{% trans "Stages are single steps of a Flow that a user is guided through." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + + + +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + {% for stage in object_list %} + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Flows' %}
+
+
{{ stage.name }}
+ {{ stage|verbose_name }} +
+
+
    + {% for flow in stage.flow_set.all %} +
  • {{ flow.slug }}<
  • + {% empty %} +
  • -
  • + {% endfor %} +
+
+ + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+ {% get_links stage as links %} + {% for name, href in links.items %} + {% trans name %} + {% endfor %} +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Stages.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any stages." %} + {% else %} + {% trans 'Currently no stages exist. Click the button below to create one.' %} + {% endif %} +
+ + + + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/stage_binding/list.html b/authentik/admin/templates/administration/stage_binding/list.html new file mode 100644 index 00000000..c4a772a6 --- /dev/null +++ b/authentik/admin/templates/administration/stage_binding/list.html @@ -0,0 +1,125 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Stage Bindings' %} +

+

{% trans "Bind existing Stages to Flows." %}

+
+
+
+
+ {% if object_list %} +
+
+
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + {% regroup object_list by target as grouped_bindings %} + {% for flow in grouped_bindings %} + + + + + + + {% for binding in flow.list %} + + + + + + + {% endfor %} + {% endfor %} + +
{% trans 'Order' %}{% trans 'Name' %}{% trans 'Stage Type' %}
+ {% blocktrans with slug=flow.grouper.slug %} + Flow {{ slug }} + {% endblocktrans %} +
+ + {{ binding.order }} + + +
+
{{ binding.target.slug }}
+ + {{ binding.target.name }} + +
+
+
+
+ {{ binding.stage.name }} +
+ + {{ binding.stage }} + +
+
+ + + {% trans 'Update' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ +

+ {% trans 'No Flow-Stage Bindings.' %} +

+
+ {% trans 'Currently no flow-stage bindings exist. Click the button below to create one.' %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/stage_invitation/list.html b/authentik/admin/templates/administration/stage_invitation/list.html new file mode 100644 index 00000000..109c755d --- /dev/null +++ b/authentik/admin/templates/administration/stage_invitation/list.html @@ -0,0 +1,103 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Invitations' %} +

+

{% trans "Create Invitation Links to enroll Users, and optionally force specific attributes of their account." %} +

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + {% for invitation in object_list %} + + + + + + {% endfor %} + +
{% trans 'Expiry' %}{% trans 'Link' %}
+ + {{ invitation.expiry }} + + + + {{ invitation.Link }} + + + + + {% trans 'Delete' %} + +
+
+
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Invitations.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any invitations." %} + {% else %} + {% trans 'Currently no invitations exist. Click the button below to create one.' %} + {% endif %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/stage_prompt/list.html b/authentik/admin/templates/administration/stage_prompt/list.html new file mode 100644 index 00000000..45ca2e5b --- /dev/null +++ b/authentik/admin/templates/administration/stage_prompt/list.html @@ -0,0 +1,130 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} +{% load admin_reflection %} + +{% block content %} +
+
+

+ + {% trans 'Prompts' %} +

+

{% trans "Single Prompts that can be used for Prompt Stages." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + + {% for prompt in object_list %} + + + + + + + + + {% endfor %} + +
{% trans 'Field' %}{% trans 'Label' %}{% trans 'Type' %}{% trans 'Order' %}{% trans 'Flows' %}
+
+
{{ prompt.field_key }}
+
+
+
+ {{ prompt.label }} +
+
+
+ {{ prompt.type }} +
+
+
+ {{ prompt.order }} +
+
+
    + {% for flow in prompt.flow_set.all %} +
  • {{ flow.slug }}
  • + {% empty %} +
  • -
  • + {% endfor %} +
+
+ + + {% trans 'Update' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+ {% get_links prompt as links %} + {% for name, href in links.items %} + {% trans name %} + {% endfor %} +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Stage Prompts.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any stage prompts." %} + {% else %} + {% trans 'Currently no stage prompts exist. Click the button below to create one.' %} + {% endif %} +
+ {% trans 'Create' %} +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/task/list.html b/authentik/admin/templates/administration/task/list.html new file mode 100644 index 00000000..7f0ef9ea --- /dev/null +++ b/authentik/admin/templates/administration/task/list.html @@ -0,0 +1,84 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load humanize %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'System Tasks' %} +

+

{% trans "Long-running operations which authentik executes in the background." %}

+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + {% for task in object_list %} + + + + + + + + + {% endfor %} + +
{% trans 'Identifier' %}{% trans 'Description' %}{% trans 'Last Run' %}{% trans 'Status' %}{% trans 'Messages' %}
+
{{ task.task_name }}
+
+ + {{ task.task_description }} + + + + {{ task.finish_timestamp|naturaltime }} + + + + {% if task.result.status == task_successful %} + {% trans 'Successful' %} + {% elif task.result.status == task_warning %} + {% trans 'Warning' %} + {% elif task.result.status == task_error %} + {% trans 'Error' %} + {% else %} + {% trans 'Unknown' %} + {% endif %} + + + {% for message in task.result.messages %} +
+ {{ message }} +
+ {% endfor %} +
+ + {% trans 'Retry Task' %} + +
+
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/token/list.html b/authentik/admin/templates/administration/token/list.html new file mode 100644 index 00000000..9eb7667a --- /dev/null +++ b/authentik/admin/templates/administration/token/list.html @@ -0,0 +1,102 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Tokens' %} +

+

{% trans "Tokens are used throughout authentik for Email validation stages, Recovery keys and API access." %}

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} + {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for token in object_list %} + + + + + + + + {% endfor %} + +
{% trans 'Identifier' %}{% trans 'User' %}{% trans 'Expires?' %}{% trans 'Expiry Date' %}
+
{{ token.identifier }}
+
+ + {{ token.user }} + + + + {{ token.expiring|yesno:"Yes,No" }} + + + + {% if not token.expiring %} + - + {% else %} + {{ token.expires }} + {% endif %} + + + + + {% trans 'Delete' %} + +
+
+ + {% trans 'Copy token' %} + +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Tokens.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any token." %} + {% else %} + {% trans 'Currently no tokens exist.' %} + {% endif %} +
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/user/disable.html b/authentik/admin/templates/administration/user/disable.html new file mode 100644 index 00000000..3069d880 --- /dev/null +++ b/authentik/admin/templates/administration/user/disable.html @@ -0,0 +1,42 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+ {% block above_form %} +

+ {% blocktrans with object_type=object|verbose_name %} + Disable {{ object_type }} + {% endblocktrans %} +

+ {% endblock %} +
+
+
+
+
+
+
+
+ {% csrf_token %} +

+ {% blocktrans with object_type=object|verbose_name name=object %} + Are you sure you want to disable {{ object_type }} "{{ object }}"? + {% endblocktrans %} +

+
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/authentik/admin/templates/administration/user/list.html b/authentik/admin/templates/administration/user/list.html new file mode 100644 index 00000000..fe291cb5 --- /dev/null +++ b/authentik/admin/templates/administration/user/list.html @@ -0,0 +1,125 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+

+ + {% trans 'Users' %} +

+
+
+
+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+ +
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + {% for user in object_list %} + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Active' %}{% trans 'Last Login' %}
+
+
{{ user.username }}
+ {{ user.name }} +
+
+ + {{ user.is_active }} + + + + {{ user.last_login }} + + + + + {% trans 'Edit' %} + +
+
+ {% if user.is_active %} + + + {% trans 'Disable' %} + +
+
+ {% else %} + + + {% trans 'Enable' %} + +
+
+ {% endif %} + {% trans 'Reset Password' %} + {% trans 'Impersonate' %} +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+
+
+
+ +

+ {% trans 'No Users.' %} +

+
+ {% if request.GET.search != "" %} + {% trans "Your search query doesn't match any users." %} + {% else %} + {% trans 'Currently no users exist. How did you even get here.' %} + {% endif %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/authentik/admin/templates/fields/codemirror.html b/authentik/admin/templates/fields/codemirror.html new file mode 100644 index 00000000..19040059 --- /dev/null +++ b/authentik/admin/templates/fields/codemirror.html @@ -0,0 +1 @@ + diff --git a/authentik/admin/templates/generic/create.html b/authentik/admin/templates/generic/create.html new file mode 100644 index 00000000..c1cbe91f --- /dev/null +++ b/authentik/admin/templates/generic/create.html @@ -0,0 +1,18 @@ +{% extends base_template|default:"generic/form.html" %} + +{% load authentik_utils %} +{% load i18n %} + +{% block above_form %} +

+ {% blocktrans with type=form|form_verbose_name %} + Create {{ type }} + {% endblocktrans %} +

+{% endblock %} + +{% block action %} +{% blocktrans with type=form|form_verbose_name %} +Create {{ type }} +{% endblocktrans %} +{% endblock %} diff --git a/authentik/admin/templates/generic/form.html b/authentik/admin/templates/generic/form.html new file mode 100644 index 00000000..d7208c69 --- /dev/null +++ b/authentik/admin/templates/generic/form.html @@ -0,0 +1,38 @@ +{% extends container_template|default:"administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} +{% load static %} + +{% block content %} +
+
+ {% block above_form %} + {% endblock %} +
+
+
+
+
+
+
+
+ {% include 'partials/form_horizontal.html' with form=form %} + {% block beneath_form %} + {% endblock %} +
+
+
+
+
+
+ +{% endblock %} + +{% block scripts %} +{{ block.super }} +{{ form.media.js }} +{% endblock %} diff --git a/authentik/admin/templates/generic/form_non_model.html b/authentik/admin/templates/generic/form_non_model.html new file mode 100644 index 00000000..6223e33c --- /dev/null +++ b/authentik/admin/templates/generic/form_non_model.html @@ -0,0 +1,20 @@ +{% extends base_template|default:"generic/form.html" %} + +{% load authentik_utils %} +{% load i18n %} + +{% block above_form %} +

+ {% trans form.title %} +

+{% endblock %} + +{% block beneath_form %} +

+ {% trans form.body %} +

+{% endblock %} + +{% block action %} +{% trans 'Confirm' %} +{% endblock %} diff --git a/authentik/admin/templates/generic/update.html b/authentik/admin/templates/generic/update.html new file mode 100644 index 00000000..7b46d40f --- /dev/null +++ b/authentik/admin/templates/generic/update.html @@ -0,0 +1,18 @@ +{% extends base_template|default:"generic/form.html" %} + +{% load authentik_utils %} +{% load i18n %} + +{% block above_form %} +

+ {% blocktrans with type=form|form_verbose_name|title inst=form.instance %} + Update {{ inst }} + {% endblocktrans %} +

+{% endblock %} + +{% block action %} +{% blocktrans with type=form|form_verbose_name %} +Update {{ type }} +{% endblocktrans %} +{% endblock %} diff --git a/passbook/admin/templatetags/__init__.py b/authentik/admin/templatetags/__init__.py similarity index 100% rename from passbook/admin/templatetags/__init__.py rename to authentik/admin/templatetags/__init__.py diff --git a/authentik/admin/templatetags/admin_reflection.py b/authentik/admin/templatetags/admin_reflection.py new file mode 100644 index 00000000..33c334dc --- /dev/null +++ b/authentik/admin/templatetags/admin_reflection.py @@ -0,0 +1,62 @@ +"""authentik admin templatetags""" +from django import template +from django.db.models import Model +from django.utils.html import mark_safe +from structlog import get_logger + +register = template.Library() +LOGGER = get_logger() + + +@register.simple_tag() +def get_links(model_instance): + """Find all link_ methods on an object instance, run them and return as dict""" + prefix = "link_" + links = {} + + if not isinstance(model_instance, Model): + LOGGER.warning("Model is not instance of Model", model_instance=model_instance) + return links + + try: + for name in dir(model_instance): + if not name.startswith(prefix): + continue + value = getattr(model_instance, name) + if not callable(value): + continue + human_name = name.replace(prefix, "").replace("_", " ").capitalize() + link = value() + if link: + links[human_name] = link + except NotImplementedError: + pass + + return links + + +@register.simple_tag(takes_context=True) +def get_htmls(context, model_instance): + """Find all html_ methods on an object instance, run them and return as dict""" + prefix = "html_" + htmls = [] + + if not isinstance(model_instance, Model): + LOGGER.warning("Model is not instance of Model", model_instance=model_instance) + return htmls + + try: + for name in dir(model_instance): + if not name.startswith(prefix): + continue + value = getattr(model_instance, name) + if not callable(value): + continue + if name.startswith(prefix): + html = value(context.get("request")) + if html: + htmls.append(mark_safe(html)) + except NotImplementedError: + pass + + return htmls diff --git a/authentik/admin/tests.py b/authentik/admin/tests.py new file mode 100644 index 00000000..5f9399a0 --- /dev/null +++ b/authentik/admin/tests.py @@ -0,0 +1,66 @@ +"""admin tests""" +from importlib import import_module +from typing import Callable + +from django.forms import ModelForm +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.urls.exceptions import NoReverseMatch + +from authentik.admin.urls import urlpatterns +from authentik.core.models import Group, User +from authentik.lib.utils.reflection import get_apps + + +class TestAdmin(TestCase): + """Generic admin tests""" + + def setUp(self): + self.user = User.objects.create_user(username="test") + self.user.ak_groups.add(Group.objects.filter(is_superuser=True).first()) + self.user.save() + self.client = Client() + self.client.force_login(self.user) + + +def generic_view_tester(view_name: str) -> Callable: + """This is used instead of subTest for better visibility""" + + def tester(self: TestAdmin): + try: + full_url = reverse(f"authentik_admin:{view_name}") + response = self.client.get(full_url) + self.assertTrue(response.status_code < 500) + except NoReverseMatch: + pass + + return tester + + +for url in urlpatterns: + method_name = url.name.replace("-", "_") + setattr(TestAdmin, f"test_view_{method_name}", generic_view_tester(url.name)) + + +def generic_form_tester(form: ModelForm) -> Callable: + """Test a form""" + + def tester(self: TestAdmin): + form_inst = form() + self.assertFalse(form_inst.is_valid()) + + return tester + + +# Load the forms module from every app, so we have all forms loaded +for app in get_apps(): + module = app.__module__.replace(".apps", ".forms") + try: + import_module(module) + except ImportError: + pass + +for form_class in ModelForm.__subclasses__(): + setattr( + TestAdmin, f"test_form_{form_class.__name__}", generic_form_tester(form_class) + ) diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py new file mode 100644 index 00000000..ecd81d8d --- /dev/null +++ b/authentik/admin/urls.py @@ -0,0 +1,353 @@ +"""authentik URL Configuration""" +from django.urls import path + +from authentik.admin.views import ( + applications, + certificate_key_pair, + flows, + groups, + outposts, + outposts_service_connections, + overview, + policies, + policies_bindings, + property_mappings, + providers, + sources, + stages, + stages_bindings, + stages_invitations, + stages_prompts, + tasks, + tokens, + users, +) + +urlpatterns = [ + path( + "overview/cache/flow/", + overview.FlowCacheClearView.as_view(), + name="overview-clear-flow-cache", + ), + path( + "overview/cache/policy/", + overview.PolicyCacheClearView.as_view(), + name="overview-clear-policy-cache", + ), + path("overview/", overview.AdministrationOverviewView.as_view(), name="overview"), + # Applications + path( + "applications/", applications.ApplicationListView.as_view(), name="applications" + ), + path( + "applications/create/", + applications.ApplicationCreateView.as_view(), + name="application-create", + ), + path( + "applications//update/", + applications.ApplicationUpdateView.as_view(), + name="application-update", + ), + path( + "applications//delete/", + applications.ApplicationDeleteView.as_view(), + name="application-delete", + ), + # Tokens + path("tokens/", tokens.TokenListView.as_view(), name="tokens"), + path( + "tokens//delete/", + tokens.TokenDeleteView.as_view(), + name="token-delete", + ), + # Sources + path("sources/", sources.SourceListView.as_view(), name="sources"), + path("sources/create/", sources.SourceCreateView.as_view(), name="source-create"), + path( + "sources//update/", + sources.SourceUpdateView.as_view(), + name="source-update", + ), + path( + "sources//delete/", + sources.SourceDeleteView.as_view(), + name="source-delete", + ), + # Policies + path("policies/", policies.PolicyListView.as_view(), name="policies"), + path("policies/create/", policies.PolicyCreateView.as_view(), name="policy-create"), + path( + "policies//update/", + policies.PolicyUpdateView.as_view(), + name="policy-update", + ), + path( + "policies//delete/", + policies.PolicyDeleteView.as_view(), + name="policy-delete", + ), + path( + "policies//test/", + policies.PolicyTestView.as_view(), + name="policy-test", + ), + # Policy bindings + path( + "policies/bindings/", + policies_bindings.PolicyBindingListView.as_view(), + name="policies-bindings", + ), + path( + "policies/bindings/create/", + policies_bindings.PolicyBindingCreateView.as_view(), + name="policy-binding-create", + ), + path( + "policies/bindings//update/", + policies_bindings.PolicyBindingUpdateView.as_view(), + name="policy-binding-update", + ), + path( + "policies/bindings//delete/", + policies_bindings.PolicyBindingDeleteView.as_view(), + name="policy-binding-delete", + ), + # Providers + path("providers/", providers.ProviderListView.as_view(), name="providers"), + path( + "providers/create/", + providers.ProviderCreateView.as_view(), + name="provider-create", + ), + path( + "providers//update/", + providers.ProviderUpdateView.as_view(), + name="provider-update", + ), + path( + "providers//delete/", + providers.ProviderDeleteView.as_view(), + name="provider-delete", + ), + # Stages + path("stages/", stages.StageListView.as_view(), name="stages"), + path("stages/create/", stages.StageCreateView.as_view(), name="stage-create"), + path( + "stages//update/", + stages.StageUpdateView.as_view(), + name="stage-update", + ), + path( + "stages//delete/", + stages.StageDeleteView.as_view(), + name="stage-delete", + ), + # Stage bindings + path( + "stages/bindings/", + stages_bindings.StageBindingListView.as_view(), + name="stage-bindings", + ), + path( + "stages/bindings/create/", + stages_bindings.StageBindingCreateView.as_view(), + name="stage-binding-create", + ), + path( + "stages/bindings//update/", + stages_bindings.StageBindingUpdateView.as_view(), + name="stage-binding-update", + ), + path( + "stages/bindings//delete/", + stages_bindings.StageBindingDeleteView.as_view(), + name="stage-binding-delete", + ), + # Stage Prompts + path( + "stages/prompts/", + stages_prompts.PromptListView.as_view(), + name="stage-prompts", + ), + path( + "stages/prompts/create/", + stages_prompts.PromptCreateView.as_view(), + name="stage-prompt-create", + ), + path( + "stages/prompts//update/", + stages_prompts.PromptUpdateView.as_view(), + name="stage-prompt-update", + ), + path( + "stages/prompts//delete/", + stages_prompts.PromptDeleteView.as_view(), + name="stage-prompt-delete", + ), + # Stage Invitations + path( + "stages/invitations/", + stages_invitations.InvitationListView.as_view(), + name="stage-invitations", + ), + path( + "stages/invitations/create/", + stages_invitations.InvitationCreateView.as_view(), + name="stage-invitation-create", + ), + path( + "stages/invitations//delete/", + stages_invitations.InvitationDeleteView.as_view(), + name="stage-invitation-delete", + ), + # Flows + path("flows/", flows.FlowListView.as_view(), name="flows"), + path( + "flows/create/", + flows.FlowCreateView.as_view(), + name="flow-create", + ), + path( + "flows/import/", + flows.FlowImportView.as_view(), + name="flow-import", + ), + path( + "flows//update/", + flows.FlowUpdateView.as_view(), + name="flow-update", + ), + path( + "flows//execute/", + flows.FlowDebugExecuteView.as_view(), + name="flow-execute", + ), + path( + "flows//export/", + flows.FlowExportView.as_view(), + name="flow-export", + ), + path( + "flows//delete/", + flows.FlowDeleteView.as_view(), + name="flow-delete", + ), + # Property Mappings + path( + "property-mappings/", + property_mappings.PropertyMappingListView.as_view(), + name="property-mappings", + ), + path( + "property-mappings/create/", + property_mappings.PropertyMappingCreateView.as_view(), + name="property-mapping-create", + ), + path( + "property-mappings//update/", + property_mappings.PropertyMappingUpdateView.as_view(), + name="property-mapping-update", + ), + path( + "property-mappings//delete/", + property_mappings.PropertyMappingDeleteView.as_view(), + name="property-mapping-delete", + ), + # Users + path("users/", users.UserListView.as_view(), name="users"), + path("users/create/", users.UserCreateView.as_view(), name="user-create"), + path("users//update/", users.UserUpdateView.as_view(), name="user-update"), + path("users//delete/", users.UserDeleteView.as_view(), name="user-delete"), + path( + "users//disable/", users.UserDisableView.as_view(), name="user-disable" + ), + path("users//enable/", users.UserEnableView.as_view(), name="user-enable"), + path( + "users//reset/", + users.UserPasswordResetView.as_view(), + name="user-password-reset", + ), + # Groups + path("groups/", groups.GroupListView.as_view(), name="groups"), + path("groups/create/", groups.GroupCreateView.as_view(), name="group-create"), + path( + "groups//update/", + groups.GroupUpdateView.as_view(), + name="group-update", + ), + path( + "groups//delete/", + groups.GroupDeleteView.as_view(), + name="group-delete", + ), + # Certificate-Key Pairs + path( + "crypto/certificates/", + certificate_key_pair.CertificateKeyPairListView.as_view(), + name="certificate_key_pair", + ), + path( + "crypto/certificates/create/", + certificate_key_pair.CertificateKeyPairCreateView.as_view(), + name="certificatekeypair-create", + ), + path( + "crypto/certificates//update/", + certificate_key_pair.CertificateKeyPairUpdateView.as_view(), + name="certificatekeypair-update", + ), + path( + "crypto/certificates//delete/", + certificate_key_pair.CertificateKeyPairDeleteView.as_view(), + name="certificatekeypair-delete", + ), + # Outposts + path( + "outposts/", + outposts.OutpostListView.as_view(), + name="outposts", + ), + path( + "outposts/create/", + outposts.OutpostCreateView.as_view(), + name="outpost-create", + ), + path( + "outposts//update/", + outposts.OutpostUpdateView.as_view(), + name="outpost-update", + ), + path( + "outposts//delete/", + outposts.OutpostDeleteView.as_view(), + name="outpost-delete", + ), + # Outpost Service Connections + path( + "outposts/service_connections/", + outposts_service_connections.OutpostServiceConnectionListView.as_view(), + name="outpost-service-connections", + ), + path( + "outposts/service_connections/create/", + outposts_service_connections.OutpostServiceConnectionCreateView.as_view(), + name="outpost-service-connection-create", + ), + path( + "outposts/service_connections//update/", + outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(), + name="outpost-service-connection-update", + ), + path( + "outposts/service_connections//delete/", + outposts_service_connections.OutpostServiceConnectionDeleteView.as_view(), + name="outpost-service-connection-delete", + ), + # Tasks + path( + "tasks/", + tasks.TaskListView.as_view(), + name="tasks", + ), +] diff --git a/passbook/admin/views/__init__.py b/authentik/admin/views/__init__.py similarity index 100% rename from passbook/admin/views/__init__.py rename to authentik/admin/views/__init__.py diff --git a/authentik/admin/views/applications.py b/authentik/admin/views/applications.py new file mode 100644 index 00000000..4d440227 --- /dev/null +++ b/authentik/admin/views/applications.py @@ -0,0 +1,93 @@ +"""authentik Application administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.forms.applications import ApplicationForm +from authentik.core.models import Application +from authentik.lib.views import CreateAssignPermView + + +class ApplicationListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all applications""" + + model = Application + permission_required = "authentik_core.view_application" + ordering = "name" + template_name = "administration/application/list.html" + + search_fields = [ + "name", + "slug", + "meta_launch_url", + "meta_icon_url", + "meta_description", + "meta_publisher", + ] + + +class ApplicationCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Application""" + + model = Application + form_class = ApplicationForm + permission_required = "authentik_core.add_application" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:applications") + success_message = _("Successfully created Application") + + +class ApplicationUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update application""" + + model = Application + form_class = ApplicationForm + permission_required = "authentik_core.change_application" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:applications") + success_message = _("Successfully updated Application") + + +class ApplicationDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete application""" + + model = Application + permission_required = "authentik_core.delete_application" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:applications") + success_message = _("Successfully deleted Application") diff --git a/authentik/admin/views/certificate_key_pair.py b/authentik/admin/views/certificate_key_pair.py new file mode 100644 index 00000000..09e154cf --- /dev/null +++ b/authentik/admin/views/certificate_key_pair.py @@ -0,0 +1,86 @@ +"""authentik CertificateKeyPair administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.crypto.forms import CertificateKeyPairForm +from authentik.crypto.models import CertificateKeyPair +from authentik.lib.views import CreateAssignPermView + + +class CertificateKeyPairListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all keypairs""" + + model = CertificateKeyPair + permission_required = "authentik_crypto.view_certificatekeypair" + ordering = "name" + template_name = "administration/certificatekeypair/list.html" + + search_fields = ["name"] + + +class CertificateKeyPairCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new CertificateKeyPair""" + + model = CertificateKeyPair + form_class = CertificateKeyPairForm + permission_required = "authentik_crypto.add_certificatekeypair" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:certificate_key_pair") + success_message = _("Successfully created CertificateKeyPair") + + +class CertificateKeyPairUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update certificatekeypair""" + + model = CertificateKeyPair + form_class = CertificateKeyPairForm + permission_required = "authentik_crypto.change_certificatekeypair" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:certificate_key_pair") + success_message = _("Successfully updated Certificate-Key Pair") + + +class CertificateKeyPairDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete certificatekeypair""" + + model = CertificateKeyPair + permission_required = "authentik_crypto.delete_certificatekeypair" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:certificate_key_pair") + success_message = _("Successfully deleted Certificate-Key Pair") diff --git a/authentik/admin/views/flows.py b/authentik/admin/views/flows.py new file mode 100644 index 00000000..1578615f --- /dev/null +++ b/authentik/admin/views/flows.py @@ -0,0 +1,151 @@ +"""authentik Flow administration""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import DetailView, FormView, ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.flows.forms import FlowForm, FlowImportForm +from authentik.flows.models import Flow +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.transfer.common import DataclassEncoder +from authentik.flows.transfer.exporter import FlowExporter +from authentik.flows.transfer.importer import FlowImporter +from authentik.flows.views import SESSION_KEY_PLAN, FlowPlanner +from authentik.lib.utils.urls import redirect_with_qs +from authentik.lib.views import CreateAssignPermView + + +class FlowListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all flows""" + + model = Flow + permission_required = "authentik_flows.view_flow" + ordering = "name" + template_name = "administration/flow/list.html" + search_fields = ["name", "slug", "designation", "title"] + + +class FlowCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Flow""" + + model = Flow + form_class = FlowForm + permission_required = "authentik_flows.add_flow" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:flows") + success_message = _("Successfully created Flow") + + +class FlowUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update flow""" + + model = Flow + form_class = FlowForm + permission_required = "authentik_flows.change_flow" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:flows") + success_message = _("Successfully updated Flow") + + +class FlowDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete flow""" + + model = Flow + permission_required = "authentik_flows.delete_flow" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:flows") + success_message = _("Successfully deleted Flow") + + +class FlowDebugExecuteView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + """Debug exectue flow, setting the current user as pending user""" + + model = Flow + permission_required = "authentik_flows.view_flow" + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, pk: str) -> HttpResponse: + """Debug exectue flow, setting the current user as pending user""" + flow: Flow = self.get_object() + planner = FlowPlanner(flow) + planner.use_cache = False + plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user}) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_flows:flow-executor-shell", + self.request.GET, + flow_slug=flow.slug, + ) + + +class FlowImportView(LoginRequiredMixin, FormView): + """Import flow from JSON Export; only allowed for superusers + as these flows can contain python code""" + + form_class = FlowImportForm + template_name = "administration/flow/import.html" + success_url = reverse_lazy("authentik_admin:flows") + + def dispatch(self, request, *args, **kwargs): + if not request.user.is_superuser: + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form: FlowImportForm) -> HttpResponse: + importer = FlowImporter(form.cleaned_data["flow"].read().decode()) + successful = importer.apply() + if not successful: + messages.error(self.request, _("Failed to import flow.")) + else: + messages.success(self.request, _("Successfully imported flow.")) + return super().form_valid(form) + + +class FlowExportView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + """Export Flow""" + + model = Flow + permission_required = "authentik_flows.export_flow" + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, pk: str) -> HttpResponse: + """Debug exectue flow, setting the current user as pending user""" + flow: Flow = self.get_object() + exporter = FlowExporter(flow) + response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False) + response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' + return response diff --git a/authentik/admin/views/groups.py b/authentik/admin/views/groups.py new file mode 100644 index 00000000..bebd3bdb --- /dev/null +++ b/authentik/admin/views/groups.py @@ -0,0 +1,83 @@ +"""authentik Group administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.forms.groups import GroupForm +from authentik.core.models import Group +from authentik.lib.views import CreateAssignPermView + + +class GroupListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all groups""" + + model = Group + permission_required = "authentik_core.view_group" + ordering = "name" + template_name = "administration/group/list.html" + search_fields = ["name", "attributes"] + + +class GroupCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Group""" + + model = Group + form_class = GroupForm + permission_required = "authentik_core.add_group" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:groups") + success_message = _("Successfully created Group") + + +class GroupUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update group""" + + model = Group + form_class = GroupForm + permission_required = "authentik_core.change_group" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:groups") + success_message = _("Successfully updated Group") + + +class GroupDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete group""" + + model = Group + permission_required = "authentik_flows.delete_group" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:groups") + success_message = _("Successfully deleted Group") diff --git a/authentik/admin/views/outposts.py b/authentik/admin/views/outposts.py new file mode 100644 index 00000000..1e54ca5e --- /dev/null +++ b/authentik/admin/views/outposts.py @@ -0,0 +1,93 @@ +"""authentik Outpost administration""" +from dataclasses import asdict +from typing import Any, Dict + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.lib.views import CreateAssignPermView +from authentik.outposts.forms import OutpostForm +from authentik.outposts.models import Outpost, OutpostConfig + + +class OutpostListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all outposts""" + + model = Outpost + permission_required = "authentik_outposts.view_outpost" + ordering = "name" + template_name = "administration/outpost/list.html" + search_fields = ["name", "_config"] + + +class OutpostCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Outpost""" + + model = Outpost + form_class = OutpostForm + permission_required = "authentik_outposts.add_outpost" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:outposts") + success_message = _("Successfully created Outpost") + + def get_initial(self) -> Dict[str, Any]: + return { + "_config": asdict( + OutpostConfig(authentik_host=self.request.build_absolute_uri("/")) + ) + } + + +class OutpostUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update outpost""" + + model = Outpost + form_class = OutpostForm + permission_required = "authentik_outposts.change_outpost" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:outposts") + success_message = _("Successfully updated Outpost") + + +class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete outpost""" + + model = Outpost + permission_required = "authentik_outposts.delete_outpost" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:outposts") + success_message = _("Successfully deleted Outpost") diff --git a/authentik/admin/views/outposts_service_connections.py b/authentik/admin/views/outposts_service_connections.py new file mode 100644 index 00000000..a1aded02 --- /dev/null +++ b/authentik/admin/views/outposts_service_connections.py @@ -0,0 +1,83 @@ +"""authentik OutpostServiceConnection administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + InheritanceCreateView, + InheritanceListView, + InheritanceUpdateView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.outposts.models import OutpostServiceConnection + + +class OutpostServiceConnectionListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + InheritanceListView, +): + """Show list of all outpost-service-connections""" + + model = OutpostServiceConnection + permission_required = "authentik_outposts.add_outpostserviceconnection" + template_name = "administration/outpost_service_connection/list.html" + ordering = "pk" + search_fields = ["pk", "name"] + + +class OutpostServiceConnectionCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + InheritanceCreateView, +): + """Create new OutpostServiceConnection""" + + model = OutpostServiceConnection + permission_required = "authentik_outposts.add_outpostserviceconnection" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:outpost-service-connections") + success_message = _("Successfully created OutpostServiceConnection") + + +class OutpostServiceConnectionUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + InheritanceUpdateView, +): + """Update outpostserviceconnection""" + + model = OutpostServiceConnection + permission_required = "authentik_outposts.change_outpostserviceconnection" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:outpost-service-connections") + success_message = _("Successfully updated OutpostServiceConnection") + + +class OutpostServiceConnectionDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete outpostserviceconnection""" + + model = OutpostServiceConnection + permission_required = "authentik_outposts.delete_outpostserviceconnection" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:outpost-service-connections") + success_message = _("Successfully deleted OutpostServiceConnection") diff --git a/authentik/admin/views/overview.py b/authentik/admin/views/overview.py new file mode 100644 index 00000000..bded396d --- /dev/null +++ b/authentik/admin/views/overview.py @@ -0,0 +1,85 @@ +"""authentik administration overview""" +from typing import Union + +from django.conf import settings +from django.contrib.messages.views import SuccessMessageMixin +from django.core.cache import cache +from django.http.request import HttpRequest +from django.http.response import HttpResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import FormView, TemplateView +from packaging.version import LegacyVersion, Version, parse +from structlog import get_logger + +from authentik import __version__ +from authentik.admin.forms.overview import FlowCacheClearForm, PolicyCacheClearForm +from authentik.admin.mixins import AdminRequiredMixin +from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version +from authentik.core.models import Provider, User +from authentik.policies.models import Policy + +LOGGER = get_logger() + + +class AdministrationOverviewView(AdminRequiredMixin, TemplateView): + """Overview View""" + + template_name = "administration/overview.html" + + def get_latest_version(self) -> Union[LegacyVersion, Version]: + """Get latest version from cache""" + version_in_cache = cache.get(VERSION_CACHE_KEY) + if not version_in_cache: + if not settings.DEBUG: + update_latest_version.delay() + return parse(__version__) + return parse(version_in_cache) + + def get_context_data(self, **kwargs): + kwargs["policy_count"] = len(Policy.objects.all()) + kwargs["user_count"] = len(User.objects.all()) - 1 # Remove anonymous user + kwargs["provider_count"] = len(Provider.objects.all()) + kwargs["version"] = parse(__version__) + kwargs["version_latest"] = self.get_latest_version() + kwargs["providers_without_application"] = Provider.objects.filter( + application=None + ) + kwargs["policies_without_binding"] = len( + Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True) + ) + kwargs["cached_policies"] = len(cache.keys("policy_*")) + kwargs["cached_flows"] = len(cache.keys("flow_*")) + return super().get_context_data(**kwargs) + + +class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): + """View to clear Policy cache""" + + form_class = PolicyCacheClearForm + + template_name = "generic/form_non_model.html" + success_url = reverse_lazy("authentik_admin:overview") + success_message = _("Successfully cleared Policy cache") + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + keys = cache.keys("policy_*") + cache.delete_many(keys) + LOGGER.debug("Cleared Policy cache", keys=len(keys)) + return super().post(request, *args, **kwargs) + + +class FlowCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): + """View to clear Flow cache""" + + form_class = FlowCacheClearForm + + template_name = "generic/form_non_model.html" + success_url = reverse_lazy("authentik_admin:overview") + success_message = _("Successfully cleared Flow cache") + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + keys = cache.keys("flow_*") + cache.delete_many(keys) + LOGGER.debug("Cleared flow cache", keys=len(keys)) + return super().post(request, *args, **kwargs) diff --git a/authentik/admin/views/policies.py b/authentik/admin/views/policies.py new file mode 100644 index 00000000..43499991 --- /dev/null +++ b/authentik/admin/views/policies.py @@ -0,0 +1,129 @@ +"""authentik Policy administration""" +from typing import Any, Dict + +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import QuerySet +from django.http import HttpResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from django.views.generic.detail import DetailView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.forms.policies import PolicyTestForm +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + InheritanceCreateView, + InheritanceListView, + InheritanceUpdateView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.policies.models import Policy, PolicyBinding +from authentik.policies.process import PolicyProcess, PolicyRequest + + +class PolicyListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + InheritanceListView, +): + """Show list of all policies""" + + model = Policy + permission_required = "authentik_policies.view_policy" + ordering = "name" + template_name = "administration/policy/list.html" + search_fields = ["name"] + + +class PolicyCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + InheritanceCreateView, +): + """Create new Policy""" + + model = Policy + permission_required = "authentik_policies.add_policy" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:policies") + success_message = _("Successfully created Policy") + + +class PolicyUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + InheritanceUpdateView, +): + """Update policy""" + + model = Policy + permission_required = "authentik_policies.change_policy" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:policies") + success_message = _("Successfully updated Policy") + + +class PolicyDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete policy""" + + model = Policy + permission_required = "authentik_policies.delete_policy" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:policies") + success_message = _("Successfully deleted Policy") + + +class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, FormView): + """View to test policy(s)""" + + model = Policy + form_class = PolicyTestForm + permission_required = "authentik_policies.view_policy" + template_name = "administration/policy/test.html" + object = None + + def get_object(self, queryset=None) -> QuerySet: + return ( + Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first() + ) + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs["policy"] = self.get_object() + return super().get_context_data(**kwargs) + + def post(self, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + return super().post(*args, **kwargs) + + def form_valid(self, form: PolicyTestForm) -> HttpResponse: + policy = self.get_object() + user = form.cleaned_data.get("user") + + p_request = PolicyRequest(user) + p_request.http_request = self.request + p_request.context = form.cleaned_data + + proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None) + result = proc.execute() + if result.passing: + messages.success(self.request, _("User successfully passed policy.")) + else: + messages.error(self.request, _("User didn't pass policy.")) + return self.render_to_response(self.get_context_data(form=form, result=result)) diff --git a/authentik/admin/views/policies_bindings.py b/authentik/admin/views/policies_bindings.py new file mode 100644 index 00000000..9f7a8f97 --- /dev/null +++ b/authentik/admin/views/policies_bindings.py @@ -0,0 +1,99 @@ +"""authentik PolicyBinding administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import QuerySet +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin +from guardian.shortcuts import get_objects_for_user + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + UserPaginateListMixin, +) +from authentik.lib.views import CreateAssignPermView +from authentik.policies.forms import PolicyBindingForm +from authentik.policies.models import PolicyBinding + + +class PolicyBindingListView( + LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView +): + """Show list of all policies""" + + model = PolicyBinding + permission_required = "authentik_policies.view_policybinding" + ordering = ["order", "target"] + template_name = "administration/policy_binding/list.html" + + def get_queryset(self) -> QuerySet: + # Since `select_subclasses` does not work with a foreign key, we have to do two queries here + # First, get all pbm objects that have bindings attached + objects = ( + get_objects_for_user( + self.request.user, "authentik_policies.view_policybindingmodel" + ) + .filter(policies__isnull=False) + .select_subclasses() + .select_related() + .order_by("pk") + ) + for pbm in objects: + pbm.bindings = get_objects_for_user( + self.request.user, self.permission_required + ).filter(target__pk=pbm.pbm_uuid) + return objects + + +class PolicyBindingCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new PolicyBinding""" + + model = PolicyBinding + permission_required = "authentik_policies.add_policybinding" + form_class = PolicyBindingForm + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:policies-bindings") + success_message = _("Successfully created PolicyBinding") + + +class PolicyBindingUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update policybinding""" + + model = PolicyBinding + permission_required = "authentik_policies.change_policybinding" + form_class = PolicyBindingForm + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:policies-bindings") + success_message = _("Successfully updated PolicyBinding") + + +class PolicyBindingDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete policybinding""" + + model = PolicyBinding + permission_required = "authentik_policies.delete_policybinding" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:policies-bindings") + success_message = _("Successfully deleted PolicyBinding") diff --git a/authentik/admin/views/property_mappings.py b/authentik/admin/views/property_mappings.py new file mode 100644 index 00000000..522b2662 --- /dev/null +++ b/authentik/admin/views/property_mappings.py @@ -0,0 +1,83 @@ +"""authentik PropertyMapping administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + InheritanceCreateView, + InheritanceListView, + InheritanceUpdateView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.models import PropertyMapping + + +class PropertyMappingListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + InheritanceListView, +): + """Show list of all property_mappings""" + + model = PropertyMapping + permission_required = "authentik_core.view_propertymapping" + template_name = "administration/property_mapping/list.html" + ordering = "name" + search_fields = ["name", "expression"] + + +class PropertyMappingCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + InheritanceCreateView, +): + """Create new PropertyMapping""" + + model = PropertyMapping + permission_required = "authentik_core.add_propertymapping" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:property-mappings") + success_message = _("Successfully created Property Mapping") + + +class PropertyMappingUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + InheritanceUpdateView, +): + """Update property_mapping""" + + model = PropertyMapping + permission_required = "authentik_core.change_propertymapping" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:property-mappings") + success_message = _("Successfully updated Property Mapping") + + +class PropertyMappingDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete property_mapping""" + + model = PropertyMapping + permission_required = "authentik_core.delete_propertymapping" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:property-mappings") + success_message = _("Successfully deleted Property Mapping") diff --git a/authentik/admin/views/providers.py b/authentik/admin/views/providers.py new file mode 100644 index 00000000..ed4accf4 --- /dev/null +++ b/authentik/admin/views/providers.py @@ -0,0 +1,83 @@ +"""authentik Provider administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + InheritanceCreateView, + InheritanceListView, + InheritanceUpdateView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.models import Provider + + +class ProviderListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + InheritanceListView, +): + """Show list of all providers""" + + model = Provider + permission_required = "authentik_core.add_provider" + template_name = "administration/provider/list.html" + ordering = "pk" + search_fields = ["pk", "name"] + + +class ProviderCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + InheritanceCreateView, +): + """Create new Provider""" + + model = Provider + permission_required = "authentik_core.add_provider" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:providers") + success_message = _("Successfully created Provider") + + +class ProviderUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + InheritanceUpdateView, +): + """Update provider""" + + model = Provider + permission_required = "authentik_core.change_provider" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:providers") + success_message = _("Successfully updated Provider") + + +class ProviderDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete provider""" + + model = Provider + permission_required = "authentik_core.delete_provider" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:providers") + success_message = _("Successfully deleted Provider") diff --git a/authentik/admin/views/sources.py b/authentik/admin/views/sources.py new file mode 100644 index 00000000..23fc5d0b --- /dev/null +++ b/authentik/admin/views/sources.py @@ -0,0 +1,81 @@ +"""authentik Source administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + InheritanceCreateView, + InheritanceListView, + InheritanceUpdateView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.models import Source + + +class SourceListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + InheritanceListView, +): + """Show list of all sources""" + + model = Source + permission_required = "authentik_core.view_source" + ordering = "name" + template_name = "administration/source/list.html" + search_fields = ["name", "slug"] + + +class SourceCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + InheritanceCreateView, +): + """Create new Source""" + + model = Source + permission_required = "authentik_core.add_source" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:sources") + success_message = _("Successfully created Source") + + +class SourceUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + InheritanceUpdateView, +): + """Update source""" + + model = Source + permission_required = "authentik_core.change_source" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:sources") + success_message = _("Successfully updated Source") + + +class SourceDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete source""" + + model = Source + permission_required = "authentik_core.delete_source" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:sources") + success_message = _("Successfully deleted Source") diff --git a/authentik/admin/views/stages.py b/authentik/admin/views/stages.py new file mode 100644 index 00000000..55e7623d --- /dev/null +++ b/authentik/admin/views/stages.py @@ -0,0 +1,79 @@ +"""authentik Stage administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + InheritanceCreateView, + InheritanceListView, + InheritanceUpdateView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.flows.models import Stage + + +class StageListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + InheritanceListView, +): + """Show list of all stages""" + + model = Stage + template_name = "administration/stage/list.html" + permission_required = "authentik_flows.view_stage" + ordering = "name" + search_fields = ["name"] + + +class StageCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + InheritanceCreateView, +): + """Create new Stage""" + + model = Stage + template_name = "generic/create.html" + permission_required = "authentik_flows.add_stage" + + success_url = reverse_lazy("authentik_admin:stages") + success_message = _("Successfully created Stage") + + +class StageUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + InheritanceUpdateView, +): + """Update stage""" + + model = Stage + permission_required = "authentik_flows.update_application" + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:stages") + success_message = _("Successfully updated Stage") + + +class StageDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete stage""" + + model = Stage + template_name = "generic/delete.html" + permission_required = "authentik_flows.delete_stage" + success_url = reverse_lazy("authentik_admin:stages") + success_message = _("Successfully deleted Stage") diff --git a/authentik/admin/views/stages_bindings.py b/authentik/admin/views/stages_bindings.py new file mode 100644 index 00000000..d048764e --- /dev/null +++ b/authentik/admin/views/stages_bindings.py @@ -0,0 +1,79 @@ +"""authentik StageBinding administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + UserPaginateListMixin, +) +from authentik.flows.forms import FlowStageBindingForm +from authentik.flows.models import FlowStageBinding +from authentik.lib.views import CreateAssignPermView + + +class StageBindingListView( + LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView +): + """Show list of all flows""" + + model = FlowStageBinding + permission_required = "authentik_flows.view_flowstagebinding" + ordering = ["target", "order"] + template_name = "administration/stage_binding/list.html" + + +class StageBindingCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new StageBinding""" + + model = FlowStageBinding + permission_required = "authentik_flows.add_flowstagebinding" + form_class = FlowStageBindingForm + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:stage-bindings") + success_message = _("Successfully created StageBinding") + + +class StageBindingUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update FlowStageBinding""" + + model = FlowStageBinding + permission_required = "authentik_flows.change_flowstagebinding" + form_class = FlowStageBindingForm + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:stage-bindings") + success_message = _("Successfully updated StageBinding") + + +class StageBindingDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete FlowStageBinding""" + + model = FlowStageBinding + permission_required = "authentik_flows.delete_flowstagebinding" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:stage-bindings") + success_message = _("Successfully deleted FlowStageBinding") diff --git a/authentik/admin/views/stages_invitations.py b/authentik/admin/views/stages_invitations.py new file mode 100644 index 00000000..b914c16d --- /dev/null +++ b/authentik/admin/views/stages_invitations.py @@ -0,0 +1,76 @@ +"""authentik Invitation administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.http import HttpResponseRedirect +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.lib.views import CreateAssignPermView +from authentik.stages.invitation.forms import InvitationForm +from authentik.stages.invitation.models import Invitation +from authentik.stages.invitation.signals import invitation_created + + +class InvitationListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all invitations""" + + model = Invitation + permission_required = "authentik_stages_invitation.view_invitation" + template_name = "administration/stage_invitation/list.html" + ordering = "-expires" + search_fields = ["created_by__username", "expires", "fixed_data"] + + +class InvitationCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Invitation""" + + model = Invitation + form_class = InvitationForm + permission_required = "authentik_stages_invitation.add_invitation" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:stage-invitations") + success_message = _("Successfully created Invitation") + + def form_valid(self, form): + obj = form.save(commit=False) + obj.created_by = self.request.user + obj.save() + invitation_created.send(sender=self, request=self.request, invitation=obj) + return HttpResponseRedirect(self.success_url) + + +class InvitationDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete invitation""" + + model = Invitation + permission_required = "authentik_stages_invitation.delete_invitation" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:stage-invitations") + success_message = _("Successfully deleted Invitation") diff --git a/authentik/admin/views/stages_prompts.py b/authentik/admin/views/stages_prompts.py new file mode 100644 index 00000000..cc59a2ba --- /dev/null +++ b/authentik/admin/views/stages_prompts.py @@ -0,0 +1,88 @@ +"""authentik Prompt administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.lib.views import CreateAssignPermView +from authentik.stages.prompt.forms import PromptAdminForm +from authentik.stages.prompt.models import Prompt + + +class PromptListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all prompts""" + + model = Prompt + permission_required = "authentik_stages_prompt.view_prompt" + ordering = "order" + template_name = "administration/stage_prompt/list.html" + search_fields = [ + "field_key", + "label", + "type", + "placeholder", + ] + + +class PromptCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Prompt""" + + model = Prompt + form_class = PromptAdminForm + permission_required = "authentik_stages_prompt.add_prompt" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:stage-prompts") + success_message = _("Successfully created Prompt") + + +class PromptUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update prompt""" + + model = Prompt + form_class = PromptAdminForm + permission_required = "authentik_stages_prompt.change_prompt" + + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:stage-prompts") + success_message = _("Successfully updated Prompt") + + +class PromptDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete prompt""" + + model = Prompt + permission_required = "authentik_stages_prompt.delete_prompt" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:stage-prompts") + success_message = _("Successfully deleted Prompt") diff --git a/authentik/admin/views/tasks.py b/authentik/admin/views/tasks.py new file mode 100644 index 00000000..44b96c8e --- /dev/null +++ b/authentik/admin/views/tasks.py @@ -0,0 +1,23 @@ +"""authentik Tasks List""" +from typing import Any, Dict + +from django.views.generic.base import TemplateView + +from authentik.admin.mixins import AdminRequiredMixin +from authentik.lib.tasks import TaskInfo, TaskResultStatus + + +class TaskListView(AdminRequiredMixin, TemplateView): + """Show list of all background tasks""" + + template_name = "administration/task/list.html" + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + kwargs["object_list"] = sorted( + TaskInfo.all().values(), key=lambda x: x.task_name + ) + kwargs["task_successful"] = TaskResultStatus.SUCCESSFUL + kwargs["task_warning"] = TaskResultStatus.WARNING + kwargs["task_error"] = TaskResultStatus.ERROR + return kwargs diff --git a/authentik/admin/views/tokens.py b/authentik/admin/views/tokens.py new file mode 100644 index 00000000..126dac06 --- /dev/null +++ b/authentik/admin/views/tokens.py @@ -0,0 +1,45 @@ +"""authentik Token administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from authentik.admin.views.utils import ( + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.models import Token + + +class TokenListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all tokens""" + + model = Token + permission_required = "authentik_core.view_token" + ordering = "expires" + template_name = "administration/token/list.html" + search_fields = [ + "identifier", + "intent", + "user__username", + "description", + ] + + +class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete token""" + + model = Token + permission_required = "authentik_core.delete_token" + + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:tokens") + success_message = _("Successfully deleted Token") diff --git a/authentik/admin/views/users.py b/authentik/admin/views/users.py new file mode 100644 index 00000000..434a8c0c --- /dev/null +++ b/authentik/admin/views/users.py @@ -0,0 +1,168 @@ +"""authentik User administration""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse, reverse_lazy +from django.utils.http import urlencode +from django.utils.translation import gettext as _ +from django.views.generic import DetailView, ListView, UpdateView +from guardian.mixins import ( + PermissionListMixin, + PermissionRequiredMixin, + get_anonymous_user, +) + +from authentik.admin.forms.users import UserForm +from authentik.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.models import Token, User +from authentik.lib.views import CreateAssignPermView + + +class UserListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all users""" + + model = User + permission_required = "authentik_core.view_user" + ordering = "username" + template_name = "administration/user/list.html" + search_fields = ["username", "name", "attributes"] + + def get_queryset(self): + return super().get_queryset().exclude(pk=get_anonymous_user().pk) + + +class UserCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create user""" + + model = User + form_class = UserForm + permission_required = "authentik_core.add_user" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_admin:users") + success_message = _("Successfully created User") + + +class UserUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + UpdateView, +): + """Update user""" + + model = User + form_class = UserForm + permission_required = "authentik_core.change_user" + + # By default the object's name is user which is used by other checks + context_object_name = "object" + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_admin:users") + success_message = _("Successfully updated User") + + +class UserDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete user""" + + model = User + permission_required = "authentik_core.delete_user" + + # By default the object's name is user which is used by other checks + context_object_name = "object" + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_admin:users") + success_message = _("Successfully deleted User") + + +class UserDisableView( + LoginRequiredMixin, PermissionRequiredMixin, BackSuccessUrlMixin, DeleteMessageView +): + """Disable user""" + + object: User + + model = User + permission_required = "authentik_core.update_user" + + # By default the object's name is user which is used by other checks + context_object_name = "object" + template_name = "administration/user/disable.html" + success_url = reverse_lazy("authentik_admin:users") + success_message = _("Successfully disabled User") + + def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object: User = self.get_object() + success_url = self.get_success_url() + self.object.is_active = False + self.object.save() + return HttpResponseRedirect(success_url) + + +class UserEnableView( + LoginRequiredMixin, PermissionRequiredMixin, BackSuccessUrlMixin, DetailView +): + """Enable user""" + + object: User + + model = User + permission_required = "authentik_core.update_user" + + # By default the object's name is user which is used by other checks + context_object_name = "object" + success_url = reverse_lazy("authentik_admin:users") + success_message = _("Successfully enabled User") + + def get(self, request: HttpRequest, *args, **kwargs): + self.object: User = self.get_object() + success_url = self.get_success_url() + self.object.is_active = True + self.object.save() + return HttpResponseRedirect(success_url) + + +class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + """Get Password reset link for user""" + + model = User + permission_required = "authentik_core.reset_user_password" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Create token for user and return link""" + super().get(request, *args, **kwargs) + token, __ = Token.objects.get_or_create( + identifier="password-reset-temp", user=self.object + ) + querystring = urlencode({"token": token.key}) + link = request.build_absolute_uri( + reverse("authentik_flows:default-recovery") + f"?{querystring}" + ) + messages.success( + request, _("Password reset link:
%(link)s
" % {"link": link}) + ) + return redirect("authentik_admin:users") diff --git a/authentik/admin/views/utils.py b/authentik/admin/views/utils.py new file mode 100644 index 00000000..bec33e4c --- /dev/null +++ b/authentik/admin/views/utils.py @@ -0,0 +1,124 @@ +"""authentik admin util views""" +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse + +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib.postgres.search import SearchQuery, SearchVector +from django.db.models import QuerySet +from django.http import Http404 +from django.http.request import HttpRequest +from django.views.generic import DeleteView, ListView, UpdateView +from django.views.generic.list import MultipleObjectMixin + +from authentik.lib.utils.reflection import all_subclasses +from authentik.lib.views import CreateAssignPermView + + +class DeleteMessageView(SuccessMessageMixin, DeleteView): + """DeleteView which shows `self.success_message` on successful deletion""" + + def delete(self, request, *args, **kwargs): + messages.success(self.request, self.success_message) + return super().delete(request, *args, **kwargs) + + +class InheritanceListView(ListView): + """ListView for objects using InheritanceManager""" + + def get_context_data(self, **kwargs): + kwargs["types"] = {x.__name__: x for x in all_subclasses(self.model)} + return super().get_context_data(**kwargs) + + def get_queryset(self): + return super().get_queryset().select_subclasses() + + +class SearchListMixin(MultipleObjectMixin): + """Accept search query using `search` querystring parameter. Requires self.search_fields, + a list of all fields to search. Can contain special lookups like __icontains""" + + search_fields: List[str] + + def get_queryset(self) -> QuerySet: + queryset = super().get_queryset() + if "search" in self.request.GET: + raw_query = self.request.GET["search"] + if raw_query == "": + # Empty query, don't search at all + return queryset + search = SearchQuery(raw_query, search_type="websearch") + return queryset.annotate(search=SearchVector(*self.search_fields)).filter( + search=search + ) + return queryset + + +class InheritanceCreateView(CreateAssignPermView): + """CreateView for objects using InheritanceManager""" + + def get_form_class(self): + provider_type = self.request.GET.get("type") + try: + model = next( + x for x in all_subclasses(self.model) if x.__name__ == provider_type + ) + except StopIteration as exc: + raise Http404 from exc + return model().form + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + form_cls = self.get_form_class() + if hasattr(form_cls, "template_name"): + kwargs["base_template"] = form_cls.template_name + return kwargs + + +class InheritanceUpdateView(UpdateView): + """UpdateView for objects using InheritanceManager""" + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + form_cls = self.get_form_class() + if hasattr(form_cls, "template_name"): + kwargs["base_template"] = form_cls.template_name + return kwargs + + def get_form_class(self): + return self.get_object().form + + def get_object(self, queryset=None): + return ( + self.model.objects.filter(pk=self.kwargs.get("pk")) + .select_subclasses() + .first() + ) + + +class BackSuccessUrlMixin: + """Checks if a relative URL has been given as ?back param, and redirect to it. Otherwise + default to self.success_url.""" + + request: HttpRequest + + success_url: Optional[str] + + def get_success_url(self) -> str: + """get_success_url from FormMixin""" + back_param = self.request.GET.get("back") + if back_param: + if not bool(urlparse(back_param).netloc): + return back_param + return str(self.success_url) + + +class UserPaginateListMixin: + """Get paginate_by value from user's attributes, defaulting to 15""" + + request: HttpRequest + + # pylint: disable=unused-argument + def get_paginate_by(self, queryset: QuerySet) -> int: + """get_paginate_by Function of ListView""" + return self.request.user.attributes.get("paginate_by", 15) diff --git a/passbook/api/__init__.py b/authentik/api/__init__.py similarity index 100% rename from passbook/api/__init__.py rename to authentik/api/__init__.py diff --git a/authentik/api/apps.py b/authentik/api/apps.py new file mode 100644 index 00000000..8ae859a5 --- /dev/null +++ b/authentik/api/apps.py @@ -0,0 +1,12 @@ +"""authentik API AppConfig""" + +from django.apps import AppConfig + + +class AuthentikAPIConfig(AppConfig): + """authentik API Config""" + + name = "authentik.api" + label = "authentik_api" + mountpoint = "api/" + verbose_name = "authentik API" diff --git a/authentik/api/auth.py b/authentik/api/auth.py new file mode 100644 index 00000000..c3a6bb3a --- /dev/null +++ b/authentik/api/auth.py @@ -0,0 +1,57 @@ +"""API Authentication""" +from base64 import b64decode +from typing import Any, Optional, Tuple, Union + +from rest_framework.authentication import BaseAuthentication, get_authorization_header +from rest_framework.request import Request +from structlog import get_logger + +from authentik.core.models import Token, TokenIntents, User + +LOGGER = get_logger() + + +def token_from_header(raw_header: bytes) -> Optional[Token]: + """raw_header in the Format of `Basic dGVzdDp0ZXN0`""" + auth_credentials = raw_header.decode() + # Accept headers with Type format and without + if " " in auth_credentials: + auth_type, auth_credentials = auth_credentials.split() + if auth_type.lower() != "basic": + LOGGER.debug( + "Unsupported authentication type, denying", type=auth_type.lower() + ) + return None + try: + auth_credentials = b64decode(auth_credentials.encode()).decode() + except UnicodeDecodeError: + return None + # Accept credentials with username and without + if ":" in auth_credentials: + _, password = auth_credentials.split(":") + else: + password = auth_credentials + if password == "": + return None + tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API) + if not tokens.exists(): + LOGGER.debug("Token not found") + return None + return tokens.first() + + +class AuthentikTokenAuthentication(BaseAuthentication): + """Token-based authentication using HTTP Basic authentication""" + + def authenticate(self, request: Request) -> Union[Tuple[User, Any], None]: + """Token-based authentication using HTTP Basic authentication""" + auth = get_authorization_header(request) + + token = token_from_header(auth) + if not token: + return None + + return (token.user, None) + + def authenticate_header(self, request: Request) -> str: + return 'Basic realm="authentik"' diff --git a/passbook/api/pagination.py b/authentik/api/pagination.py similarity index 100% rename from passbook/api/pagination.py rename to authentik/api/pagination.py diff --git a/authentik/api/templates/rest_framework/api.html b/authentik/api/templates/rest_framework/api.html new file mode 100644 index 00000000..aa3e2c31 --- /dev/null +++ b/authentik/api/templates/rest_framework/api.html @@ -0,0 +1,7 @@ +{% extends "rest_framework/base.html" %} + +{% block branding %} + + authentik + +{% endblock %} diff --git a/authentik/api/urls.py b/authentik/api/urls.py new file mode 100644 index 00000000..b4c7791b --- /dev/null +++ b/authentik/api/urls.py @@ -0,0 +1,8 @@ +"""authentik api urls""" +from django.urls import include, path + +from authentik.api.v2.urls import urlpatterns as v2_urls + +urlpatterns = [ + path("v2beta/", include(v2_urls)), +] diff --git a/passbook/api/v2/__init__.py b/authentik/api/v2/__init__.py similarity index 100% rename from passbook/api/v2/__init__.py rename to authentik/api/v2/__init__.py diff --git a/authentik/api/v2/config.py b/authentik/api/v2/config.py new file mode 100644 index 00000000..89ec46b1 --- /dev/null +++ b/authentik/api/v2/config.py @@ -0,0 +1,46 @@ +"""core Configs API""" +from drf_yasg2.utils import swagger_auto_schema +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ReadOnlyField, Serializer +from rest_framework.viewsets import ViewSet + +from authentik.lib.config import CONFIG + + +class ConfigSerializer(Serializer): + """Serialize authentik Config into DRF Object""" + + branding_logo = ReadOnlyField() + branding_title = ReadOnlyField() + + error_reporting_enabled = ReadOnlyField() + error_reporting_environment = ReadOnlyField() + error_reporting_send_pii = ReadOnlyField() + + def create(self, request: Request) -> Response: + raise NotImplementedError + + def update(self, request: Request) -> Response: + raise NotImplementedError + + +class ConfigsViewSet(ViewSet): + """Read-only view set that returns the current session's Configs""" + + permission_classes = [AllowAny] + + @swagger_auto_schema(responses={200: ConfigSerializer(many=True)}) + def list(self, request: Request) -> Response: + """Retrive public configuration options""" + config = ConfigSerializer( + { + "branding_logo": CONFIG.y("authentik.branding.logo"), + "branding_title": CONFIG.y("authentik.branding.title"), + "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), + "error_reporting_environment": CONFIG.y("error_reporting.environment"), + "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"), + } + ) + return Response(config.data) diff --git a/passbook/api/v2/messages.py b/authentik/api/v2/messages.py similarity index 100% rename from passbook/api/v2/messages.py rename to authentik/api/v2/messages.py diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py new file mode 100644 index 00000000..bfbbc92a --- /dev/null +++ b/authentik/api/v2/urls.py @@ -0,0 +1,160 @@ +"""api v2 urls""" +from django.urls import path, re_path +from drf_yasg2 import openapi +from drf_yasg2.views import get_schema_view +from rest_framework import routers +from rest_framework.permissions import AllowAny + +from authentik.admin.api.overview import AdministrationOverviewViewSet +from authentik.admin.api.overview_metrics import AdministrationMetricsViewSet +from authentik.admin.api.tasks import TaskViewSet +from authentik.api.v2.config import ConfigsViewSet +from authentik.api.v2.messages import MessagesViewSet +from authentik.audit.api import EventViewSet +from authentik.core.api.applications import ApplicationViewSet +from authentik.core.api.groups import GroupViewSet +from authentik.core.api.propertymappings import PropertyMappingViewSet +from authentik.core.api.providers import ProviderViewSet +from authentik.core.api.sources import SourceViewSet +from authentik.core.api.tokens import TokenViewSet +from authentik.core.api.users import UserViewSet +from authentik.crypto.api import CertificateKeyPairViewSet +from authentik.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet +from authentik.outposts.api import ( + DockerServiceConnectionViewSet, + KubernetesServiceConnectionViewSet, + OutpostViewSet, +) +from authentik.policies.api import PolicyBindingViewSet, PolicyViewSet +from authentik.policies.dummy.api import DummyPolicyViewSet +from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet +from authentik.policies.expression.api import ExpressionPolicyViewSet +from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet +from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet +from authentik.policies.password.api import PasswordPolicyViewSet +from authentik.policies.reputation.api import ReputationPolicyViewSet +from authentik.providers.oauth2.api import OAuth2ProviderViewSet, ScopeMappingViewSet +from authentik.providers.proxy.api import ( + ProxyOutpostConfigViewSet, + ProxyProviderViewSet, +) +from authentik.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet +from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet +from authentik.sources.oauth.api import OAuthSourceViewSet +from authentik.sources.saml.api import SAMLSourceViewSet +from authentik.stages.captcha.api import CaptchaStageViewSet +from authentik.stages.consent.api import ConsentStageViewSet +from authentik.stages.dummy.api import DummyStageViewSet +from authentik.stages.email.api import EmailStageViewSet +from authentik.stages.identification.api import IdentificationStageViewSet +from authentik.stages.invitation.api import InvitationStageViewSet, InvitationViewSet +from authentik.stages.otp_static.api import OTPStaticStageViewSet +from authentik.stages.otp_time.api import OTPTimeStageViewSet +from authentik.stages.otp_validate.api import OTPValidateStageViewSet +from authentik.stages.password.api import PasswordStageViewSet +from authentik.stages.prompt.api import PromptStageViewSet, PromptViewSet +from authentik.stages.user_delete.api import UserDeleteStageViewSet +from authentik.stages.user_login.api import UserLoginStageViewSet +from authentik.stages.user_logout.api import UserLogoutStageViewSet +from authentik.stages.user_write.api import UserWriteStageViewSet + +router = routers.DefaultRouter() + +router.register("root/messages", MessagesViewSet, basename="messages") +router.register("root/config", ConfigsViewSet, basename="configs") + +router.register( + "admin/overview", AdministrationOverviewViewSet, basename="admin_overview" +) +router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics") +router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") + +router.register("core/applications", ApplicationViewSet) +router.register("core/groups", GroupViewSet) +router.register("core/users", UserViewSet) +router.register("core/tokens", TokenViewSet) + +router.register("outposts/outposts", OutpostViewSet) +router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) +router.register( + "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet +) +router.register("outposts/proxy", ProxyOutpostConfigViewSet) + +router.register("flows/instances", FlowViewSet) +router.register("flows/bindings", FlowStageBindingViewSet) + +router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet) + +router.register("audit/events", EventViewSet) + +router.register("sources/all", SourceViewSet) +router.register("sources/ldap", LDAPSourceViewSet) +router.register("sources/saml", SAMLSourceViewSet) +router.register("sources/oauth", OAuthSourceViewSet) + +router.register("policies/all", PolicyViewSet) +router.register("policies/bindings", PolicyBindingViewSet) +router.register("policies/expression", ExpressionPolicyViewSet) +router.register("policies/group_membership", GroupMembershipPolicyViewSet) +router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) +router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) +router.register("policies/password", PasswordPolicyViewSet) +router.register("policies/reputation", ReputationPolicyViewSet) + +router.register("providers/all", ProviderViewSet) +router.register("providers/proxy", ProxyProviderViewSet) +router.register("providers/oauth2", OAuth2ProviderViewSet) +router.register("providers/saml", SAMLProviderViewSet) + +router.register("propertymappings/all", PropertyMappingViewSet) +router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) +router.register("propertymappings/saml", SAMLPropertyMappingViewSet) +router.register("propertymappings/scope", ScopeMappingViewSet) + +router.register("stages/all", StageViewSet) +router.register("stages/captcha", CaptchaStageViewSet) +router.register("stages/consent", ConsentStageViewSet) +router.register("stages/email", EmailStageViewSet) +router.register("stages/identification", IdentificationStageViewSet) +router.register("stages/invitation", InvitationStageViewSet) +router.register("stages/invitation/invitations", InvitationViewSet) +router.register("stages/otp_static", OTPStaticStageViewSet) +router.register("stages/otp_time", OTPTimeStageViewSet) +router.register("stages/otp_validate", OTPValidateStageViewSet) +router.register("stages/password", PasswordStageViewSet) +router.register("stages/prompt/prompts", PromptViewSet) +router.register("stages/prompt/stages", PromptStageViewSet) +router.register("stages/user_delete", UserDeleteStageViewSet) +router.register("stages/user_login", UserLoginStageViewSet) +router.register("stages/user_logout", UserLogoutStageViewSet) +router.register("stages/user_write", UserWriteStageViewSet) + +router.register("stages/dummy", DummyStageViewSet) +router.register("policies/dummy", DummyPolicyViewSet) + +info = openapi.Info( + title="authentik API", + default_version="v2", + contact=openapi.Contact(email="hello@beryju.org"), + license=openapi.License(name="MIT License"), +) +SchemaView = get_schema_view( + info, + public=True, + permission_classes=(AllowAny,), +) + +urlpatterns = [ + re_path( + r"^swagger(?P\.json|\.yaml)$", + SchemaView.without_ui(cache_timeout=0), + name="schema-json", + ), + path( + "swagger/", + SchemaView.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path("redoc/", SchemaView.with_ui("redoc", cache_timeout=0), name="schema-redoc"), +] + router.urls diff --git a/passbook/audit/__init__.py b/authentik/audit/__init__.py similarity index 100% rename from passbook/audit/__init__.py rename to authentik/audit/__init__.py diff --git a/authentik/audit/api.py b/authentik/audit/api.py new file mode 100644 index 00000000..c2c16577 --- /dev/null +++ b/authentik/audit/api.py @@ -0,0 +1,70 @@ +"""Audit API Views""" +from django.db.models.aggregates import Count +from django.db.models.fields.json import KeyTextTransform +from drf_yasg2.utils import swagger_auto_schema +from rest_framework.decorators import action +from rest_framework.fields import DictField, IntegerField +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer, Serializer +from rest_framework.viewsets import ReadOnlyModelViewSet + +from authentik.audit.models import Event, EventAction + + +class EventSerializer(ModelSerializer): + """Event Serializer""" + + class Meta: + + model = Event + fields = [ + "pk", + "user", + "action", + "app", + "context", + "client_ip", + "created", + ] + + +class EventTopPerUserSerialier(Serializer): + """Response object of Event's top_per_user""" + + application = DictField() + counted_events = IntegerField() + unique_users = IntegerField() + + def create(self, request: Request) -> Response: + raise NotImplementedError + + def update(self, request: Request) -> Response: + raise NotImplementedError + + +class EventViewSet(ReadOnlyModelViewSet): + """Event Read-Only Viewset""" + + queryset = Event.objects.all() + serializer_class = EventSerializer + + @swagger_auto_schema( + method="GET", responses={200: EventTopPerUserSerialier(many=True)} + ) + @action(detail=False, methods=["GET"]) + def top_per_user(self, request: Request): + """Get the top_n events grouped by user count""" + filtered_action = request.query_params.get("filter_action", EventAction.LOGIN) + top_n = request.query_params.get("top_n", 15) + return Response( + Event.objects.filter(action=filtered_action) + .exclude(context__authorized_application=None) + .annotate(application=KeyTextTransform("authorized_application", "context")) + .annotate(user_pk=KeyTextTransform("pk", "user")) + .values("application") + .annotate(counted_events=Count("application")) + .annotate(unique_users=Count("user_pk", distinct=True)) + .values("unique_users", "application", "counted_events") + .order_by("-counted_events")[:top_n] + ) diff --git a/authentik/audit/apps.py b/authentik/audit/apps.py new file mode 100644 index 00000000..a88e8964 --- /dev/null +++ b/authentik/audit/apps.py @@ -0,0 +1,16 @@ +"""authentik audit app""" +from importlib import import_module + +from django.apps import AppConfig + + +class AuthentikAuditConfig(AppConfig): + """authentik audit app""" + + name = "authentik.audit" + label = "authentik_audit" + verbose_name = "authentik Audit" + mountpoint = "audit/" + + def ready(self): + import_module("authentik.audit.signals") diff --git a/authentik/audit/middleware.py b/authentik/audit/middleware.py new file mode 100644 index 00000000..7c192a56 --- /dev/null +++ b/authentik/audit/middleware.py @@ -0,0 +1,85 @@ +"""Audit middleware""" +from functools import partial +from typing import Callable + +from django.contrib.auth.models import User +from django.db.models import Model +from django.db.models.signals import post_save, pre_delete +from django.http import HttpRequest, HttpResponse + +from authentik.audit.models import Event, EventAction, model_to_dict +from authentik.audit.signals import EventNewThread +from authentik.core.middleware import LOCAL + + +class AuditMiddleware: + """Register handlers for duration of request-response that log creation/update/deletion + of models""" + + get_response: Callable[[HttpRequest], HttpResponse] + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + # Connect signal for automatic logging + if hasattr(request, "user") and getattr( + request.user, "is_authenticated", False + ): + post_save_handler = partial( + self.post_save_handler, user=request.user, request=request + ) + pre_delete_handler = partial( + self.pre_delete_handler, user=request.user, request=request + ) + post_save.connect( + post_save_handler, + dispatch_uid=LOCAL.authentik["request_id"], + weak=False, + ) + pre_delete.connect( + pre_delete_handler, + dispatch_uid=LOCAL.authentik["request_id"], + weak=False, + ) + + response = self.get_response(request) + + post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) + pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) + + return response + + # pylint: disable=unused-argument + def process_exception(self, request: HttpRequest, exception: Exception): + """Unregister handlers in case of exception""" + post_save.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) + pre_delete.disconnect(dispatch_uid=LOCAL.authentik["request_id"]) + + @staticmethod + # pylint: disable=unused-argument + def post_save_handler( + user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ + ): + """Signal handler for all object's post_save""" + if isinstance(instance, Event): + return + + action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED + EventNewThread(action, request, user=user, model=model_to_dict(instance)).run() + + @staticmethod + # pylint: disable=unused-argument + def pre_delete_handler( + user: User, request: HttpRequest, sender, instance: Model, **_ + ): + """Signal handler for all object's pre_delete""" + if isinstance(instance, Event): + return + + EventNewThread( + EventAction.MODEL_DELETED, + request, + user=user, + model=model_to_dict(instance), + ).run() diff --git a/passbook/audit/migrations/0001_initial.py b/authentik/audit/migrations/0001_initial.py similarity index 100% rename from passbook/audit/migrations/0001_initial.py rename to authentik/audit/migrations/0001_initial.py diff --git a/authentik/audit/migrations/0002_auto_20200918_2116.py b/authentik/audit/migrations/0002_auto_20200918_2116.py new file mode 100644 index 00000000..a6fcabf0 --- /dev/null +++ b/authentik/audit/migrations/0002_auto_20200918_2116.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.1 on 2020-09-18 21:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_audit", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("LOGIN", "login"), + ("LOGIN_FAILED", "login_failed"), + ("LOGOUT", "logout"), + ("AUTHORIZE_APPLICATION", "authorize_application"), + ("SUSPICIOUS_REQUEST", "suspicious_request"), + ("SIGN_UP", "sign_up"), + ("PASSWORD_RESET", "password_reset"), + ("INVITE_CREATED", "invitation_created"), + ("INVITE_USED", "invitation_used"), + ("IMPERSONATION_STARTED", "impersonation_started"), + ("IMPERSONATION_ENDED", "impersonation_ended"), + ("CUSTOM", "custom"), + ] + ), + ), + ] diff --git a/authentik/audit/migrations/0003_auto_20200917_1155.py b/authentik/audit/migrations/0003_auto_20200917_1155.py new file mode 100644 index 00000000..6163fe30 --- /dev/null +++ b/authentik/audit/migrations/0003_auto_20200917_1155.py @@ -0,0 +1,64 @@ +# Generated by Django 3.1.1 on 2020-09-17 11:55 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import authentik.audit.models + + +def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + Event = apps.get_model("authentik_audit", "Event") + + db_alias = schema_editor.connection.alias + for event in Event.objects.all(): + event.delete() + # Because event objects cannot be updated, we have to re-create them + event.pk = None + event.user_json = ( + authentik.audit.models.get_user(event.user) if event.user else {} + ) + event._state.adding = True + event.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_audit", "0002_auto_20200918_2116"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("LOGIN", "login"), + ("LOGIN_FAILED", "login_failed"), + ("LOGOUT", "logout"), + ("AUTHORIZE_APPLICATION", "authorize_application"), + ("SUSPICIOUS_REQUEST", "suspicious_request"), + ("SIGN_UP", "sign_up"), + ("PASSWORD_RESET", "password_reset"), + ("INVITE_CREATED", "invitation_created"), + ("INVITE_USED", "invitation_used"), + ("IMPERSONATION_STARTED", "impersonation_started"), + ("IMPERSONATION_ENDED", "impersonation_ended"), + ("CUSTOM", "custom"), + ] + ), + ), + migrations.AddField( + model_name="event", + name="user_json", + field=models.JSONField(default=dict), + ), + migrations.RunPython(convert_user_to_json), + migrations.RemoveField( + model_name="event", + name="user", + ), + migrations.RenameField( + model_name="event", old_name="user_json", new_name="user" + ), + ] diff --git a/authentik/audit/migrations/0004_auto_20200921_1829.py b/authentik/audit/migrations/0004_auto_20200921_1829.py new file mode 100644 index 00000000..df4f64ab --- /dev/null +++ b/authentik/audit/migrations/0004_auto_20200921_1829.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.1 on 2020-09-21 18:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_audit", "0003_auto_20200917_1155"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("sign_up", "Sign Up"), + ("authorize_application", "Authorize Application"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("invitation_created", "Invite Created"), + ("invitation_used", "Invite Used"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("custom_", "Custom Prefix"), + ] + ), + ), + ] diff --git a/authentik/audit/migrations/0005_auto_20201005_2139.py b/authentik/audit/migrations/0005_auto_20201005_2139.py new file mode 100644 index 00000000..3a288117 --- /dev/null +++ b/authentik/audit/migrations/0005_auto_20201005_2139.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.2 on 2020-10-05 21:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_audit", "0004_auto_20200921_1829"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("invitation_created", "Invite Created"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("custom_", "Custom Prefix"), + ] + ), + ), + ] diff --git a/authentik/audit/migrations/0006_auto_20201017_2024.py b/authentik/audit/migrations/0006_auto_20201017_2024.py new file mode 100644 index 00000000..ec242f6b --- /dev/null +++ b/authentik/audit/migrations/0006_auto_20201017_2024.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1.2 on 2020-10-17 20:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_audit", "0005_auto_20201005_2139"), + ] + + operations = [ + migrations.RemoveField( + model_name="event", + name="date", + ), + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("token_view", "Token View"), + ("invitation_created", "Invite Created"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("custom_", "Custom Prefix"), + ] + ), + ), + ] diff --git a/passbook/audit/migrations/__init__.py b/authentik/audit/migrations/__init__.py similarity index 100% rename from passbook/audit/migrations/__init__.py rename to authentik/audit/migrations/__init__.py diff --git a/authentik/audit/models.py b/authentik/audit/models.py new file mode 100644 index 00000000..e07a6b76 --- /dev/null +++ b/authentik/audit/models.py @@ -0,0 +1,199 @@ +"""authentik audit models""" +from inspect import getmodule, stack +from typing import Any, Dict, Optional, Union +from uuid import UUID, uuid4 + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models.base import Model +from django.http import HttpRequest +from django.utils.translation import gettext as _ +from django.views.debug import SafeExceptionReporterFilter +from guardian.utils import get_anonymous_user +from structlog import get_logger + +from authentik.core.middleware import ( + SESSION_IMPERSONATE_ORIGINAL_USER, + SESSION_IMPERSONATE_USER, +) +from authentik.core.models import User +from authentik.lib.utils.http import get_client_ip + +LOGGER = get_logger("authentik.audit") + + +def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: + """Cleanse a dictionary, recursively""" + final_dict = {} + for key, value in source.items(): + try: + if SafeExceptionReporterFilter.hidden_settings.search(key): + final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute + else: + final_dict[key] = value + except TypeError: + final_dict[key] = value + if isinstance(value, dict): + final_dict[key] = cleanse_dict(value) + return final_dict + + +def model_to_dict(model: Model) -> Dict[str, Any]: + """Convert model to dict""" + name = str(model) + if hasattr(model, "name"): + name = model.name + return { + "app": model._meta.app_label, + "model_name": model._meta.model_name, + "pk": model.pk, + "name": name, + } + + +def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]: + """Convert user object to dictionary, optionally including the original user""" + if isinstance(user, AnonymousUser): + user = get_anonymous_user() + user_data = { + "username": user.username, + "pk": user.pk, + "email": user.email, + } + if original_user: + original_data = get_user(original_user) + original_data["on_behalf_of"] = user_data + return original_data + return user_data + + +def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: + """clean source of all Models that would interfere with the JSONField. + Models are replaced with a dictionary of { + app: str, + name: str, + pk: Any + }""" + final_dict = {} + for key, value in source.items(): + if isinstance(value, dict): + final_dict[key] = sanitize_dict(value) + elif isinstance(value, models.Model): + final_dict[key] = sanitize_dict(model_to_dict(value)) + elif isinstance(value, UUID): + final_dict[key] = value.hex + else: + final_dict[key] = value + return final_dict + + +class EventAction(models.TextChoices): + """All possible actions to save into the audit log""" + + LOGIN = "login" + LOGIN_FAILED = "login_failed" + LOGOUT = "logout" + + USER_WRITE = "user_write" + SUSPICIOUS_REQUEST = "suspicious_request" + PASSWORD_SET = "password_set" # noqa # nosec + + TOKEN_VIEW = "token_view" + + INVITE_CREATED = "invitation_created" + INVITE_USED = "invitation_used" + + AUTHORIZE_APPLICATION = "authorize_application" + SOURCE_LINKED = "source_linked" + + IMPERSONATION_STARTED = "impersonation_started" + IMPERSONATION_ENDED = "impersonation_ended" + + MODEL_CREATED = "model_created" + MODEL_UPDATED = "model_updated" + MODEL_DELETED = "model_deleted" + + CUSTOM_PREFIX = "custom_" + + +class Event(models.Model): + """An individual audit log event""" + + event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + user = models.JSONField(default=dict) + action = models.TextField(choices=EventAction.choices) + app = models.TextField() + context = models.JSONField(default=dict, blank=True) + client_ip = models.GenericIPAddressField(null=True) + created = models.DateTimeField(auto_now_add=True) + + @staticmethod + def _get_app_from_request(request: HttpRequest) -> str: + if not isinstance(request, HttpRequest): + return "" + return request.resolver_match.app_name + + @staticmethod + def new( + action: Union[str, EventAction], + app: Optional[str] = None, + _inspect_offset: int = 1, + **kwargs, + ) -> "Event": + """Create new Event instance from arguments. Instance is NOT saved.""" + if not isinstance(action, EventAction): + action = EventAction.CUSTOM_PREFIX + action + if not app: + app = getmodule(stack()[_inspect_offset][0]).__name__ + cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs)) + event = Event(action=action, app=app, context=cleaned_kwargs) + return event + + def from_http( + self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None + ) -> "Event": + """Add data from a Django-HttpRequest, allowing the creation of + Events independently from requests. + `user` arguments optionally overrides user from requests.""" + if hasattr(request, "user"): + self.user = get_user( + request.user, + request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None), + ) + if user: + self.user = get_user(user) + # Check if we're currently impersonating, and add that user + if hasattr(request, "session"): + if SESSION_IMPERSONATE_ORIGINAL_USER in request.session: + self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER]) + self.user["on_behalf_of"] = get_user( + request.session[SESSION_IMPERSONATE_USER] + ) + # User 255.255.255.255 as fallback if IP cannot be determined + self.client_ip = get_client_ip(request) or "255.255.255.255" + # If there's no app set, we get it from the requests too + if not self.app: + self.app = Event._get_app_from_request(request) + self.save() + return self + + def save(self, *args, **kwargs): + if not self._state.adding: + raise ValidationError( + "you may not edit an existing %s" % self._meta.model_name + ) + LOGGER.debug( + "Created Audit event", + action=self.action, + context=self.context, + client_ip=self.client_ip, + user=self.user, + ) + return super().save(*args, **kwargs) + + class Meta: + + verbose_name = _("Audit Event") + verbose_name_plural = _("Audit Events") diff --git a/authentik/audit/signals.py b/authentik/audit/signals.py new file mode 100644 index 00000000..88d769a8 --- /dev/null +++ b/authentik/audit/signals.py @@ -0,0 +1,107 @@ +"""authentik audit signal listener""" +from threading import Thread +from typing import Any, Dict, Optional + +from django.contrib.auth.signals import ( + user_logged_in, + user_logged_out, + user_login_failed, +) +from django.dispatch import receiver +from django.http import HttpRequest + +from authentik.audit.models import Event, EventAction +from authentik.core.models import User +from authentik.core.signals import password_changed +from authentik.stages.invitation.models import Invitation +from authentik.stages.invitation.signals import invitation_created, invitation_used +from authentik.stages.user_write.signals import user_write + + +class EventNewThread(Thread): + """Create Event in background thread""" + + action: str + request: HttpRequest + kwargs: Dict[str, Any] + user: Optional[User] = None + + def __init__( + self, action: str, request: HttpRequest, user: Optional[User] = None, **kwargs + ): + super().__init__() + self.action = action + self.request = request + self.user = user + self.kwargs = kwargs + + def run(self): + Event.new(self.action, **self.kwargs).from_http(self.request, user=self.user) + + +@receiver(user_logged_in) +# pylint: disable=unused-argument +def on_user_logged_in(sender, request: HttpRequest, user: User, **_): + """Log successful login""" + thread = EventNewThread(EventAction.LOGIN, request) + thread.user = user + thread.run() + + +@receiver(user_logged_out) +# pylint: disable=unused-argument +def on_user_logged_out(sender, request: HttpRequest, user: User, **_): + """Log successfully logout""" + thread = EventNewThread(EventAction.LOGOUT, request) + thread.user = user + thread.run() + + +@receiver(user_write) +# pylint: disable=unused-argument +def on_user_write( + sender, request: HttpRequest, user: User, data: Dict[str, Any], **kwargs +): + """Log User write""" + thread = EventNewThread(EventAction.USER_WRITE, request, **data) + thread.kwargs["created"] = kwargs.get("created", False) + thread.user = user + thread.run() + + +@receiver(user_login_failed) +# pylint: disable=unused-argument +def on_user_login_failed( + sender, credentials: Dict[str, str], request: HttpRequest, **_ +): + """Failed Login""" + thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials) + thread.run() + + +@receiver(invitation_created) +# pylint: disable=unused-argument +def on_invitation_created(sender, request: HttpRequest, invitation: Invitation, **_): + """Log Invitation creation""" + thread = EventNewThread( + EventAction.INVITE_CREATED, request, invitation_uuid=invitation.invite_uuid.hex + ) + thread.run() + + +@receiver(invitation_used) +# pylint: disable=unused-argument +def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_): + """Log Invitation usage""" + thread = EventNewThread( + EventAction.INVITE_USED, request, invitation_uuid=invitation.invite_uuid.hex + ) + thread.run() + + +@receiver(password_changed) +# pylint: disable=unused-argument +def on_password_changed(sender, user: User, password: str, **_): + """Log password change""" + thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user) + thread.run() diff --git a/authentik/audit/templates/audit/list.html b/authentik/audit/templates/audit/list.html new file mode 100644 index 00000000..470f9f0f --- /dev/null +++ b/authentik/audit/templates/audit/list.html @@ -0,0 +1,90 @@ +{% extends "base/page.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block page_content %} +
+
+
+

+ + {% trans 'Audit Log' %} +

+
+
+
+
+
+
+ {% include 'partials/toolbar_search.html' %} + + {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for entry in object_list %} + + + + + + + + {% endfor %} + +
{% trans 'Action' %}{% trans 'Context' %}{% trans 'User' %}{% trans 'Creation Date' %}{% trans 'Client IP' %}
+
+
{{ entry.action }}
+ {{ entry.app|default:'-' }} +
+
+
+
+ {{ entry.context }} +
+ {% if entry.user.on_behalf_of %} + + {% blocktrans with username=entry.user.on_behalf_of.username %} + On behalf of {{ username }} + {% endblocktrans %} + + {% endif %} +
+
+
+
{{ entry.user.username }}
+ + {% blocktrans with pk=entry.user.pk %} + ID: {{ pk }} + {% endblocktrans %} + +
+
+ + {{ entry.created }} + + + + {{ entry.client_ip }} + +
+
+ {% include 'partials/pagination.html' %} +
+
+
+
+{% endblock %} diff --git a/passbook/audit/tests/__init__.py b/authentik/audit/tests/__init__.py similarity index 100% rename from passbook/audit/tests/__init__.py rename to authentik/audit/tests/__init__.py diff --git a/authentik/audit/tests/test_event.py b/authentik/audit/tests/test_event.py new file mode 100644 index 00000000..bf7a6b59 --- /dev/null +++ b/authentik/audit/tests/test_event.py @@ -0,0 +1,33 @@ +"""audit event tests""" + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.audit.models import Event +from authentik.policies.dummy.models import DummyPolicy + + +class TestAuditEvent(TestCase): + """Test Audit Event""" + + def test_new_with_model(self): + """Create a new Event passing a model as kwarg""" + event = Event.new("unittest", test={"model": get_anonymous_user()}) + event.save() # We save to ensure nothing is un-saveable + model_content_type = ContentType.objects.get_for_model(get_anonymous_user()) + self.assertEqual( + event.context.get("test").get("model").get("app"), + model_content_type.app_label, + ) + + def test_new_with_uuid_model(self): + """Create a new Event passing a model (with UUID PK) as kwarg""" + temp_model = DummyPolicy.objects.create(name="test", result=True) + event = Event.new("unittest", model=temp_model) + event.save() # We save to ensure nothing is un-saveable + model_content_type = ContentType.objects.get_for_model(temp_model) + self.assertEqual( + event.context.get("model").get("app"), model_content_type.app_label + ) + self.assertEqual(event.context.get("model").get("pk"), temp_model.pk.hex) diff --git a/authentik/audit/urls.py b/authentik/audit/urls.py new file mode 100644 index 00000000..13fd64df --- /dev/null +++ b/authentik/audit/urls.py @@ -0,0 +1,9 @@ +"""authentik audit urls""" +from django.urls import path + +from authentik.audit.views import EventListView + +urlpatterns = [ + # Audit Log + path("audit/", EventListView.as_view(), name="log"), +] diff --git a/authentik/audit/views.py b/authentik/audit/views.py new file mode 100644 index 00000000..c87d2fd6 --- /dev/null +++ b/authentik/audit/views.py @@ -0,0 +1,30 @@ +"""authentik Event administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import ListView +from guardian.mixins import PermissionListMixin + +from authentik.admin.views.utils import SearchListMixin, UserPaginateListMixin +from authentik.audit.models import Event + + +class EventListView( + PermissionListMixin, + LoginRequiredMixin, + SearchListMixin, + UserPaginateListMixin, + ListView, +): + """Show list of all invitations""" + + model = Event + template_name = "audit/list.html" + permission_required = "authentik_audit.view_event" + ordering = "-created" + + search_fields = [ + "user", + "action", + "app", + "context", + "client_ip", + ] diff --git a/passbook/core/__init__.py b/authentik/core/__init__.py similarity index 100% rename from passbook/core/__init__.py rename to authentik/core/__init__.py diff --git a/authentik/core/admin.py b/authentik/core/admin.py new file mode 100644 index 00000000..d30ece7e --- /dev/null +++ b/authentik/core/admin.py @@ -0,0 +1,24 @@ +"""authentik core admin""" + +from django.apps import AppConfig, apps +from django.contrib import admin +from django.contrib.admin.sites import AlreadyRegistered +from guardian.admin import GuardedModelAdmin +from structlog import get_logger + +LOGGER = get_logger() + + +def admin_autoregister(app: AppConfig): + """Automatically register all models from app""" + for model in app.get_models(): + try: + admin.site.register(model, GuardedModelAdmin) + except AlreadyRegistered: + pass + + +for _app in apps.get_app_configs(): + if _app.label.startswith("authentik_"): + LOGGER.debug("Registering application for dj-admin", application=_app.label) + admin_autoregister(_app) diff --git a/passbook/core/api/__init__.py b/authentik/core/api/__init__.py similarity index 100% rename from passbook/core/api/__init__.py rename to authentik/core/api/__init__.py diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py new file mode 100644 index 00000000..8dab6929 --- /dev/null +++ b/authentik/core/api/applications.py @@ -0,0 +1,81 @@ +"""Application API Views""" +from django.db.models import QuerySet +from rest_framework.decorators import action +from rest_framework.fields import SerializerMethodField +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet +from rest_framework_guardian.filters import ObjectPermissionsFilter + +from authentik.admin.api.overview_metrics import get_events_per_1h +from authentik.audit.models import EventAction +from authentik.core.models import Application +from authentik.policies.engine import PolicyEngine + + +class ApplicationSerializer(ModelSerializer): + """Application Serializer""" + + launch_url = SerializerMethodField() + + def get_launch_url(self, instance: Application) -> str: + """Get generated launch URL""" + return instance.get_launch_url() or "" + + class Meta: + + model = Application + fields = [ + "pk", + "name", + "slug", + "provider", + "launch_url", + "meta_launch_url", + "meta_icon", + "meta_description", + "meta_publisher", + "policies", + ] + + +class ApplicationViewSet(ModelViewSet): + """Application Viewset""" + + queryset = Application.objects.all() + serializer_class = ApplicationSerializer + lookup_field = "slug" + + def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: + """Custom filter_queryset method which ignores guardian, but still supports sorting""" + for backend in list(self.filter_backends): + if backend == ObjectPermissionsFilter: + continue + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset + + def list(self, request: Request) -> Response: + """Custom list method that checks Policy based access instead of guardian""" + queryset = self._filter_queryset_for_list(self.get_queryset()) + self.paginate_queryset(queryset) + allowed_applications = [] + for application in queryset.order_by("name"): + engine = PolicyEngine(application, self.request.user, self.request) + engine.build() + if engine.passing: + allowed_applications.append(application) + serializer = self.get_serializer(allowed_applications, many=True) + return self.get_paginated_response(serializer.data) + + @action(detail=True) + def metrics(self, request: Request, slug: str): + """Metrics for application logins""" + # TODO: Check app read and audit read perms + app = Application.objects.get(slug=slug) + return Response( + get_events_per_1h( + action=EventAction.AUTHORIZE_APPLICATION, + context__authorized_application__pk=app.pk.hex, + ) + ) diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py new file mode 100644 index 00000000..fa1b8953 --- /dev/null +++ b/authentik/core/api/groups.py @@ -0,0 +1,21 @@ +"""Groups API Viewset""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.core.models import Group + + +class GroupSerializer(ModelSerializer): + """Group Serializer""" + + class Meta: + + model = Group + fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] + + +class GroupViewSet(ModelViewSet): + """Group Viewset""" + + queryset = Group.objects.all() + serializer_class = GroupSerializer diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py new file mode 100644 index 00000000..3394f575 --- /dev/null +++ b/authentik/core/api/propertymappings.py @@ -0,0 +1,30 @@ +"""PropertyMapping API Views""" +from rest_framework.serializers import ModelSerializer, SerializerMethodField +from rest_framework.viewsets import ReadOnlyModelViewSet + +from authentik.core.models import PropertyMapping + + +class PropertyMappingSerializer(ModelSerializer): + """PropertyMapping Serializer""" + + __type__ = SerializerMethodField(method_name="get_type") + + def get_type(self, obj): + """Get object type so that we know which API Endpoint to use to get the full object""" + return obj._meta.object_name.lower().replace("propertymapping", "") + + class Meta: + + model = PropertyMapping + fields = ["pk", "name", "expression", "__type__"] + + +class PropertyMappingViewSet(ReadOnlyModelViewSet): + """PropertyMapping Viewset""" + + queryset = PropertyMapping.objects.all() + serializer_class = PropertyMappingSerializer + + def get_queryset(self): + return PropertyMapping.objects.select_subclasses() diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py new file mode 100644 index 00000000..e0e32b07 --- /dev/null +++ b/authentik/core/api/providers.py @@ -0,0 +1,30 @@ +"""Provider API Views""" +from rest_framework.serializers import ModelSerializer, SerializerMethodField +from rest_framework.viewsets import ReadOnlyModelViewSet + +from authentik.core.models import Provider + + +class ProviderSerializer(ModelSerializer): + """Provider Serializer""" + + __type__ = SerializerMethodField(method_name="get_type") + + def get_type(self, obj): + """Get object type so that we know which API Endpoint to use to get the full object""" + return obj._meta.object_name.lower().replace("provider", "") + + class Meta: + + model = Provider + fields = ["pk", "name", "authorization_flow", "property_mappings", "__type__"] + + +class ProviderViewSet(ReadOnlyModelViewSet): + """Provider Viewset""" + + queryset = Provider.objects.all() + serializer_class = ProviderSerializer + + def get_queryset(self): + return Provider.objects.select_subclasses() diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py new file mode 100644 index 00000000..e19acf27 --- /dev/null +++ b/authentik/core/api/sources.py @@ -0,0 +1,31 @@ +"""Source API Views""" +from rest_framework.serializers import ModelSerializer, SerializerMethodField +from rest_framework.viewsets import ReadOnlyModelViewSet + +from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS +from authentik.core.models import Source + + +class SourceSerializer(ModelSerializer): + """Source Serializer""" + + __type__ = SerializerMethodField(method_name="get_type") + + def get_type(self, obj): + """Get object type so that we know which API Endpoint to use to get the full object""" + return obj._meta.object_name.lower().replace("source", "") + + class Meta: + + model = Source + fields = SOURCE_SERIALIZER_FIELDS + ["__type__"] + + +class SourceViewSet(ReadOnlyModelViewSet): + """Source Viewset""" + + queryset = Source.objects.all() + serializer_class = SourceSerializer + + def get_queryset(self): + return Source.objects.select_subclasses() diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py new file mode 100644 index 00000000..bdaedf91 --- /dev/null +++ b/authentik/core/api/tokens.py @@ -0,0 +1,37 @@ +"""Tokens API Viewset""" +from django.http.response import Http404 +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.audit.models import Event, EventAction +from authentik.core.models import Token + + +class TokenSerializer(ModelSerializer): + """Token Serializer""" + + class Meta: + + model = Token + fields = ["pk", "identifier", "intent", "user", "description"] + + +class TokenViewSet(ModelViewSet): + """Token Viewset""" + + lookup_field = "identifier" + queryset = Token.filter_not_expired() + serializer_class = TokenSerializer + + @action(detail=True) + def view_key(self, request: Request, identifier: str) -> Response: + """Return token key and log access""" + tokens = Token.filter_not_expired(identifier=identifier) + if not tokens.exists(): + raise Http404 + token = tokens.first() + Event.new(EventAction.TOKEN_VIEW, token=token).from_http(request) + return Response({"key": token.key}) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py new file mode 100644 index 00000000..b4fb8554 --- /dev/null +++ b/authentik/core/api/users.py @@ -0,0 +1,44 @@ +"""User API Views""" +from drf_yasg2.utils import swagger_auto_schema +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ( + BooleanField, + ModelSerializer, + SerializerMethodField, +) +from rest_framework.viewsets import ModelViewSet + +from authentik.core.models import User +from authentik.lib.templatetags.authentik_utils import avatar + + +class UserSerializer(ModelSerializer): + """User Serializer""" + + is_superuser = BooleanField(read_only=True) + avatar = SerializerMethodField() + + def get_avatar(self, user: User) -> str: + """Add user's avatar as URL""" + return avatar(user) + + class Meta: + + model = User + fields = ["pk", "username", "name", "is_superuser", "email", "avatar"] + + +class UserViewSet(ModelViewSet): + """User Viewset""" + + queryset = User.objects.all() + serializer_class = UserSerializer + + @swagger_auto_schema(responses={200: UserSerializer(many=False)}) + @action(detail=False) + # pylint: disable=invalid-name + def me(self, request: Request) -> Response: + """Get information about current user""" + return Response(UserSerializer(request.user).data) diff --git a/authentik/core/apps.py b/authentik/core/apps.py new file mode 100644 index 00000000..395737f3 --- /dev/null +++ b/authentik/core/apps.py @@ -0,0 +1,11 @@ +"""authentik core app config""" +from django.apps import AppConfig + + +class AuthentikCoreConfig(AppConfig): + """authentik core app config""" + + name = "authentik.core" + label = "authentik_core" + verbose_name = "authentik Core" + mountpoint = "" diff --git a/authentik/core/channels.py b/authentik/core/channels.py new file mode 100644 index 00000000..31be6ffd --- /dev/null +++ b/authentik/core/channels.py @@ -0,0 +1,32 @@ +"""Channels base classes""" +from channels.generic.websocket import JsonWebsocketConsumer +from structlog import get_logger + +from authentik.api.auth import token_from_header +from authentik.core.models import User + +LOGGER = get_logger() + + +class AuthJsonConsumer(JsonWebsocketConsumer): + """Authorize a client with a token""" + + user: User + + def connect(self): + headers = dict(self.scope["headers"]) + if b"authorization" not in headers: + LOGGER.warning("WS Request without authorization header") + self.close() + return False + + raw_header = headers[b"authorization"] + + token = token_from_header(raw_header) + if not token: + LOGGER.warning("Failed to authenticate") + self.close() + return False + + self.user = token.user + return True diff --git a/authentik/core/exceptions.py b/authentik/core/exceptions.py new file mode 100644 index 00000000..7b157fc2 --- /dev/null +++ b/authentik/core/exceptions.py @@ -0,0 +1,6 @@ +"""authentik core exceptions""" +from authentik.lib.sentry import SentryIgnoredException + + +class PropertyMappingExpressionException(SentryIgnoredException): + """Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" diff --git a/authentik/core/expression.py b/authentik/core/expression.py new file mode 100644 index 00000000..534ba477 --- /dev/null +++ b/authentik/core/expression.py @@ -0,0 +1,21 @@ +"""Property Mapping Evaluator""" +from typing import Optional + +from django.http import HttpRequest + +from authentik.core.models import User +from authentik.lib.expression.evaluator import BaseEvaluator + + +class PropertyMappingEvaluator(BaseEvaluator): + """Custom Evalautor that adds some different context variables.""" + + def set_context( + self, user: Optional[User], request: Optional[HttpRequest], **kwargs + ): + """Update context with context from PropertyMapping's evaluate""" + if user: + self._context["user"] = user + if request: + self._context["request"] = request + self._context.update(**kwargs) diff --git a/passbook/core/forms/__init__.py b/authentik/core/forms/__init__.py similarity index 100% rename from passbook/core/forms/__init__.py rename to authentik/core/forms/__init__.py diff --git a/authentik/core/forms/applications.py b/authentik/core/forms/applications.py new file mode 100644 index 00000000..db31e20a --- /dev/null +++ b/authentik/core/forms/applications.py @@ -0,0 +1,50 @@ +"""authentik Core Application forms""" +from django import forms +from django.utils.translation import gettext_lazy as _ + +from authentik.core.models import Application, Provider +from authentik.lib.widgets import GroupedModelChoiceField + + +class ApplicationForm(forms.ModelForm): + """Application Form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["provider"].queryset = ( + Provider.objects.all().order_by("pk").select_subclasses() + ) + + class Meta: + + model = Application + fields = [ + "name", + "slug", + "provider", + "meta_launch_url", + "meta_icon", + "meta_description", + "meta_publisher", + ] + widgets = { + "name": forms.TextInput(), + "meta_launch_url": forms.TextInput(), + "meta_publisher": forms.TextInput(), + "meta_icon": forms.FileInput(), + } + help_texts = { + "meta_launch_url": _( + ( + "If left empty, authentik will try to extract the launch URL " + "based on the selected provider." + ) + ), + } + field_classes = {"provider": GroupedModelChoiceField} + labels = { + "meta_launch_url": _("Launch URL"), + "meta_icon": _("Icon"), + "meta_description": _("Description"), + "meta_publisher": _("Publisher"), + } diff --git a/authentik/core/forms/groups.py b/authentik/core/forms/groups.py new file mode 100644 index 00000000..8d10f1ea --- /dev/null +++ b/authentik/core/forms/groups.py @@ -0,0 +1,38 @@ +"""authentik Core Group forms""" +from django import forms + +from authentik.admin.fields import CodeMirrorWidget, YAMLField +from authentik.core.models import Group, User + + +class GroupForm(forms.ModelForm): + """Group Form""" + + members = forms.ModelMultipleChoiceField( + User.objects.all(), + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.initial["members"] = self.instance.users.values_list("pk", flat=True) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + if instance.pk: + instance.users.clear() + instance.users.add(*self.cleaned_data["members"]) + return instance + + class Meta: + + model = Group + fields = ["name", "is_superuser", "parent", "members", "attributes"] + widgets = { + "name": forms.TextInput(), + "attributes": CodeMirrorWidget, + } + field_classes = { + "attributes": YAMLField, + } diff --git a/authentik/core/forms/token.py b/authentik/core/forms/token.py new file mode 100644 index 00000000..9bc43aa8 --- /dev/null +++ b/authentik/core/forms/token.py @@ -0,0 +1,22 @@ +"""Core user token form""" +from django import forms + +from authentik.core.models import Token + + +class UserTokenForm(forms.ModelForm): + """Token form, for tokens created by endusers""" + + class Meta: + + model = Token + fields = [ + "identifier", + "expires", + "expiring", + "description", + ] + widgets = { + "identifier": forms.TextInput(), + "description": forms.TextInput(), + } diff --git a/authentik/core/forms/users.py b/authentik/core/forms/users.py new file mode 100644 index 00000000..36b5e33c --- /dev/null +++ b/authentik/core/forms/users.py @@ -0,0 +1,15 @@ +"""authentik core user forms""" + +from django import forms + +from authentik.core.models import User + + +class UserDetailForm(forms.ModelForm): + """Update User Details""" + + class Meta: + + model = User + fields = ["username", "name", "email"] + widgets = {"name": forms.TextInput} diff --git a/authentik/core/middleware.py b/authentik/core/middleware.py new file mode 100644 index 00000000..9b43485e --- /dev/null +++ b/authentik/core/middleware.py @@ -0,0 +1,56 @@ +"""authentik admin Middleware to impersonate users""" +from logging import Logger +from threading import local +from typing import Callable +from uuid import uuid4 + +from django.http import HttpRequest, HttpResponse + +SESSION_IMPERSONATE_USER = "authentik_impersonate_user" +SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user" +LOCAL = local() + + +class ImpersonateMiddleware: + """Middleware to impersonate users""" + + get_response: Callable[[HttpRequest], HttpResponse] + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + # No permission checks are done here, they need to be checked before + # SESSION_IMPERSONATE_USER is set. + + if SESSION_IMPERSONATE_USER in request.session: + request.user = request.session[SESSION_IMPERSONATE_USER] + + return self.get_response(request) + + +class RequestIDMiddleware: + """Add a unique ID to every request""" + + get_response: Callable[[HttpRequest], HttpResponse] + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + if not hasattr(request, "request_id"): + request_id = uuid4().hex + setattr(request, "request_id", request_id) + LOCAL.authentik = {"request_id": request_id} + response = self.get_response(request) + response["X-authentik-id"] = request.request_id + del LOCAL.authentik["request_id"] + return response + + +# pylint: disable=unused-argument +def structlog_add_request_id(logger: Logger, method_name: str, event_dict): + """If threadlocal has authentik defined, add request_id to log""" + if hasattr(LOCAL, "authentik"): + event_dict["request_id"] = LOCAL.authentik.get("request_id", "") + return event_dict diff --git a/authentik/core/migrations/0001_initial.py b/authentik/core/migrations/0001_initial.py new file mode 100644 index 00000000..e79bbbfd --- /dev/null +++ b/authentik/core/migrations/0001_initial.py @@ -0,0 +1,356 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:07 + +import uuid + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import guardian.mixins +from django.conf import settings +from django.db import migrations, models + +import authentik.core.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0001_initial"), + ("auth", "0011_update_proxy_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False)), + ("name", models.TextField(help_text="User's display name.")), + ("password_change_date", models.DateTimeField(auto_now_add=True)), + ( + "attributes", + models.JSONField(blank=True, default=dict), + ), + ], + options={ + "permissions": (("reset_user_password", "Reset Password"),), + }, + bases=(guardian.mixins.GuardianUserMixin, models.Model), + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="PropertyMapping", + fields=[ + ( + "pm_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField()), + ("expression", models.TextField()), + ], + options={ + "verbose_name": "Property Mapping", + "verbose_name_plural": "Property Mappings", + }, + ), + migrations.CreateModel( + name="Source", + fields=[ + ( + "policybindingmodel_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.PolicyBindingModel", + ), + ), + ("name", models.TextField(help_text="Source's display Name.")), + ( + "slug", + models.SlugField(help_text="Internal source name, used in URLs."), + ), + ("enabled", models.BooleanField(default=True)), + ( + "property_mappings", + models.ManyToManyField( + blank=True, default=None, to="authentik_core.PropertyMapping" + ), + ), + ], + bases=("authentik_policies.policybindingmodel",), + ), + migrations.CreateModel( + name="UserSourceConnection", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_core.Source", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "source")}, + }, + ), + migrations.CreateModel( + name="Token", + fields=[ + ( + "token_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "expires", + models.DateTimeField( + default=authentik.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ("description", models.TextField(blank=True, default="")), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Token", + "verbose_name_plural": "Tokens", + }, + ), + migrations.CreateModel( + name="Provider", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "property_mappings", + models.ManyToManyField( + blank=True, default=None, to="authentik_core.PropertyMapping" + ), + ), + ], + ), + migrations.CreateModel( + name="Group", + fields=[ + ( + "group_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=80, verbose_name="name")), + ( + "attributes", + models.JSONField(blank=True, default=dict), + ), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children", + to="authentik_core.Group", + ), + ), + ], + options={ + "unique_together": {("name", "parent")}, + }, + ), + migrations.CreateModel( + name="Application", + fields=[ + ( + "policybindingmodel_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.PolicyBindingModel", + ), + ), + ("name", models.TextField(help_text="Application's display Name.")), + ( + "slug", + models.SlugField( + help_text="Internal application name, used in URLs." + ), + ), + ("skip_authorization", models.BooleanField(default=False)), + ("meta_launch_url", models.URLField(blank=True, default="")), + ("meta_icon_url", models.TextField(blank=True, default="")), + ("meta_description", models.TextField(blank=True, default="")), + ("meta_publisher", models.TextField(blank=True, default="")), + ( + "provider", + models.OneToOneField( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_core.Provider", + ), + ), + ], + bases=("authentik_policies.policybindingmodel",), + ), + migrations.AddField( + model_name="user", + name="groups", + field=models.ManyToManyField(to="authentik_core.Group"), + ), + migrations.AddField( + model_name="user", + name="sources", + field=models.ManyToManyField( + through="authentik_core.UserSourceConnection", + to="authentik_core.Source", + ), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ] diff --git a/authentik/core/migrations/0002_auto_20200523_1133.py b/authentik/core/migrations/0002_auto_20200523_1133.py new file mode 100644 index 00000000..ecc0717f --- /dev/null +++ b/authentik/core/migrations/0002_auto_20200523_1133.py @@ -0,0 +1,55 @@ +# Generated by Django 3.0.6 on 2020-05-23 11:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0003_auto_20200523_1133"), + ("authentik_core", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="application", + name="skip_authorization", + ), + migrations.AddField( + model_name="source", + name="authentication_flow", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Flow to use when authenticating existing users.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="source_authentication", + to="authentik_flows.Flow", + ), + ), + migrations.AddField( + model_name="source", + name="enrollment_flow", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Flow to use when enrolling new users.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="source_enrollment", + to="authentik_flows.Flow", + ), + ), + migrations.AddField( + model_name="provider", + name="authorization_flow", + field=models.ForeignKey( + help_text="Flow used when authorizing this provider.", + on_delete=django.db.models.deletion.CASCADE, + related_name="provider_authorization", + to="authentik_flows.Flow", + ), + ), + ] diff --git a/authentik/core/migrations/0003_default_user.py b/authentik/core/migrations/0003_default_user.py new file mode 100644 index 00000000..ffa3eee8 --- /dev/null +++ b/authentik/core/migrations/0003_default_user.py @@ -0,0 +1,45 @@ +# Generated by Django 3.0.6 on 2020-05-23 16:40 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + # We have to use a direct import here, otherwise we get an object manager error + from authentik.core.models import User + + db_alias = schema_editor.connection.alias + + akadmin, _ = User.objects.using(db_alias).get_or_create( + username="akadmin", email="root@localhost", name="authentik Default Admin" + ) + akadmin.set_password("akadmin", signal=False) # noqa # nosec + akadmin.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0002_auto_20200523_1133"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="is_superuser", + ), + migrations.RemoveField( + model_name="user", + name="is_staff", + ), + migrations.RunPython(create_default_user), + migrations.AddField( + model_name="user", + name="is_superuser", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", name="is_staff", field=models.BooleanField(default=False) + ), + ] diff --git a/authentik/core/migrations/0004_auto_20200703_2213.py b/authentik/core/migrations/0004_auto_20200703_2213.py new file mode 100644 index 00000000..e3e98bea --- /dev/null +++ b/authentik/core/migrations/0004_auto_20200703_2213.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.7 on 2020-07-03 22:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0003_default_user"), + ] + + operations = [ + migrations.AlterModelOptions( + name="application", + options={ + "verbose_name": "Application", + "verbose_name_plural": "Applications", + }, + ), + migrations.AlterModelOptions( + name="user", + options={ + "permissions": (("reset_user_password", "Reset Password"),), + "verbose_name": "User", + "verbose_name_plural": "Users", + }, + ), + ] diff --git a/authentik/core/migrations/0005_token_intent.py b/authentik/core/migrations/0005_token_intent.py new file mode 100644 index 00000000..b7790106 --- /dev/null +++ b/authentik/core/migrations/0005_token_intent.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.7 on 2020-07-05 21:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0004_auto_20200703_2213"), + ] + + operations = [ + migrations.AddField( + model_name="token", + name="intent", + field=models.TextField( + choices=[ + ("verification", "Intent Verification"), + ("api", "Intent Api"), + ], + default="verification", + ), + ), + ] diff --git a/authentik/core/migrations/0006_auto_20200709_1608.py b/authentik/core/migrations/0006_auto_20200709_1608.py new file mode 100644 index 00000000..2dec9372 --- /dev/null +++ b/authentik/core/migrations/0006_auto_20200709_1608.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.8 on 2020-07-09 16:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0005_token_intent"), + ] + + operations = [ + migrations.AlterField( + model_name="source", + name="slug", + field=models.SlugField( + help_text="Internal source name, used in URLs.", unique=True + ), + ), + ] diff --git a/authentik/core/migrations/0007_auto_20200815_1841.py b/authentik/core/migrations/0007_auto_20200815_1841.py new file mode 100644 index 00000000..51fe03d1 --- /dev/null +++ b/authentik/core/migrations/0007_auto_20200815_1841.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-08-15 18:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0006_auto_20200709_1608"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="first_name", + field=models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ] diff --git a/authentik/core/migrations/0008_auto_20200824_1532.py b/authentik/core/migrations/0008_auto_20200824_1532.py new file mode 100644 index 00000000..13ba0d3d --- /dev/null +++ b/authentik/core/migrations/0008_auto_20200824_1532.py @@ -0,0 +1,36 @@ +# Generated by Django 3.1 on 2020-08-24 15:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("authentik_core", "0007_auto_20200815_1841"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="groups", + field=models.ManyToManyField(to="authentik_core.Group"), + ), + migrations.AddField( + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + migrations.AddField( + model_name="user", + name="pb_groups", + field=models.ManyToManyField(to="authentik_core.Group"), + ), + ] diff --git a/authentik/core/migrations/0009_group_is_superuser.py b/authentik/core/migrations/0009_group_is_superuser.py new file mode 100644 index 00000000..37133587 --- /dev/null +++ b/authentik/core/migrations/0009_group_is_superuser.py @@ -0,0 +1,61 @@ +# Generated by Django 3.1.1 on 2020-09-15 19:53 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import authentik.core.models + + +def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + Group = apps.get_model("authentik_core", "Group") + User = apps.get_model("authentik_core", "User") + + # Creates a default admin group + group, _ = Group.objects.using(db_alias).get_or_create( + is_superuser=True, + defaults={ + "name": "authentik Admins", + }, + ) + group.users.set(User.objects.filter(username="akadmin")) + group.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0008_auto_20200824_1532"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="is_superuser", + ), + migrations.RemoveField( + model_name="user", + name="is_staff", + ), + migrations.AlterField( + model_name="user", + name="pb_groups", + field=models.ManyToManyField( + related_name="users", to="authentik_core.Group" + ), + ), + migrations.AddField( + model_name="group", + name="is_superuser", + field=models.BooleanField( + default=False, help_text="Users added to this group will be superusers." + ), + ), + migrations.RunPython(create_default_admin_group), + migrations.AlterModelManagers( + name="user", + managers=[ + ("objects", authentik.core.models.UserManager()), + ], + ), + ] diff --git a/authentik/core/migrations/0010_auto_20200917_1021.py b/authentik/core/migrations/0010_auto_20200917_1021.py new file mode 100644 index 00000000..d9e670da --- /dev/null +++ b/authentik/core/migrations/0010_auto_20200917_1021.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.1 on 2020-09-17 10:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0009_group_is_superuser"), + ] + + operations = [ + migrations.AlterModelOptions( + name="user", + options={ + "permissions": ( + ("reset_user_password", "Reset Password"), + ("impersonate", "Can impersonate other users"), + ), + "verbose_name": "User", + "verbose_name_plural": "Users", + }, + ), + ] diff --git a/authentik/core/migrations/0011_provider_name_temp.py b/authentik/core/migrations/0011_provider_name_temp.py new file mode 100644 index 00000000..9b38c50d --- /dev/null +++ b/authentik/core/migrations/0011_provider_name_temp.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.2 on 2020-10-03 17:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0010_auto_20200917_1021"), + ] + + operations = [ + migrations.AddField( + model_name="provider", + name="name_temp", + field=models.TextField(default=""), + preserve_default=False, + ), + ] diff --git a/authentik/core/migrations/0012_auto_20201003_1737.py b/authentik/core/migrations/0012_auto_20201003_1737.py new file mode 100644 index 00000000..8ec00aa2 --- /dev/null +++ b/authentik/core/migrations/0012_auto_20201003_1737.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.2 on 2020-10-03 17:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0011_provider_name_temp"), + ("authentik_providers_oauth2", "0006_remove_oauth2provider_name"), + ("authentik_providers_saml", "0006_remove_samlprovider_name"), + ] + + operations = [ + migrations.RenameField( + model_name="provider", + old_name="name_temp", + new_name="name", + ), + ] diff --git a/authentik/core/migrations/0013_auto_20201003_2132.py b/authentik/core/migrations/0013_auto_20201003_2132.py new file mode 100644 index 00000000..9ed9b362 --- /dev/null +++ b/authentik/core/migrations/0013_auto_20201003_2132.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1.2 on 2020-10-03 21:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0012_auto_20201003_1737"), + ] + + operations = [ + migrations.AddField( + model_name="token", + name="identifier", + field=models.TextField(default=""), + preserve_default=False, + ), + migrations.AlterField( + model_name="token", + name="intent", + field=models.TextField( + choices=[ + ("verification", "Intent Verification"), + ("api", "Intent Api"), + ("recovery", "Intent Recovery"), + ], + default="verification", + ), + ), + migrations.AlterUniqueTogether( + name="token", + unique_together={("identifier", "user")}, + ), + ] diff --git a/authentik/core/migrations/0014_auto_20201018_1158.py b/authentik/core/migrations/0014_auto_20201018_1158.py new file mode 100644 index 00000000..0f3f9dc9 --- /dev/null +++ b/authentik/core/migrations/0014_auto_20201018_1158.py @@ -0,0 +1,50 @@ +# Generated by Django 3.1.2 on 2020-10-18 11:58 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import authentik.core.models + + +def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + Token = apps.get_model("authentik_core", "Token") + + for token in Token.objects.using(db_alias).all(): + token.key = token.pk.hex + token.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0013_auto_20201003_2132"), + ] + + operations = [ + migrations.AddField( + model_name="token", + name="key", + field=models.TextField(default=authentik.core.models.default_token_key), + ), + migrations.AlterUniqueTogether( + name="token", + unique_together=set(), + ), + migrations.AlterField( + model_name="token", + name="identifier", + field=models.SlugField(max_length=255), + ), + migrations.AddIndex( + model_name="token", + index=models.Index(fields=["key"], name="authentik_co_key_e45007_idx"), + ), + migrations.AddIndex( + model_name="token", + index=models.Index( + fields=["identifier"], name="authentik_co_identif_1a34a8_idx" + ), + ), + migrations.RunPython(set_default_token_key), + ] diff --git a/authentik/core/migrations/0015_application_icon.py b/authentik/core/migrations/0015_application_icon.py new file mode 100644 index 00000000..4ea6ac2c --- /dev/null +++ b/authentik/core/migrations/0015_application_icon.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.3 on 2020-11-23 17:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0014_auto_20201018_1158"), + ] + + operations = [ + migrations.RemoveField( + model_name="application", + name="meta_icon_url", + ), + migrations.AddField( + model_name="application", + name="meta_icon", + field=models.FileField( + blank=True, default="", upload_to="application-icons/" + ), + ), + ] diff --git a/authentik/core/migrations/0016_auto_20201202_2234.py b/authentik/core/migrations/0016_auto_20201202_2234.py new file mode 100644 index 00000000..e03ab30e --- /dev/null +++ b/authentik/core/migrations/0016_auto_20201202_2234.py @@ -0,0 +1,36 @@ +# Generated by Django 3.1.3 on 2020-12-02 22:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0015_application_icon"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="token", + name="authentik_co_key_e45007_idx", + ), + migrations.RemoveIndex( + model_name="token", + name="authentik_co_identif_1a34a8_idx", + ), + migrations.RenameField( + model_name="user", + old_name="pb_groups", + new_name="ak_groups", + ), + migrations.AddIndex( + model_name="token", + index=models.Index( + fields=["identifier"], name="authentik_c_identif_d9d032_idx" + ), + ), + migrations.AddIndex( + model_name="token", + index=models.Index(fields=["key"], name="authentik_c_key_f71355_idx"), + ), + ] diff --git a/passbook/core/migrations/__init__.py b/authentik/core/migrations/__init__.py similarity index 100% rename from passbook/core/migrations/__init__.py rename to authentik/core/migrations/__init__.py diff --git a/authentik/core/models.py b/authentik/core/models.py new file mode 100644 index 00000000..b55acac5 --- /dev/null +++ b/authentik/core/models.py @@ -0,0 +1,371 @@ +"""authentik core models""" +from datetime import timedelta +from typing import Any, Dict, Optional, Type +from uuid import uuid4 + +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import UserManager as DjangoUserManager +from django.db import models +from django.db.models import Q, QuerySet +from django.forms import ModelForm +from django.http import HttpRequest +from django.utils.functional import cached_property +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from guardian.mixins import GuardianUserMixin +from model_utils.managers import InheritanceManager +from structlog import get_logger + +from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.signals import password_changed +from authentik.core.types import UILoginButton +from authentik.flows.models import Flow +from authentik.lib.models import CreatedUpdatedModel +from authentik.policies.models import PolicyBindingModel + +LOGGER = get_logger() +USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" +USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" + + +def default_token_duration(): + """Default duration a Token is valid""" + return now() + timedelta(minutes=30) + + +def default_token_key(): + """Default token key""" + return uuid4().hex + + +class Group(models.Model): + """Custom Group model which supports a basic hierarchy""" + + group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + name = models.CharField(_("name"), max_length=80) + is_superuser = models.BooleanField( + default=False, help_text=_("Users added to this group will be superusers.") + ) + + parent = models.ForeignKey( + "Group", + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="children", + ) + attributes = models.JSONField(default=dict, blank=True) + + def __str__(self): + return f"Group {self.name}" + + class Meta: + + unique_together = ( + ( + "name", + "parent", + ), + ) + + +class UserManager(DjangoUserManager): + """Custom user manager that doesn't assign is_superuser and is_staff""" + + def create_user(self, username, email=None, password=None, **extra_fields): + """Custom user manager that doesn't assign is_superuser and is_staff""" + return self._create_user(username, email, password, **extra_fields) + + +class User(GuardianUserMixin, AbstractUser): + """Custom User model to allow easier adding o f user-based settings""" + + uuid = models.UUIDField(default=uuid4, editable=False) + name = models.TextField(help_text=_("User's display name.")) + + sources = models.ManyToManyField("Source", through="UserSourceConnection") + ak_groups = models.ManyToManyField("Group", related_name="users") + password_change_date = models.DateTimeField(auto_now_add=True) + + attributes = models.JSONField(default=dict, blank=True) + + objects = UserManager() + + def group_attributes(self) -> Dict[str, Any]: + """Get a dictionary containing the attributes from all groups the user belongs to, + including the users attributes""" + final_attributes = {} + for group in self.ak_groups.all().order_by("name"): + final_attributes.update(group.attributes) + final_attributes.update(self.attributes) + return final_attributes + + @cached_property + def is_superuser(self) -> bool: + """Get supseruser status based on membership in a group with superuser status""" + return self.ak_groups.filter(is_superuser=True).exists() + + @property + def is_staff(self) -> bool: + """superuser == staff user""" + return self.is_superuser # type: ignore + + def set_password(self, password, signal=True): + if self.pk and signal: + password_changed.send(sender=self, user=self, password=password) + self.password_change_date = now() + return super().set_password(password) + + class Meta: + + permissions = ( + ("reset_user_password", "Reset Password"), + ("impersonate", "Can impersonate other users"), + ) + verbose_name = _("User") + verbose_name_plural = _("Users") + + +class Provider(models.Model): + """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" + + name = models.TextField() + + authorization_flow = models.ForeignKey( + Flow, + on_delete=models.CASCADE, + help_text=_("Flow used when authorizing this provider."), + related_name="provider_authorization", + ) + + property_mappings = models.ManyToManyField( + "PropertyMapping", default=None, blank=True + ) + + objects = InheritanceManager() + + @property + def launch_url(self) -> Optional[str]: + """URL to this provider and initiate authorization for the user. + Can return None for providers that are not URL-based""" + return None + + @property + def form(self) -> Type[ModelForm]: + """Return Form class used to edit this object""" + raise NotImplementedError + + def __str__(self): + return self.name + + +class Application(PolicyBindingModel): + """Every Application which uses authentik for authentication/identification/authorization + needs an Application record. Other authentication types can subclass this Model to + add custom fields and other properties""" + + name = models.TextField(help_text=_("Application's display Name.")) + slug = models.SlugField(help_text=_("Internal application name, used in URLs.")) + provider = models.OneToOneField( + "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT + ) + + meta_launch_url = models.URLField(default="", blank=True) + # For template applications, this can be set to /static/authentik/applications/* + meta_icon = models.FileField(upload_to="application-icons/", default="", blank=True) + meta_description = models.TextField(default="", blank=True) + meta_publisher = models.TextField(default="", blank=True) + + def get_launch_url(self) -> Optional[str]: + """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" + if self.meta_launch_url: + return self.meta_launch_url + if self.provider: + return self.get_provider().launch_url + return None + + def get_provider(self) -> Optional[Provider]: + """Get casted provider instance""" + if not self.provider: + return None + return Provider.objects.get_subclass(pk=self.provider.pk) + + def __str__(self): + return self.name + + class Meta: + + verbose_name = _("Application") + verbose_name_plural = _("Applications") + + +class Source(PolicyBindingModel): + """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" + + name = models.TextField(help_text=_("Source's display Name.")) + slug = models.SlugField( + help_text=_("Internal source name, used in URLs."), unique=True + ) + + enabled = models.BooleanField(default=True) + property_mappings = models.ManyToManyField( + "PropertyMapping", default=None, blank=True + ) + + authentication_flow = models.ForeignKey( + Flow, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + help_text=_("Flow to use when authenticating existing users."), + related_name="source_authentication", + ) + enrollment_flow = models.ForeignKey( + Flow, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + help_text=_("Flow to use when enrolling new users."), + related_name="source_enrollment", + ) + + objects = InheritanceManager() + + @property + def form(self) -> Type[ModelForm]: + """Return Form class used to edit this object""" + raise NotImplementedError + + @property + def ui_login_button(self) -> Optional[UILoginButton]: + """If source uses a http-based flow, return UI Information about the login + button. If source doesn't use http-based flow, return None.""" + return None + + @property + def ui_additional_info(self) -> Optional[str]: + """Return additional Info, such as a callback URL. Show in the administration interface.""" + return None + + @property + def ui_user_settings(self) -> Optional[str]: + """Entrypoint to integrate with User settings. Can either return None if no + user settings are available, or a string with the URL to fetch.""" + return None + + def __str__(self): + return self.name + + +class UserSourceConnection(CreatedUpdatedModel): + """Connection between User and Source.""" + + user = models.ForeignKey(User, on_delete=models.CASCADE) + source = models.ForeignKey(Source, on_delete=models.CASCADE) + + class Meta: + + unique_together = (("user", "source"),) + + +class ExpiringModel(models.Model): + """Base Model which can expire, and is automatically cleaned up.""" + + expires = models.DateTimeField(default=default_token_duration) + expiring = models.BooleanField(default=True) + + @classmethod + def filter_not_expired(cls, **kwargs) -> QuerySet: + """Filer for tokens which are not expired yet or are not expiring, + and match filters in `kwargs`""" + expired = Q(expires__lt=now(), expiring=True) + return cls.objects.exclude(expired).filter(**kwargs) + + @property + def is_expired(self) -> bool: + """Check if token is expired yet.""" + return now() > self.expires + + class Meta: + + abstract = True + + +class TokenIntents(models.TextChoices): + """Intents a Token can be created for.""" + + # Single use token + INTENT_VERIFICATION = "verification" + + # Allow access to API + INTENT_API = "api" + + # Recovery use for the recovery app + INTENT_RECOVERY = "recovery" + + +class Token(ExpiringModel): + """Token used to authenticate the User for API Access or confirm another Stage like Email.""" + + token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + identifier = models.SlugField(max_length=255) + key = models.TextField(default=default_token_key) + intent = models.TextField( + choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION + ) + user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+") + description = models.TextField(default="", blank=True) + + def __str__(self): + description = f"{self.identifier}" + if self.expiring: + description += f" (expires={self.expires})" + return description + + class Meta: + + verbose_name = _("Token") + verbose_name_plural = _("Tokens") + indexes = [ + models.Index(fields=["identifier"]), + models.Index(fields=["key"]), + ] + + +class PropertyMapping(models.Model): + """User-defined key -> x mapping which can be used by providers to expose extra data.""" + + pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + name = models.TextField() + expression = models.TextField() + + objects = InheritanceManager() + + @property + def form(self) -> Type[ModelForm]: + """Return Form class used to edit this object""" + raise NotImplementedError + + def evaluate( + self, user: Optional[User], request: Optional[HttpRequest], **kwargs + ) -> Any: + """Evaluate `self.expression` using `**kwargs` as Context.""" + from authentik.core.expression import PropertyMappingEvaluator + + evaluator = PropertyMappingEvaluator() + evaluator.set_context(user, request, **kwargs) + try: + return evaluator.evaluate(self.expression) + except (ValueError, SyntaxError) as exc: + raise PropertyMappingExpressionException from exc + + def __str__(self): + return f"Property Mapping {self.name}" + + class Meta: + + verbose_name = _("Property Mapping") + verbose_name_plural = _("Property Mappings") diff --git a/authentik/core/signals.py b/authentik/core/signals.py new file mode 100644 index 00000000..ef493518 --- /dev/null +++ b/authentik/core/signals.py @@ -0,0 +1,5 @@ +"""authentik core signals""" +from django.core.signals import Signal + +# Arguments: user: User, password: str +password_changed = Signal() diff --git a/authentik/core/tasks.py b/authentik/core/tasks.py new file mode 100644 index 00000000..d7c5fa09 --- /dev/null +++ b/authentik/core/tasks.py @@ -0,0 +1,63 @@ +"""authentik core tasks""" +from datetime import datetime +from io import StringIO + +from boto3.exceptions import Boto3Error +from botocore.exceptions import BotoCoreError, ClientError +from dbbackup.db.exceptions import CommandConnectorError +from django.contrib.humanize.templatetags.humanize import naturaltime +from django.core import management +from django.utils.timezone import now +from structlog import get_logger + +from authentik.core.models import ExpiringModel +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.root.celery import CELERY_APP + +LOGGER = get_logger() + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def clean_expired_models(self: MonitoredTask): + """Remove expired objects""" + messages = [] + for cls in ExpiringModel.__subclasses__(): + cls: ExpiringModel + amount, _ = ( + cls.objects.all() + .exclude(expiring=False) + .exclude(expiring=True, expires__gt=now()) + .delete() + ) + LOGGER.debug("Deleted expired models", model=cls, amount=amount) + messages.append(f"Deleted {amount} expired {cls._meta.verbose_name_plural}") + self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def backup_database(self: MonitoredTask): # pragma: no cover + """Database backup""" + self.result_timeout_hours = 25 + try: + start = datetime.now() + out = StringIO() + management.call_command("dbbackup", quiet=True, stdout=out) + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, + [ + f"Successfully finished database backup {naturaltime(start)}", + out.getvalue(), + ], + ) + ) + LOGGER.info("Successfully backed up database.") + except ( + IOError, + BotoCoreError, + ClientError, + Boto3Error, + PermissionError, + CommandConnectorError, + ) as exc: + self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) diff --git a/authentik/core/templates/403_csrf.html b/authentik/core/templates/403_csrf.html new file mode 100644 index 00000000..518b8705 --- /dev/null +++ b/authentik/core/templates/403_csrf.html @@ -0,0 +1,27 @@ +{% extends 'login/base.html' %} + +{% load static %} +{% load i18n %} +{% load authentik_utils %} + +{% block card_title %} +{{ title }} (403) +{% endblock %} + +{% block card %} +
+

{{ main }}

+ {% if no_referer %} +

{{ no_referer1 }}

+

{{ no_referer2 }}

+

{{ no_referer3 }}

+ {% endif %} + {% if no_cookie %} +

{{ no_cookie1 }}

+

{{ no_cookie2 }}

+ {% endif %} + {% if 'back' in request.GET %} + {% trans 'Back' %} + {% endif %} +
+{% endblock %} diff --git a/authentik/core/templates/base/page.html b/authentik/core/templates/base/page.html new file mode 100644 index 00000000..90f9ed89 --- /dev/null +++ b/authentik/core/templates/base/page.html @@ -0,0 +1,12 @@ +{% extends "base/skeleton.html" %} + +{% load i18n %} + +{% block body %} + +
+ {% trans 'Skip to content' %} + {% block page_content %} + {% endblock %} +
+{% endblock %} diff --git a/authentik/core/templates/base/skeleton.html b/authentik/core/templates/base/skeleton.html new file mode 100644 index 00000000..d26ccd0c --- /dev/null +++ b/authentik/core/templates/base/skeleton.html @@ -0,0 +1,41 @@ +{% load static %} +{% load i18n %} +{% load authentik_utils %} + + + + + + + + + + {% block title %}{% trans title|default:config.authentik.branding.title %}{% endblock %} + + + + + + + + + {% block head %} + {% endblock %} + + + {% if 'authentik_impersonate_user' in request.session %} +
+
+
+ {% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %} + {% trans 'Stop impersonation' %} +
+
+
+ {% endif %} + {% block body %} + {% endblock %} + {% block scripts %} + {% endblock %} + + diff --git a/authentik/core/templates/error/generic.html b/authentik/core/templates/error/generic.html new file mode 100644 index 00000000..095be61d --- /dev/null +++ b/authentik/core/templates/error/generic.html @@ -0,0 +1,26 @@ +{% extends 'base/page.html' %} + +{% load i18n %} +{% load authentik_utils %} + +{% block body %} +
+
+
+ +

+ {% trans title %} +

+
+ {% if message %} +

{% trans message %}

+ {% endif %} +
+ {% if 'back' in request.GET %} + {% trans 'Back' %} + {% endif %} + {% trans 'Go to home' %} +
+
+
+{% endblock %} diff --git a/authentik/core/templates/generic/autosubmit_form.html b/authentik/core/templates/generic/autosubmit_form.html new file mode 100644 index 00000000..b7254b43 --- /dev/null +++ b/authentik/core/templates/generic/autosubmit_form.html @@ -0,0 +1,31 @@ +{% extends "login/base.html" %} + +{% load authentik_utils %} +{% load i18n %} + +{% block title %} +{{ title }} +{% endblock %} + +{% block card %} +
+ {% csrf_token %} + {% for key, value in attrs.items %} + + {% endfor %} +
+
+ + + + + +
+
+
+
+ +
+
+
+{% endblock %} diff --git a/authentik/core/templates/generic/autosubmit_form_full.html b/authentik/core/templates/generic/autosubmit_form_full.html new file mode 100644 index 00000000..e3b044b8 --- /dev/null +++ b/authentik/core/templates/generic/autosubmit_form_full.html @@ -0,0 +1,34 @@ +{% extends "login/base_full.html" %} + +{% load authentik_utils %} +{% load i18n %} + +{% block title %} +{{ title }} +{% endblock %} + +{% block card %} +
+ {% csrf_token %} + {% for key, value in attrs.items %} + + {% endfor %} +
+
+ + + + + +
+
+
+
+ +
+
+
+ +{% endblock %} diff --git a/authentik/core/templates/generic/delete.html b/authentik/core/templates/generic/delete.html new file mode 100644 index 00000000..594155be --- /dev/null +++ b/authentik/core/templates/generic/delete.html @@ -0,0 +1,43 @@ +{% extends container_template|default:"administration/base.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block content %} +
+
+ {% block above_form %} +

+ {% blocktrans with object_type=object|verbose_name %} + Delete {{ object_type }} + {% endblocktrans %} +

+ {% endblock %} +
+
+
+
+
+
+
+
+ {% csrf_token %} +

+ {% blocktrans with object_type=object|verbose_name name=object %} + Are you sure you want to delete {{ object_type }} "{{ object }}"? + {% endblocktrans %} +

+ +
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/authentik/core/templates/library.html b/authentik/core/templates/library.html new file mode 100644 index 00000000..fa69e07a --- /dev/null +++ b/authentik/core/templates/library.html @@ -0,0 +1,53 @@ +{% load i18n %} + +
+
+
+

+ + {% trans 'Applications' %} +

+
+
+
+ {% if applications %} + + {% else %} +
+
+ +

{% trans 'No Applications available.' %}

+
+ {% trans "Either no applications are defined, or you don't have access to any." %} +
+ {% if perms.authentik_core.add_application %} + + {% trans 'Create Application' %} + + {% endif %} +
+
+ {% endif %} +
+
diff --git a/authentik/core/templates/login/base.html b/authentik/core/templates/login/base.html new file mode 100644 index 00000000..401c5c1c --- /dev/null +++ b/authentik/core/templates/login/base.html @@ -0,0 +1,59 @@ +{% load static %} +{% load i18n %} + + + + + + diff --git a/authentik/core/templates/login/base_full.html b/authentik/core/templates/login/base_full.html new file mode 100644 index 00000000..bae02d2b --- /dev/null +++ b/authentik/core/templates/login/base_full.html @@ -0,0 +1,75 @@ +{% extends 'base/skeleton.html' %} + +{% load static %} +{% load i18n %} +{% load authentik_utils %} + +{% block head %} +{{ block.super }} + +{% endblock %} + +{% block body %} +
+ + + + + + + + + + + +
+ + +{% endblock %} diff --git a/passbook/core/templates/login/form.html b/authentik/core/templates/login/form.html similarity index 100% rename from passbook/core/templates/login/form.html rename to authentik/core/templates/login/form.html diff --git a/authentik/core/templates/login/form_with_user.html b/authentik/core/templates/login/form_with_user.html new file mode 100644 index 00000000..59f70b4f --- /dev/null +++ b/authentik/core/templates/login/form_with_user.html @@ -0,0 +1,18 @@ +{% extends 'login/form.html' %} + +{% load i18n %} +{% load authentik_utils %} + +{% block above_form %} +
+
+
+ + {{ user.username }} +
+ +
+
+{% endblock %} diff --git a/authentik/core/templates/login/loading.html b/authentik/core/templates/login/loading.html new file mode 100644 index 00000000..fd6ca02e --- /dev/null +++ b/authentik/core/templates/login/loading.html @@ -0,0 +1,24 @@ +{% extends 'login/base.html' %} + +{% load static %} +{% load i18n %} +{% load authentik_utils %} + +{% block title %} +{% trans title %} +{% endblock %} + +{% block head %} + +{% endblock %} + +{% block card %} + +
+
+
+
+
+{% endblock %} diff --git a/authentik/core/templates/partials/form.html b/authentik/core/templates/partials/form.html new file mode 100644 index 00000000..be635809 --- /dev/null +++ b/authentik/core/templates/partials/form.html @@ -0,0 +1,73 @@ +{% load authentik_utils %} +{% load i18n %} + +{% csrf_token %} +{% if form.non_field_errors %} +
+

+ {{ form.non_field_errors }} +

+
+{% endif %} +{% for field in form %} +{% if field.field.widget|fieldtype == 'HiddenInput' %} + {{ field }} +{% else %} +
+ {% if field.field.widget|fieldtype == 'RadioSelect' %} + + {% for c in field %} +
+ + +
+ {% endfor %} + {% elif field.field.widget|fieldtype == 'Select' %} + +
+ {{ field }} +
+ {% if field.help_text %} + + {{ field.help_text }} + + {% endif %} + {% elif field.field.widget|fieldtype == 'CheckboxInput' %} + + {% if field.help_text %} + + {{ field.help_text }} + + {% endif %} + {% else %} + + {{ field|css_class:'pf-c-form-control' }} + {% if field.help_text %} + + {{ field.help_text }} + + {% endif %} + {% endif %} + {% for error in field.errors %} +

+ {{ error }} +

+ {% endfor %} +
+{% endif %} +{% endfor %} diff --git a/authentik/core/templates/partials/form_horizontal.html b/authentik/core/templates/partials/form_horizontal.html new file mode 100644 index 00000000..883fc61e --- /dev/null +++ b/authentik/core/templates/partials/form_horizontal.html @@ -0,0 +1,108 @@ +{% load authentik_utils %} +{% load i18n %} + +{% csrf_token %} +{% for field in form %} +
+ {% if field.field.widget|fieldtype == 'RadioSelect' %} +
+ +
+
+ {% for c in field %} +
+ + +
+ {% endfor %} + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} +
+ {% elif field.field.widget|fieldtype == 'Select' %} +
+ +
+
+
+ {{ field|css_class:"pf-c-form-control" }} + {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} +
+
+ {% elif field.field.widget|fieldtype == 'CheckboxInput' %} +
+
+
+ {{ field|css_class:"pf-c-check__input" }} + +
+ {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} +
+
+ {% elif field.field.widget|fieldtype == "FileInput" %} +
+ +
+
+
+ {{ field|css_class:"pf-c-form-control" }} + {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} + {% if field.value %} + + {% blocktrans with current=field.value %} + Currently set to {{current}}. + {% endblocktrans %} + + {% endif %} +
+
+ {% else %} +
+ +
+
+
+ {{ field|css_class:'pf-c-form-control' }} + {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} +
+
+ {% endif %} + {% for error in field.errors %} +

+ {{ error }} +

+ {% endfor %} +
+{% endfor %} diff --git a/authentik/core/templates/partials/pagination.html b/authentik/core/templates/partials/pagination.html new file mode 100644 index 00000000..87c2ae35 --- /dev/null +++ b/authentik/core/templates/partials/pagination.html @@ -0,0 +1,42 @@ +{% load i18n %} +{% load authentik_utils %} + +
+
+
+
+
+ + {% blocktrans with start_index=page_obj.start_index end_index=page_obj.end_index total_items=paginator.count %} + {{ start_index }} - {{ end_index }} of {{ total_items }} + {% endblocktrans %} + +
+
+ +
+
+
diff --git a/passbook/core/templates/partials/toolbar_search.html b/authentik/core/templates/partials/toolbar_search.html similarity index 100% rename from passbook/core/templates/partials/toolbar_search.html rename to authentik/core/templates/partials/toolbar_search.html diff --git a/authentik/core/templates/shell.html b/authentik/core/templates/shell.html new file mode 100644 index 00000000..4d4ff3b6 --- /dev/null +++ b/authentik/core/templates/shell.html @@ -0,0 +1,5 @@ +{% extends "base/skeleton.html" %} + +{% block body %} + +{% endblock %} diff --git a/authentik/core/templates/user/settings.html b/authentik/core/templates/user/settings.html new file mode 100644 index 00000000..ef34045b --- /dev/null +++ b/authentik/core/templates/user/settings.html @@ -0,0 +1,78 @@ +{% load i18n %} +{% load authentik_user_settings %} + +
+
+
+
+

+ + {% trans 'User Settings' %} +

+

{% trans "Configure settings relevant to your user profile." %}

+
+
+
+
+
+
+
+ {% trans 'Update details' %} +
+
+
+ {% include 'partials/form_horizontal.html' with form=form %} + {% block beneath_form %} + {% endblock %} +
+
+
+ + {% if unenrollment_enabled %} + {% trans "Delete account" %} + {% endif %} +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ {% user_stages as user_stages_loc %} + {% for stage in user_stages_loc %} +
+
+
+ +
+
+
+
+
+ {% endfor %} + {% user_sources as user_sources_loc %} + {% for source in user_sources_loc %} +
+
+
+ +
+
+
+
+
+ {% endfor %} +
+
diff --git a/authentik/core/templates/user/token_list.html b/authentik/core/templates/user/token_list.html new file mode 100644 index 00000000..c51b6b76 --- /dev/null +++ b/authentik/core/templates/user/token_list.html @@ -0,0 +1,100 @@ +{% load i18n %} + +
+
+

{% trans "Tokens can be used to access authentik's API." %}

+
+ {% if object_list %} +
+
+ {% include 'partials/toolbar_search.html' %} +
+ + + {% trans 'Create' %} + +
+
+
+ {% include 'partials/pagination.html' %} +
+
+ + + + + + + + + + + + {% for token in object_list %} + + + + + + + + {% endfor %} + +
{% trans 'Identifier' %}{% trans 'Expires?' %}{% trans 'Expiry Date' %}{% trans 'Description' %}
+
{{ token.identifier }}
+
+ + {{ token.expiring|yesno:"Yes,No" }} + + + + {% if not token.expiring %} + - + {% else %} + {{ token.expires }} + {% endif %} + + + + {{ token.description }} + + + + + {% trans 'Edit' %} + +
+
+ + + {% trans 'Delete' %} + +
+
+ + {% trans 'Copy token' %} + +
+
+ {% include 'partials/pagination.html' %} +
+ {% else %} +
+
+ +

+ {% trans 'No Tokens.' %} +

+
+ {% trans 'Currently no tokens exist. Click the button below to create one.' %} +
+ + + {% trans 'Create' %} + +
+
+
+
+ {% endif %} +
diff --git a/passbook/core/templatetags/__init__.py b/authentik/core/templatetags/__init__.py similarity index 100% rename from passbook/core/templatetags/__init__.py rename to authentik/core/templatetags/__init__.py diff --git a/authentik/core/templatetags/authentik_user_settings.py b/authentik/core/templatetags/authentik_user_settings.py new file mode 100644 index 00000000..0721fc72 --- /dev/null +++ b/authentik/core/templatetags/authentik_user_settings.py @@ -0,0 +1,44 @@ +"""authentik user settings template tags""" +from typing import Iterable + +from django import template +from django.template.context import RequestContext + +from authentik.core.models import Source +from authentik.flows.models import Stage +from authentik.policies.engine import PolicyEngine + +register = template.Library() + + +@register.simple_tag(takes_context=True) +# pylint: disable=unused-argument +def user_stages(context: RequestContext) -> list[str]: + """Return list of all stages which apply to user""" + _all_stages: Iterable[Stage] = Stage.objects.all().select_subclasses() + matching_stages: list[str] = [] + for stage in _all_stages: + user_settings = stage.ui_user_settings + if not user_settings: + continue + matching_stages.append(user_settings) + return matching_stages + + +@register.simple_tag(takes_context=True) +def user_sources(context: RequestContext) -> list[str]: + """Return a list of all sources which are enabled for the user""" + user = context.get("request").user + _all_sources: Iterable[Source] = Source.objects.filter( + enabled=True + ).select_subclasses() + matching_sources: list[str] = [] + for source in _all_sources: + user_settings = source.ui_user_settings + if not user_settings: + continue + policy_engine = PolicyEngine(source, user, context.get("request")) + policy_engine.build() + if policy_engine.passing: + matching_sources.append(user_settings) + return matching_sources diff --git a/passbook/core/tests/__init__.py b/authentik/core/tests/__init__.py similarity index 100% rename from passbook/core/tests/__init__.py rename to authentik/core/tests/__init__.py diff --git a/authentik/core/tests/test_impersonation.py b/authentik/core/tests/test_impersonation.py new file mode 100644 index 00000000..4c18483b --- /dev/null +++ b/authentik/core/tests/test_impersonation.py @@ -0,0 +1,56 @@ +"""impersonation tests""" +from django.shortcuts import reverse +from django.test.testcases import TestCase + +from authentik.core.models import User + + +class TestImpersonation(TestCase): + """impersonation tests""" + + def setUp(self) -> None: + super().setUp() + self.other_user = User.objects.create(username="to-impersonate") + self.akadmin = User.objects.get(username="akadmin") + + def test_impersonate_simple(self): + """test simple impersonation and un-impersonation""" + self.client.force_login(self.akadmin) + + self.client.get( + reverse( + "authentik_core:impersonate-init", + kwargs={"user_id": self.other_user.pk}, + ) + ) + + response = self.client.get(reverse("authentik_api:user-me")) + self.assertIn(self.other_user.username, response.content.decode()) + self.assertNotIn(self.akadmin.username, response.content.decode()) + + self.client.get(reverse("authentik_core:impersonate-end")) + + response = self.client.get(reverse("authentik_api:user-me")) + self.assertNotIn(self.other_user.username, response.content.decode()) + self.assertIn(self.akadmin.username, response.content.decode()) + + def test_impersonate_denied(self): + """test impersonation without permissions""" + self.client.force_login(self.other_user) + + self.client.get( + reverse( + "authentik_core:impersonate-init", kwargs={"user_id": self.akadmin.pk} + ) + ) + + response = self.client.get(reverse("authentik_api:user-me")) + self.assertIn(self.other_user.username, response.content.decode()) + self.assertNotIn(self.akadmin.username, response.content.decode()) + + def test_un_impersonate_empty(self): + """test un-impersonation without impersonating first""" + self.client.force_login(self.other_user) + + response = self.client.get(reverse("authentik_core:impersonate-end")) + self.assertRedirects(response, reverse("authentik_core:shell")) diff --git a/authentik/core/tests/test_tasks.py b/authentik/core/tests/test_tasks.py new file mode 100644 index 00000000..ff19843c --- /dev/null +++ b/authentik/core/tests/test_tasks.py @@ -0,0 +1,18 @@ +"""authentik core task tests""" +from django.test import TestCase +from django.utils.timezone import now +from guardian.shortcuts import get_anonymous_user + +from authentik.core.models import Token +from authentik.core.tasks import clean_expired_models + + +class TestTasks(TestCase): + """Test Tasks""" + + def test_token_cleanup(self): + """Test Token cleanup task""" + Token.objects.create(expires=now(), user=get_anonymous_user()) + self.assertEqual(Token.objects.all().count(), 1) + clean_expired_models.delay().get() + self.assertEqual(Token.objects.all().count(), 0) diff --git a/authentik/core/tests/test_views_overview.py b/authentik/core/tests/test_views_overview.py new file mode 100644 index 00000000..a756517b --- /dev/null +++ b/authentik/core/tests/test_views_overview.py @@ -0,0 +1,42 @@ +"""authentik user view tests""" +import string +from random import SystemRandom + +from django.shortcuts import reverse +from django.test import TestCase + +from authentik.core.models import User + + +class TestOverviewViews(TestCase): + """Test Overview Views""" + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + username="unittest user", + email="unittest@example.com", + password="".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ), + ) + self.client.force_login(self.user) + + def test_shell(self): + """Test shell""" + self.assertEqual( + self.client.get(reverse("authentik_core:shell")).status_code, 200 + ) + + def test_overview(self): + """Test overview""" + self.assertEqual( + self.client.get(reverse("authentik_core:overview")).status_code, 200 + ) + + def test_user_settings(self): + """Test user settings""" + self.assertEqual( + self.client.get(reverse("authentik_core:user-settings")).status_code, 200 + ) diff --git a/authentik/core/tests/test_views_user.py b/authentik/core/tests/test_views_user.py new file mode 100644 index 00000000..04b5c608 --- /dev/null +++ b/authentik/core/tests/test_views_user.py @@ -0,0 +1,30 @@ +"""authentik user view tests""" +import string +from random import SystemRandom + +from django.shortcuts import reverse +from django.test import TestCase + +from authentik.core.models import User + + +class TestUserViews(TestCase): + """Test User Views""" + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + username="unittest user", + email="unittest@example.com", + password="".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ), + ) + self.client.force_login(self.user) + + def test_user_settings(self): + """Test UserSettingsView""" + self.assertEqual( + self.client.get(reverse("authentik_core:user-settings")).status_code, 200 + ) diff --git a/authentik/core/types.py b/authentik/core/types.py new file mode 100644 index 00000000..4dd24968 --- /dev/null +++ b/authentik/core/types.py @@ -0,0 +1,20 @@ +"""authentik core dataclasses""" +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class UILoginButton: + """Dataclass for Source's ui_login_button""" + + # Name, ran through i18n + name: str + + # URL Which Button points to + url: str + + # Icon name, ran through django's static + icon_path: Optional[str] = None + + # Icon URL, used as-is + icon_url: Optional[str] = None diff --git a/authentik/core/urls.py b/authentik/core/urls.py new file mode 100644 index 00000000..e4bafb72 --- /dev/null +++ b/authentik/core/urls.py @@ -0,0 +1,39 @@ +"""authentik URL Configuration""" +from django.urls import path + +from authentik.core.views import impersonate, library, shell, user + +urlpatterns = [ + path("", shell.ShellView.as_view(), name="shell"), + # User views + path("-/user/", user.UserSettingsView.as_view(), name="user-settings"), + path("-/user/tokens/", user.TokenListView.as_view(), name="user-tokens"), + path( + "-/user/tokens/create/", + user.TokenCreateView.as_view(), + name="user-tokens-create", + ), + path( + "-/user/tokens//update/", + user.TokenUpdateView.as_view(), + name="user-tokens-update", + ), + path( + "-/user/tokens//delete/", + user.TokenDeleteView.as_view(), + name="user-tokens-delete", + ), + # Libray + path("library/", library.LibraryView.as_view(), name="overview"), + # Impersonation + path( + "-/impersonation//", + impersonate.ImpersonateInitView.as_view(), + name="impersonate-init", + ), + path( + "-/impersonation/end/", + impersonate.ImpersonateEndView.as_view(), + name="impersonate-end", + ), +] diff --git a/passbook/core/views/__init__.py b/authentik/core/views/__init__.py similarity index 100% rename from passbook/core/views/__init__.py rename to authentik/core/views/__init__.py diff --git a/authentik/core/views/error.py b/authentik/core/views/error.py new file mode 100644 index 00000000..9f9e4dea --- /dev/null +++ b/authentik/core/views/error.py @@ -0,0 +1,67 @@ +"""authentik core error views""" + +from django.http.response import ( + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + HttpResponseServerError, +) +from django.template.response import TemplateResponse +from django.views.generic import TemplateView + + +class BadRequestTemplateResponse(TemplateResponse, HttpResponseBadRequest): + """Combine Template response with Http Code 400""" + + +class ForbiddenTemplateResponse(TemplateResponse, HttpResponseForbidden): + """Combine Template response with Http Code 403""" + + +class NotFoundTemplateResponse(TemplateResponse, HttpResponseNotFound): + """Combine Template response with Http Code 404""" + + +class ServerErrorTemplateResponse(TemplateResponse, HttpResponseServerError): + """Combine Template response with Http Code 500""" + + +class BadRequestView(TemplateView): + """Show Bad Request message""" + + extra_context = {"title": "Bad Request"} + + response_class = BadRequestTemplateResponse + template_name = "error/generic.html" + + +class ForbiddenView(TemplateView): + """Show Forbidden message""" + + extra_context = {"title": "Forbidden"} + + response_class = ForbiddenTemplateResponse + template_name = "error/generic.html" + + +class NotFoundView(TemplateView): + """Show Not Found message""" + + extra_context = {"title": "Not Found"} + + response_class = NotFoundTemplateResponse + template_name = "error/generic.html" + + +class ServerErrorView(TemplateView): + """Show Server Error message""" + + extra_context = {"title": "Server Error"} + + response_class = ServerErrorTemplateResponse + template_name = "error/generic.html" + + # pylint: disable=useless-super-delegation + def dispatch(self, *args, **kwargs): # pragma: no cover + """Little wrapper so django accepts this function""" + return super().dispatch(*args, **kwargs) diff --git a/authentik/core/views/impersonate.py b/authentik/core/views/impersonate.py new file mode 100644 index 00000000..ef94e607 --- /dev/null +++ b/authentik/core/views/impersonate.py @@ -0,0 +1,58 @@ +"""authentik impersonation views""" + +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.views import View +from structlog import get_logger + +from authentik.audit.models import Event, EventAction +from authentik.core.middleware import ( + SESSION_IMPERSONATE_ORIGINAL_USER, + SESSION_IMPERSONATE_USER, +) +from authentik.core.models import User + +LOGGER = get_logger() + + +class ImpersonateInitView(View): + """Initiate Impersonation""" + + def get(self, request: HttpRequest, user_id: int) -> HttpResponse: + """Impersonation handler, checks permissions""" + if not request.user.has_perm("impersonate"): + LOGGER.debug( + "User attempted to impersonate without permissions", user=request.user + ) + return HttpResponse("Unauthorized", status=401) + + user_to_be = get_object_or_404(User, pk=user_id) + + request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user + request.session[SESSION_IMPERSONATE_USER] = user_to_be + + Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) + + return redirect("authentik_core:shell") + + +class ImpersonateEndView(View): + """End User impersonation""" + + def get(self, request: HttpRequest) -> HttpResponse: + """End Impersonation handler""" + if ( + SESSION_IMPERSONATE_USER not in request.session + or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session + ): + LOGGER.debug("Can't end impersonation", user=request.user) + return redirect("authentik_core:shell") + + original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER] + + del request.session[SESSION_IMPERSONATE_USER] + del request.session[SESSION_IMPERSONATE_ORIGINAL_USER] + + Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) + + return redirect("authentik_core:shell") diff --git a/authentik/core/views/library.py b/authentik/core/views/library.py new file mode 100644 index 00000000..0d7984cf --- /dev/null +++ b/authentik/core/views/library.py @@ -0,0 +1,23 @@ +"""authentik library view""" + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import TemplateView + +from authentik.core.models import Application +from authentik.policies.engine import PolicyEngine + + +class LibraryView(LoginRequiredMixin, TemplateView): + """Overview for logged in user, incase user opens authentik directly + and is not being forwarded""" + + template_name = "library.html" + + def get_context_data(self, **kwargs): + kwargs["applications"] = [] + for application in Application.objects.all().order_by("name"): + engine = PolicyEngine(application, self.request.user, self.request) + engine.build() + if engine.passing: + kwargs["applications"].append(application) + return super().get_context_data(**kwargs) diff --git a/passbook/core/views/shell.py b/authentik/core/views/shell.py similarity index 100% rename from passbook/core/views/shell.py rename to authentik/core/views/shell.py diff --git a/authentik/core/views/user.py b/authentik/core/views/user.py new file mode 100644 index 00000000..1c3cdd24 --- /dev/null +++ b/authentik/core/views/user.py @@ -0,0 +1,137 @@ +"""authentik core user views""" +from typing import Any, Dict + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.db.models.query import QuerySet +from django.http.response import HttpResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views.generic import ListView, UpdateView +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin +from guardian.shortcuts import get_objects_for_user + +from authentik.admin.views.utils import ( + DeleteMessageView, + SearchListMixin, + UserPaginateListMixin, +) +from authentik.core.forms.token import UserTokenForm +from authentik.core.forms.users import UserDetailForm +from authentik.core.models import Token, TokenIntents +from authentik.flows.models import Flow, FlowDesignation +from authentik.lib.views import CreateAssignPermView + + +class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView): + """Update User settings""" + + template_name = "user/settings.html" + form_class = UserDetailForm + + success_message = _("Successfully updated user.") + success_url = reverse_lazy("authentik_core:user-settings") + + def get_object(self): + return self.request.user + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + unenrollment_flow = Flow.with_policy( + self.request, designation=FlowDesignation.UNRENOLLMENT + ) + kwargs["unenrollment_enabled"] = bool(unenrollment_flow) + return kwargs + + +class TokenListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + ListView, +): + """Show list of all tokens""" + + model = Token + ordering = "expires" + permission_required = "authentik_core.view_token" + + template_name = "user/token_list.html" + search_fields = [ + "identifier", + "intent", + "description", + ] + + def get_queryset(self) -> QuerySet: + return super().get_queryset().filter(intent=TokenIntents.INTENT_API) + + +class TokenCreateView( + SuccessMessageMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + CreateAssignPermView, +): + """Create new Token""" + + model = Token + form_class = UserTokenForm + permission_required = "authentik_core.add_token" + + template_name = "generic/create.html" + success_url = reverse_lazy("authentik_core:user-tokens") + success_message = _("Successfully created Token") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + kwargs["container_template"] = "user/base.html" + return kwargs + + def form_valid(self, form: UserTokenForm) -> HttpResponse: + form.instance.user = self.request.user + form.instance.intent = TokenIntents.INTENT_API + return super().form_valid(form) + + +class TokenUpdateView( + SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView +): + """Update token""" + + model = Token + form_class = UserTokenForm + permission_required = "authentik_core.update_token" + template_name = "generic/update.html" + success_url = reverse_lazy("authentik_core:user-tokens") + success_message = _("Successfully updated Token") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + kwargs["container_template"] = "user/base.html" + return kwargs + + def get_object(self) -> Token: + identifier = self.kwargs.get("identifier") + return get_objects_for_user( + self.request.user, "authentik_core.update_token", self.model + ).filter(intent=TokenIntents.INTENT_API, identifier=identifier) + + +class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): + """Delete token""" + + model = Token + permission_required = "authentik_core.delete_token" + template_name = "generic/delete.html" + success_url = reverse_lazy("authentik_core:user-tokens") + success_message = _("Successfully deleted Token") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + kwargs["container_template"] = "user/base.html" + return kwargs diff --git a/passbook/crypto/__init__.py b/authentik/crypto/__init__.py similarity index 100% rename from passbook/crypto/__init__.py rename to authentik/crypto/__init__.py diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py new file mode 100644 index 00000000..1cbe0b72 --- /dev/null +++ b/authentik/crypto/api.py @@ -0,0 +1,47 @@ +"""Crypto API Views""" +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.x509 import load_pem_x509_certificate +from rest_framework.serializers import ModelSerializer, ValidationError +from rest_framework.viewsets import ModelViewSet + +from authentik.crypto.models import CertificateKeyPair + + +class CertificateKeyPairSerializer(ModelSerializer): + """CertificateKeyPair Serializer""" + + def validate_certificate_data(self, value): + """Verify that input is a valid PEM x509 Certificate""" + try: + load_pem_x509_certificate(value.encode("utf-8"), default_backend()) + except ValueError: + raise ValidationError("Unable to load certificate.") + return value + + def validate_key_data(self, value): + """Verify that input is a valid PEM RSA Key""" + # Since this field is optional, data can be empty. + if value == "": + return value + try: + load_pem_private_key( + str.encode("\n".join([x.strip() for x in value.split("\n")])), + password=None, + backend=default_backend(), + ) + except ValueError: + raise ValidationError("Unable to load private key.") + return value + + class Meta: + + model = CertificateKeyPair + fields = ["pk", "name", "certificate_data", "key_data"] + + +class CertificateKeyPairViewSet(ModelViewSet): + """CertificateKeyPair Viewset""" + + queryset = CertificateKeyPair.objects.all() + serializer_class = CertificateKeyPairSerializer diff --git a/authentik/crypto/apps.py b/authentik/crypto/apps.py new file mode 100644 index 00000000..a7c5419b --- /dev/null +++ b/authentik/crypto/apps.py @@ -0,0 +1,10 @@ +"""authentik crypto app config""" +from django.apps import AppConfig + + +class AuthentikCryptoConfig(AppConfig): + """authentik crypto app config""" + + name = "authentik.crypto" + label = "authentik_crypto" + verbose_name = "authentik Crypto" diff --git a/authentik/crypto/builder.py b/authentik/crypto/builder.py new file mode 100644 index 00000000..122766a8 --- /dev/null +++ b/authentik/crypto/builder.py @@ -0,0 +1,84 @@ +"""Create self-signed certificates""" +import datetime +import uuid + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + + +class CertificateBuilder: + """Build self-signed certificates""" + + __public_key = None + __private_key = None + __builder = None + __certificate = None + + def __init__(self): + self.__public_key = None + self.__private_key = None + self.__builder = None + self.__certificate = None + + def build(self): + """Build self-signed certificate""" + one_day = datetime.timedelta(1, 0, 0) + self.__private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + self.__public_key = self.__private_key.public_key() + self.__builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute( + NameOID.COMMON_NAME, + "authentik Self-signed Certificate", + ), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"), + x509.NameAttribute( + NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed" + ), + ] + ) + ) + .issuer_name( + x509.Name( + [ + x509.NameAttribute( + NameOID.COMMON_NAME, + "authentik Self-signed Certificate", + ), + ] + ) + ) + .not_valid_before(datetime.datetime.today() - one_day) + .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=365)) + .serial_number(int(uuid.uuid4())) + .public_key(self.__public_key) + ) + self.__certificate = self.__builder.sign( + private_key=self.__private_key, + algorithm=hashes.SHA256(), + backend=default_backend(), + ) + + @property + def private_key(self): + """Return private key in PEM format""" + return self.__private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + @property + def certificate(self): + """Return certificate in PEM format""" + return self.__certificate.public_bytes( + encoding=serialization.Encoding.PEM, + ).decode("utf-8") diff --git a/authentik/crypto/forms.py b/authentik/crypto/forms.py new file mode 100644 index 00000000..6ab8dafc --- /dev/null +++ b/authentik/crypto/forms.py @@ -0,0 +1,57 @@ +"""authentik Crypto forms""" +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.x509 import load_pem_x509_certificate +from django import forms +from django.utils.translation import gettext_lazy as _ + +from authentik.crypto.models import CertificateKeyPair + + +class CertificateKeyPairForm(forms.ModelForm): + """CertificateKeyPair Form""" + + def clean_certificate_data(self): + """Verify that input is a valid PEM x509 Certificate""" + certificate_data = self.cleaned_data["certificate_data"] + try: + load_pem_x509_certificate( + certificate_data.encode("utf-8"), default_backend() + ) + except ValueError: + raise forms.ValidationError("Unable to load certificate.") + return certificate_data + + def clean_key_data(self): + """Verify that input is a valid PEM RSA Key""" + key_data = self.cleaned_data["key_data"] + # Since this field is optional, data can be empty. + if key_data == "": + return key_data + try: + load_pem_private_key( + str.encode("\n".join([x.strip() for x in key_data.split("\n")])), + password=None, + backend=default_backend(), + ) + except ValueError: + raise forms.ValidationError("Unable to load private key.") + return key_data + + class Meta: + + model = CertificateKeyPair + fields = [ + "name", + "certificate_data", + "key_data", + ] + widgets = { + "name": forms.TextInput(), + "certificate_data": forms.Textarea(attrs={"class": "monospaced"}), + "key_data": forms.Textarea(attrs={"class": "monospaced"}), + } + labels = { + "certificate_data": _("Certificate"), + "key_data": _("Private Key"), + } diff --git a/passbook/crypto/migrations/0001_initial.py b/authentik/crypto/migrations/0001_initial.py similarity index 100% rename from passbook/crypto/migrations/0001_initial.py rename to authentik/crypto/migrations/0001_initial.py diff --git a/authentik/crypto/migrations/0002_create_self_signed_kp.py b/authentik/crypto/migrations/0002_create_self_signed_kp.py new file mode 100644 index 00000000..013d2de0 --- /dev/null +++ b/authentik/crypto/migrations/0002_create_self_signed_kp.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.6 on 2020-05-23 23:07 + +from django.db import migrations + + +def create_self_signed(apps, schema_editor): + CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair") + db_alias = schema_editor.connection.alias + from authentik.crypto.builder import CertificateBuilder + + builder = CertificateBuilder() + builder.build() + CertificateKeyPair.objects.using(db_alias).create( + name="authentik Self-signed Certificate", + certificate_data=builder.certificate, + key_data=builder.private_key, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0001_initial"), + ] + + operations = [migrations.RunPython(create_self_signed)] diff --git a/passbook/crypto/migrations/__init__.py b/authentik/crypto/migrations/__init__.py similarity index 100% rename from passbook/crypto/migrations/__init__.py rename to authentik/crypto/migrations/__init__.py diff --git a/authentik/crypto/models.py b/authentik/crypto/models.py new file mode 100644 index 00000000..3b447e66 --- /dev/null +++ b/authentik/crypto/models.py @@ -0,0 +1,87 @@ +"""authentik crypto models""" +from binascii import hexlify +from hashlib import md5 +from typing import Optional +from uuid import uuid4 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.x509 import Certificate, load_pem_x509_certificate +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from authentik.lib.models import CreatedUpdatedModel + + +class CertificateKeyPair(CreatedUpdatedModel): + """CertificateKeyPair that can be used for signing or encrypting if `key_data` + is set, otherwise it can be used to verify remote data.""" + + kp_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + name = models.TextField() + certificate_data = models.TextField(help_text=_("PEM-encoded Certificate data")) + key_data = models.TextField( + help_text=_( + "Optional Private Key. If this is set, you can use this keypair for encryption." + ), + blank=True, + default="", + ) + + _cert: Optional[Certificate] = None + _private_key: Optional[RSAPrivateKey] = None + _public_key: Optional[RSAPublicKey] = None + + @property + def certificate(self) -> Certificate: + """Get python cryptography Certificate instance""" + if not self._cert: + self._cert = load_pem_x509_certificate( + self.certificate_data.encode("utf-8"), default_backend() + ) + return self._cert + + @property + def public_key(self) -> Optional[RSAPublicKey]: + """Get public key of the private key""" + if not self._public_key: + self._public_key = self.private_key.public_key() + return self._public_key + + @property + def private_key(self) -> Optional[RSAPrivateKey]: + """Get python cryptography PrivateKey instance""" + if not self._private_key and self._private_key != "": + self._private_key = load_pem_private_key( + str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])), + password=None, + backend=default_backend(), + ) + return self._private_key + + @property + def fingerprint(self) -> str: + """Get SHA256 Fingerprint of certificate_data""" + return hexlify(self.certificate.fingerprint(hashes.SHA256()), ":").decode( + "utf-8" + ) + + @property + def kid(self): + """Get Key ID used for JWKS""" + return "{0}".format( + md5(self.key_data.encode("utf-8")).hexdigest() # nosec + if self.key_data + else "" + ) + + def __str__(self) -> str: + return f"Certificate-Key Pair {self.name}" + + class Meta: + + verbose_name = _("Certificate-Key Pair") + verbose_name_plural = _("Certificate-Key Pairs") diff --git a/authentik/crypto/tests.py b/authentik/crypto/tests.py new file mode 100644 index 00000000..1b43806d --- /dev/null +++ b/authentik/crypto/tests.py @@ -0,0 +1,50 @@ +"""Crypto tests""" +from django.test import TestCase + +from authentik.crypto.api import CertificateKeyPairSerializer +from authentik.crypto.forms import CertificateKeyPairForm +from authentik.crypto.models import CertificateKeyPair + + +class TestCrypto(TestCase): + """Test Crypto validation""" + + def test_form(self): + """Test form validation""" + keypair = CertificateKeyPair.objects.first() + self.assertTrue( + CertificateKeyPairForm( + { + "name": keypair.name, + "certificate_data": keypair.certificate_data, + "key_data": keypair.key_data, + } + ).is_valid() + ) + self.assertFalse( + CertificateKeyPairForm( + {"name": keypair.name, "certificate_data": "test", "key_data": "test"} + ).is_valid() + ) + + def test_serializer(self): + """Test API Validation""" + keypair = CertificateKeyPair.objects.first() + self.assertTrue( + CertificateKeyPairSerializer( + data={ + "name": keypair.name, + "certificate_data": keypair.certificate_data, + "key_data": keypair.key_data, + } + ).is_valid() + ) + self.assertFalse( + CertificateKeyPairSerializer( + data={ + "name": keypair.name, + "certificate_data": "test", + "key_data": "test", + } + ).is_valid() + ) diff --git a/passbook/flows/__init__.py b/authentik/flows/__init__.py similarity index 100% rename from passbook/flows/__init__.py rename to authentik/flows/__init__.py diff --git a/authentik/flows/api.py b/authentik/flows/api.py new file mode 100644 index 00000000..349bfde5 --- /dev/null +++ b/authentik/flows/api.py @@ -0,0 +1,94 @@ +"""Flow API Views""" +from django.core.cache import cache +from rest_framework.serializers import ModelSerializer, SerializerMethodField +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet + +from authentik.flows.models import Flow, FlowStageBinding, Stage +from authentik.flows.planner import cache_key + + +class FlowSerializer(ModelSerializer): + """Flow Serializer""" + + cache_count = SerializerMethodField() + + def get_cache_count(self, flow: Flow): + """Get count of cached flows""" + return len(cache.keys(f"{cache_key(flow)}*")) + + class Meta: + + model = Flow + fields = [ + "pk", + "name", + "slug", + "title", + "designation", + "background", + "stages", + "policies", + "cache_count", + ] + + +class FlowViewSet(ModelViewSet): + """Flow Viewset""" + + queryset = Flow.objects.all() + serializer_class = FlowSerializer + + +class FlowStageBindingSerializer(ModelSerializer): + """FlowStageBinding Serializer""" + + class Meta: + + model = FlowStageBinding + fields = [ + "pk", + "target", + "stage", + "evaluate_on_plan", + "re_evaluate_policies", + "order", + "policies", + ] + + +class FlowStageBindingViewSet(ModelViewSet): + """FlowStageBinding Viewset""" + + queryset = FlowStageBinding.objects.all() + serializer_class = FlowStageBindingSerializer + filterset_fields = "__all__" + + +class StageSerializer(ModelSerializer): + """Stage Serializer""" + + __type__ = SerializerMethodField(method_name="get_type") + verbose_name = SerializerMethodField(method_name="get_verbose_name") + + def get_type(self, obj: Stage) -> str: + """Get object type so that we know which API Endpoint to use to get the full object""" + return obj._meta.object_name.lower().replace("stage", "") + + def get_verbose_name(self, obj: Stage) -> str: + """Get verbose name for UI""" + return obj._meta.verbose_name + + class Meta: + + model = Stage + fields = ["pk", "name", "__type__", "verbose_name"] + + +class StageViewSet(ReadOnlyModelViewSet): + """Stage Viewset""" + + queryset = Stage.objects.all() + serializer_class = StageSerializer + + def get_queryset(self): + return Stage.objects.select_subclasses() diff --git a/authentik/flows/apps.py b/authentik/flows/apps.py new file mode 100644 index 00000000..513b3f04 --- /dev/null +++ b/authentik/flows/apps.py @@ -0,0 +1,16 @@ +"""authentik flows app config""" +from importlib import import_module + +from django.apps import AppConfig + + +class AuthentikFlowsConfig(AppConfig): + """authentik flows app config""" + + name = "authentik.flows" + label = "authentik_flows" + mountpoint = "flows/" + verbose_name = "authentik Flows" + + def ready(self): + import_module("authentik.flows.signals") diff --git a/passbook/flows/exceptions.py b/authentik/flows/exceptions.py similarity index 100% rename from passbook/flows/exceptions.py rename to authentik/flows/exceptions.py diff --git a/authentik/flows/forms.py b/authentik/flows/forms.py new file mode 100644 index 00000000..44c5adcc --- /dev/null +++ b/authentik/flows/forms.py @@ -0,0 +1,69 @@ +"""Flow and Stage forms""" + +from django import forms +from django.core.validators import FileExtensionValidator +from django.forms import ValidationError +from django.utils.translation import gettext_lazy as _ + +from authentik.flows.models import Flow, FlowStageBinding, Stage +from authentik.flows.transfer.importer import FlowImporter +from authentik.lib.widgets import GroupedModelChoiceField + + +class FlowForm(forms.ModelForm): + """Flow Form""" + + class Meta: + + model = Flow + fields = [ + "name", + "title", + "slug", + "designation", + "background", + ] + widgets = { + "name": forms.TextInput(), + "title": forms.TextInput(), + "background": forms.FileInput(), + } + + +class FlowStageBindingForm(forms.ModelForm): + """FlowStageBinding Form""" + + stage = GroupedModelChoiceField( + queryset=Stage.objects.all().select_subclasses(), to_field_name="stage_uuid" + ) + + class Meta: + + model = FlowStageBinding + fields = [ + "target", + "stage", + "evaluate_on_plan", + "re_evaluate_policies", + "order", + ] + widgets = { + "name": forms.TextInput(), + } + + +class FlowImportForm(forms.Form): + """Form used for flow importing""" + + flow = forms.FileField( + validators=[FileExtensionValidator(allowed_extensions=["akflow"])] + ) + + def clean_flow(self): + """Check if the flow is valid and rewind the file to the start""" + flow = self.cleaned_data["flow"].read() + valid = FlowImporter(flow.decode()).validate() + if not valid: + raise ValidationError(_("Flow invalid.")) + self.cleaned_data["flow"].seek(0) + return self.cleaned_data["flow"] diff --git a/passbook/flows/management/__init__.py b/authentik/flows/management/__init__.py similarity index 100% rename from passbook/flows/management/__init__.py rename to authentik/flows/management/__init__.py diff --git a/passbook/flows/management/commands/__init__.py b/authentik/flows/management/commands/__init__.py similarity index 100% rename from passbook/flows/management/commands/__init__.py rename to authentik/flows/management/commands/__init__.py diff --git a/authentik/flows/management/commands/apply_flow.py b/authentik/flows/management/commands/apply_flow.py new file mode 100644 index 00000000..349686a3 --- /dev/null +++ b/authentik/flows/management/commands/apply_flow.py @@ -0,0 +1,22 @@ +"""Apply flow from commandline""" +from django.core.management.base import BaseCommand, no_translations + +from authentik.flows.transfer.importer import FlowImporter + + +class Command(BaseCommand): # pragma: no cover + """Apply flow from commandline""" + + @no_translations + def handle(self, *args, **options): + """Apply all flows in order, abort when one fails to import""" + for flow_path in options.get("flows", []): + with open(flow_path, "r") as flow_file: + importer = FlowImporter(flow_file.read()) + valid = importer.validate() + if not valid: + raise ValueError("Flow invalid") + importer.apply() + + def add_arguments(self, parser): + parser.add_argument("flows", nargs="+", type=str) diff --git a/authentik/flows/management/commands/benchmark.py b/authentik/flows/management/commands/benchmark.py new file mode 100644 index 00000000..f6d963f8 --- /dev/null +++ b/authentik/flows/management/commands/benchmark.py @@ -0,0 +1,117 @@ +"""authentik benchmark command""" +from csv import DictWriter +from multiprocessing import Manager, Process, cpu_count +from sys import stdout +from time import time + +from django import db +from django.core.management.base import BaseCommand +from django.test import RequestFactory +from structlog import get_logger + +from authentik import __version__ +from authentik.core.models import User +from authentik.flows.models import Flow +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner + +LOGGER = get_logger() + + +class FlowPlanProcess(Process): # pragma: no cover + """Test process which executes flow planner""" + + def __init__(self, index, return_dict, flow, user) -> None: + super().__init__() + self.index = index + self.return_dict = return_dict + self.flow = flow + self.user = user + self.request = RequestFactory().get("/") + + def run(self): + print(f"Proc {self.index} Running") + + def test_inner(): + planner = FlowPlanner(self.flow) + planner.use_cache = False + planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: self.user}) + + diffs = [] + for _ in range(1000): + start = time() + test_inner() + end = time() + diffs.append(end - start) + self.return_dict[self.index] = diffs + + +class Command(BaseCommand): # pragma: no cover + """Benchmark authentik""" + + def add_arguments(self, parser): + parser.add_argument( + "-p", + "--processes", + default=cpu_count(), + action="store", + help="How many processes should be started.", + ) + parser.add_argument( + "--csv", + action="store_true", + help="Output results as CSV", + ) + + def benchmark_flows(self, proc_count): + """Get full recovery link""" + flow = Flow.objects.get(slug="default-authentication-flow") + user = User.objects.get(username="akadmin") + manager = Manager() + return_dict = manager.dict() + + jobs = [] + db.connections.close_all() + for i in range(proc_count): + proc = FlowPlanProcess(i, return_dict, flow, user) + jobs.append(proc) + proc.start() + + for proc in jobs: + proc.join() + return return_dict.values() + + def handle(self, *args, **options): + """Start benchmark""" + proc_count = options.get("processes", 1) + all_values = self.benchmark_flows(proc_count) + if options.get("csv"): + self.output_csv(all_values) + else: + self.output_overview(all_values) + + def output_overview(self, values): + """Output results human readable""" + total_max: int = max([max(inner) for inner in values]) + total_min: int = min([min(inner) for inner in values]) + total_avg = sum([sum(inner) for inner in values]) / sum( + [len(inner) for inner in values] + ) + + print(f"Version: {__version__}") + print(f"Processes: {len(values)}") + print(f"\tMax: {total_max * 100}ms") + print(f"\tMin: {total_min * 100}ms") + print(f"\tAvg: {total_avg * 100}ms") + + def output_csv(self, values): + """Output results as CSV""" + proc_count = len(values) + fieldnames = [f"proc_{idx}" for idx in range(proc_count)] + writer = DictWriter(stdout, fieldnames=fieldnames) + + writer.writeheader() + for run_idx in range(len(values[0])): + row_dict = {} + for proc_idx in range(proc_count): + row_dict[f"proc_{proc_idx}"] = values[proc_idx][run_idx] * 100 + writer.writerow(row_dict) diff --git a/authentik/flows/markers.py b/authentik/flows/markers.py new file mode 100644 index 00000000..e20f7af9 --- /dev/null +++ b/authentik/flows/markers.py @@ -0,0 +1,57 @@ +"""Stage Markers""" +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from django.http.request import HttpRequest +from structlog import get_logger + +from authentik.core.models import User +from authentik.flows.models import Stage +from authentik.policies.engine import PolicyEngine +from authentik.policies.models import PolicyBinding + +if TYPE_CHECKING: + from authentik.flows.planner import FlowPlan + +LOGGER = get_logger() + + +@dataclass +class StageMarker: + """Base stage marker class, no extra attributes, and has no special handler.""" + + # pylint: disable=unused-argument + def process( + self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] + ) -> Optional[Stage]: + """Process callback for this marker. This should be overridden by sub-classes. + If a stage should be removed, return None.""" + return stage + + +@dataclass +class ReevaluateMarker(StageMarker): + """Reevaluate Marker, forces stage's policies to be evaluated again.""" + + binding: PolicyBinding + user: User + + def process( + self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] + ) -> Optional[Stage]: + """Re-evaluate policies bound to stage, and if they fail, remove from plan""" + engine = PolicyEngine(self.binding, self.user) + engine.use_cache = False + if http_request: + engine.request.http_request = http_request + engine.request.context = plan.context + engine.build() + result = engine.result + if result.passing: + return stage + LOGGER.warning( + "f(plan_inst)[re-eval marker]: stage failed re-evaluation", + stage=stage, + messages=result.messages, + ) + return None diff --git a/authentik/flows/migrations/0001_initial.py b/authentik/flows/migrations/0001_initial.py new file mode 100644 index 00000000..297f09c7 --- /dev/null +++ b/authentik/flows/migrations/0001_initial.py @@ -0,0 +1,138 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:07 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Flow", + fields=[ + ( + "flow_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField()), + ("slug", models.SlugField(unique=True)), + ( + "designation", + models.CharField( + choices=[ + ("authentication", "Authentication"), + ("invalidation", "Invalidation"), + ("enrollment", "Enrollment"), + ("unenrollment", "Unrenollment"), + ("recovery", "Recovery"), + ("password_change", "Password Change"), + ], + max_length=100, + ), + ), + ( + "pbm", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + related_name="+", + to="authentik_policies.PolicyBindingModel", + ), + ), + ], + options={ + "verbose_name": "Flow", + "verbose_name_plural": "Flows", + }, + bases=("authentik_policies.policybindingmodel",), + ), + migrations.CreateModel( + name="Stage", + fields=[ + ( + "stage_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField()), + ], + ), + migrations.CreateModel( + name="FlowStageBinding", + fields=[ + ( + "policybindingmodel_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + to="authentik_policies.PolicyBindingModel", + ), + ), + ( + "fsb_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "re_evaluate_policies", + models.BooleanField( + default=False, + help_text="When this option is enabled, the planner will re-evaluate policies bound to this.", + ), + ), + ("order", models.IntegerField()), + ( + "flow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_flows.Flow", + ), + ), + ( + "stage", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_flows.Stage", + ), + ), + ], + options={ + "verbose_name": "Flow Stage Binding", + "verbose_name_plural": "Flow Stage Bindings", + "ordering": ["order", "flow"], + "unique_together": {("flow", "stage", "order")}, + }, + bases=("authentik_policies.policybindingmodel",), + ), + migrations.AddField( + model_name="flow", + name="stages", + field=models.ManyToManyField( + blank=True, + through="authentik_flows.FlowStageBinding", + to="authentik_flows.Stage", + ), + ), + ] diff --git a/authentik/flows/migrations/0003_auto_20200523_1133.py b/authentik/flows/migrations/0003_auto_20200523_1133.py new file mode 100644 index 00000000..ef438835 --- /dev/null +++ b/authentik/flows/migrations/0003_auto_20200523_1133.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.6 on 2020-05-23 11:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="flow", + name="designation", + field=models.CharField( + choices=[ + ("authentication", "Authentication"), + ("authorization", "Authorization"), + ("invalidation", "Invalidation"), + ("enrollment", "Enrollment"), + ("unenrollment", "Unrenollment"), + ("recovery", "Recovery"), + ("password_change", "Password Change"), + ], + max_length=100, + ), + ), + ] diff --git a/authentik/flows/migrations/0006_auto_20200629_0857.py b/authentik/flows/migrations/0006_auto_20200629_0857.py new file mode 100644 index 00000000..2278bbf7 --- /dev/null +++ b/authentik/flows/migrations/0006_auto_20200629_0857.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.7 on 2020-06-29 08:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0003_auto_20200523_1133"), + ] + + operations = [ + migrations.AlterField( + model_name="flow", + name="designation", + field=models.CharField( + choices=[ + ("authentication", "Authentication"), + ("authorization", "Authorization"), + ("invalidation", "Invalidation"), + ("enrollment", "Enrollment"), + ("unenrollment", "Unrenollment"), + ("recovery", "Recovery"), + ("stage_setup", "Stage Setup"), + ], + max_length=100, + ), + ), + ] diff --git a/authentik/flows/migrations/0007_auto_20200703_2059.py b/authentik/flows/migrations/0007_auto_20200703_2059.py new file mode 100644 index 00000000..220d1cc2 --- /dev/null +++ b/authentik/flows/migrations/0007_auto_20200703_2059.py @@ -0,0 +1,47 @@ +# Generated by Django 3.0.7 on 2020-07-03 20:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies", "0002_auto_20200528_1647"), + ("authentik_flows", "0006_auto_20200629_0857"), + ] + + operations = [ + migrations.AlterModelOptions( + name="flowstagebinding", + options={ + "ordering": ["order", "target"], + "verbose_name": "Flow Stage Binding", + "verbose_name_plural": "Flow Stage Bindings", + }, + ), + migrations.RenameField( + model_name="flowstagebinding", + old_name="flow", + new_name="target", + ), + migrations.RenameField( + model_name="flow", + old_name="pbm", + new_name="policybindingmodel_ptr", + ), + migrations.AlterUniqueTogether( + name="flowstagebinding", + unique_together={("target", "stage", "order")}, + ), + migrations.AlterField( + model_name="flow", + name="policybindingmodel_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + to="authentik_policies.PolicyBindingModel", + ), + ), + ] diff --git a/authentik/flows/migrations/0008_default_flows.py b/authentik/flows/migrations/0008_default_flows.py new file mode 100644 index 00000000..d903d8b4 --- /dev/null +++ b/authentik/flows/migrations/0008_default_flows.py @@ -0,0 +1,113 @@ +# Generated by Django 3.0.3 on 2020-05-08 14:30 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation +from authentik.stages.identification.models import Templates, UserFields + + +def create_default_authentication_flow( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + PasswordStage = apps.get_model("authentik_stages_password", "PasswordStage") + UserLoginStage = apps.get_model("authentik_stages_user_login", "UserLoginStage") + IdentificationStage = apps.get_model( + "authentik_stages_identification", "IdentificationStage" + ) + db_alias = schema_editor.connection.alias + + identification_stage, _ = IdentificationStage.objects.using( + db_alias + ).update_or_create( + name="default-authentication-identification", + defaults={ + "user_fields": [UserFields.E_MAIL, UserFields.USERNAME], + "template": Templates.DEFAULT_LOGIN, + }, + ) + + password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create( + name="default-authentication-password", + defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]}, + ) + + login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( + name="default-authentication-login" + ) + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-authentication-flow", + designation=FlowDesignation.AUTHENTICATION, + defaults={ + "name": "Welcome to authentik!", + }, + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=identification_stage, + defaults={ + "order": 0, + }, + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=password_stage, + defaults={ + "order": 1, + }, + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=login_stage, + defaults={ + "order": 2, + }, + ) + + +def create_default_invalidation_flow( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + UserLogoutStage = apps.get_model("authentik_stages_user_logout", "UserLogoutStage") + db_alias = schema_editor.connection.alias + + UserLogoutStage.objects.using(db_alias).update_or_create( + name="default-invalidation-logout" + ) + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-invalidation-flow", + designation=FlowDesignation.INVALIDATION, + defaults={ + "name": "Logout", + }, + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=UserLogoutStage.objects.using(db_alias).first(), + defaults={ + "order": 0, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0007_auto_20200703_2059"), + ("authentik_stages_user_login", "0001_initial"), + ("authentik_stages_user_logout", "0001_initial"), + ("authentik_stages_password", "0001_initial"), + ("authentik_stages_identification", "0001_initial"), + ] + + operations = [ + migrations.RunPython(create_default_authentication_flow), + migrations.RunPython(create_default_invalidation_flow), + ] diff --git a/authentik/flows/migrations/0009_source_flows.py b/authentik/flows/migrations/0009_source_flows.py new file mode 100644 index 00000000..d441ceeb --- /dev/null +++ b/authentik/flows/migrations/0009_source_flows.py @@ -0,0 +1,158 @@ +# Generated by Django 3.0.6 on 2020-05-23 15:47 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation +from authentik.stages.prompt.models import FieldTypes + +FLOW_POLICY_EXPRESSION = """# This policy ensures that this flow can only be used when the user +# is in a SSO Flow (meaning they come from an external IdP) +return ak_is_sso_flow""" +PROMPT_POLICY_EXPRESSION = """# Check if we've not been given a username by the external IdP +# and trigger the enrollment flow +return 'username' not in context.get('prompt_data', {})""" + + +def create_default_source_enrollment_flow( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") + + ExpressionPolicy = apps.get_model( + "authentik_policies_expression", "ExpressionPolicy" + ) + + PromptStage = apps.get_model("authentik_stages_prompt", "PromptStage") + Prompt = apps.get_model("authentik_stages_prompt", "Prompt") + UserWriteStage = apps.get_model("authentik_stages_user_write", "UserWriteStage") + UserLoginStage = apps.get_model("authentik_stages_user_login", "UserLoginStage") + + db_alias = schema_editor.connection.alias + + # Create a policy that only allows this flow when doing an SSO Request + flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-source-enrollment-if-sso", + defaults={"expression": FLOW_POLICY_EXPRESSION}, + ) + + # This creates a Flow used by sources to enroll users + # It makes sure that a username is set, and if not, prompts the user for a Username + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-source-enrollment", + designation=FlowDesignation.ENROLLMENT, + defaults={ + "name": "Welcome to authentik!", + }, + ) + PolicyBinding.objects.using(db_alias).update_or_create( + policy=flow_policy, target=flow, defaults={"order": 0} + ) + + # PromptStage to ask user for their username + prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( + name="Welcome to authentik! Please select a username.", + ) + prompt, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="username", + defaults={ + "label": "Username", + "type": FieldTypes.TEXT, + "required": True, + "placeholder": "Username", + }, + ) + prompt_stage.fields.add(prompt) + + # Policy to only trigger prompt when no username is given + prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-source-enrollment-if-username", + defaults={"expression": PROMPT_POLICY_EXPRESSION}, + ) + + # UserWrite stage to create the user, and login stage to log user in + user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( + name="default-source-enrollment-write" + ) + user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create( + name="default-source-enrollment-login" + ) + + binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=prompt_stage, + defaults={"order": 0, "re_evaluate_policies": True}, + ) + PolicyBinding.objects.using(db_alias).update_or_create( + policy=prompt_policy, target=binding, defaults={"order": 0} + ) + + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, stage=user_write, defaults={"order": 1} + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, stage=user_login, defaults={"order": 2} + ) + + +def create_default_source_authentication_flow( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") + + ExpressionPolicy = apps.get_model( + "authentik_policies_expression", "ExpressionPolicy" + ) + + UserLoginStage = apps.get_model("authentik_stages_user_login", "UserLoginStage") + + db_alias = schema_editor.connection.alias + + # Create a policy that only allows this flow when doing an SSO Request + flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-source-authentication-if-sso", + defaults={ + "expression": FLOW_POLICY_EXPRESSION, + }, + ) + + # This creates a Flow used by sources to authenticate users + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-source-authentication", + designation=FlowDesignation.AUTHENTICATION, + defaults={ + "name": "Welcome to authentik!", + }, + ) + PolicyBinding.objects.using(db_alias).update_or_create( + policy=flow_policy, target=flow, defaults={"order": 0} + ) + + user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create( + name="default-source-authentication-login" + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, stage=user_login, defaults={"order": 0} + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0008_default_flows"), + ("authentik_policies", "0001_initial"), + ("authentik_policies_expression", "0001_initial"), + ("authentik_stages_prompt", "0001_initial"), + ("authentik_stages_user_write", "0001_initial"), + ("authentik_stages_user_login", "0001_initial"), + ] + + operations = [ + migrations.RunPython(create_default_source_enrollment_flow), + migrations.RunPython(create_default_source_authentication_flow), + ] diff --git a/authentik/flows/migrations/0010_provider_flows.py b/authentik/flows/migrations/0010_provider_flows.py new file mode 100644 index 00000000..b80e11b6 --- /dev/null +++ b/authentik/flows/migrations/0010_provider_flows.py @@ -0,0 +1,48 @@ +# Generated by Django 3.0.6 on 2020-05-24 11:34 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation + + +def create_default_provider_authorization_flow( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + + ConsentStage = apps.get_model("authentik_stages_consent", "ConsentStage") + + db_alias = schema_editor.connection.alias + + # Empty flow for providers where consent is implicitly given + Flow.objects.using(db_alias).update_or_create( + slug="default-provider-authorization-implicit-consent", + designation=FlowDesignation.AUTHORIZATION, + defaults={"name": "Authorize Application"}, + ) + + # Flow with consent form to obtain explicit user consent + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-provider-authorization-explicit-consent", + designation=FlowDesignation.AUTHORIZATION, + defaults={"name": "Authorize Application"}, + ) + stage, _ = ConsentStage.objects.using(db_alias).update_or_create( + name="default-provider-authorization-consent" + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, stage=stage, defaults={"order": 0} + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0009_source_flows"), + ("authentik_stages_consent", "0001_initial"), + ] + + operations = [migrations.RunPython(create_default_provider_authorization_flow)] diff --git a/authentik/flows/migrations/0011_flow_title.py b/authentik/flows/migrations/0011_flow_title.py new file mode 100644 index 00000000..06f89d86 --- /dev/null +++ b/authentik/flows/migrations/0011_flow_title.py @@ -0,0 +1,54 @@ +# Generated by Django 3.1 on 2020-08-28 13:14 +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def add_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + slug_title_map = { + "default-authentication-flow": "Welcome to authentik!", + "default-invalidation-flow": "Default Invalidation Flow", + "default-source-enrollment": "Welcome to authentik!", + "default-source-authentication": "Welcome to authentik!", + "default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)", + "default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)", + "default-password-change": "Change password", + } + db_alias = schema_editor.connection.alias + Flow = apps.get_model("authentik_flows", "Flow") + for flow in Flow.objects.using(db_alias).all(): + if flow.slug in slug_title_map: + flow.title = slug_title_map[flow.slug] + else: + flow.title = flow.name + flow.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0010_provider_flows"), + ] + + operations = [ + migrations.AlterModelOptions( + name="flow", + options={ + "permissions": [("export_flow", "Can export a Flow")], + "verbose_name": "Flow", + "verbose_name_plural": "Flows", + }, + ), + migrations.AddField( + model_name="flow", + name="title", + field=models.TextField(default="", blank=True), + preserve_default=False, + ), + migrations.RunPython(add_title_for_defaults), + migrations.AlterField( + model_name="flow", + name="title", + field=models.TextField(), + ), + ] diff --git a/authentik/flows/migrations/0012_auto_20200908_1542.py b/authentik/flows/migrations/0012_auto_20200908_1542.py new file mode 100644 index 00000000..2f19ab80 --- /dev/null +++ b/authentik/flows/migrations/0012_auto_20200908_1542.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.1 on 2020-09-08 15:42 + +import django.db.models.deletion +from django.db import migrations, models + +import authentik.lib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0011_flow_title"), + ] + + operations = [ + migrations.AlterField( + model_name="flowstagebinding", + name="stage", + field=authentik.lib.models.InheritanceForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="authentik_flows.stage" + ), + ), + migrations.AlterField( + model_name="stage", + name="name", + field=models.TextField(unique=True), + ), + ] diff --git a/authentik/flows/migrations/0013_auto_20200924_1605.py b/authentik/flows/migrations/0013_auto_20200924_1605.py new file mode 100644 index 00000000..138992a7 --- /dev/null +++ b/authentik/flows/migrations/0013_auto_20200924_1605.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.1 on 2020-09-24 16:05 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation + + +def update_flow_designation(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + Flow = apps.get_model("authentik_flows", "Flow") + db_alias = schema_editor.connection.alias + + for flow in Flow.objects.using(db_alias).all(): + if flow.designation == "stage_setup": + flow.designation = FlowDesignation.STAGE_CONFIGURATION + flow.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0012_auto_20200908_1542"), + ] + + operations = [ + migrations.AlterField( + model_name="flow", + name="designation", + field=models.CharField( + choices=[ + ("authentication", "Authentication"), + ("authorization", "Authorization"), + ("invalidation", "Invalidation"), + ("enrollment", "Enrollment"), + ("unenrollment", "Unrenollment"), + ("recovery", "Recovery"), + ("stage_configuration", "Stage Configuration"), + ], + max_length=100, + ), + ), + migrations.RunPython(update_flow_designation), + ] diff --git a/authentik/flows/migrations/0014_auto_20200925_2332.py b/authentik/flows/migrations/0014_auto_20200925_2332.py new file mode 100644 index 00000000..15f7f4d5 --- /dev/null +++ b/authentik/flows/migrations/0014_auto_20200925_2332.py @@ -0,0 +1,51 @@ +# Generated by Django 3.1.1 on 2020-09-25 23:32 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +# First stage for default-source-enrollment flow (prompt stage) +# needs to have its policy re-evaluated +def update_default_source_enrollment_flow_binding( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + db_alias = schema_editor.connection.alias + + flows = Flow.objects.using(db_alias).filter(slug="default-source-enrollment") + if not flows.exists(): + return + flow = flows.first() + + binding = FlowStageBinding.objects.get(target=flow, order=0) + binding.re_evaluate_policies = True + binding.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0013_auto_20200924_1605"), + ] + + operations = [ + migrations.AlterModelOptions( + name="flowstagebinding", + options={ + "ordering": ["target", "order"], + "verbose_name": "Flow Stage Binding", + "verbose_name_plural": "Flow Stage Bindings", + }, + ), + migrations.AlterField( + model_name="flowstagebinding", + name="re_evaluate_policies", + field=models.BooleanField( + default=False, + help_text="When this option is enabled, the planner will re-evaluate policies bound to this binding.", + ), + ), + migrations.RunPython(update_default_source_enrollment_flow_binding), + ] diff --git a/authentik/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py b/authentik/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py new file mode 100644 index 00000000..86d5d51a --- /dev/null +++ b/authentik/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.2 on 2020-10-20 12:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0014_auto_20200925_2332"), + ] + + operations = [ + migrations.AlterField( + model_name="flowstagebinding", + name="re_evaluate_policies", + field=models.BooleanField( + default=False, + help_text="Evaluate policies when the Stage is present to the user.", + ), + ), + migrations.AddField( + model_name="flowstagebinding", + name="evaluate_on_plan", + field=models.BooleanField( + default=True, + help_text="Evaluate policies during the Flow planning process. Disable this for input-based policies.", + ), + ), + ] diff --git a/authentik/flows/migrations/0016_auto_20201202_1307.py b/authentik/flows/migrations/0016_auto_20201202_1307.py new file mode 100644 index 00000000..dfc80fc8 --- /dev/null +++ b/authentik/flows/migrations/0016_auto_20201202_1307.py @@ -0,0 +1,50 @@ +# Generated by Django 3.1.3 on 2020-12-02 13:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0015_flowstagebinding_evaluate_on_plan"), + ] + + operations = [ + migrations.AddField( + model_name="flow", + name="background", + field=models.FileField( + blank=True, + default="../static/dist/assets/images/flow_background.jpg", + help_text="Background shown during execution", + upload_to="flow-backgrounds/", + ), + ), + migrations.AlterField( + model_name="flow", + name="designation", + field=models.CharField( + choices=[ + ("authentication", "Authentication"), + ("authorization", "Authorization"), + ("invalidation", "Invalidation"), + ("enrollment", "Enrollment"), + ("unenrollment", "Unrenollment"), + ("recovery", "Recovery"), + ("stage_configuration", "Stage Configuration"), + ], + help_text="Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik.", + max_length=100, + ), + ), + migrations.AlterField( + model_name="flow", + name="slug", + field=models.SlugField(help_text="Visible in the URL.", unique=True), + ), + migrations.AlterField( + model_name="flow", + name="title", + field=models.TextField(help_text="Shown as the Title in Flow pages."), + ), + ] diff --git a/passbook/flows/migrations/__init__.py b/authentik/flows/migrations/__init__.py similarity index 100% rename from passbook/flows/migrations/__init__.py rename to authentik/flows/migrations/__init__.py diff --git a/authentik/flows/models.py b/authentik/flows/models.py new file mode 100644 index 00000000..5cd69f21 --- /dev/null +++ b/authentik/flows/models.py @@ -0,0 +1,228 @@ +"""Flow models""" +from typing import TYPE_CHECKING, Optional, Type +from uuid import uuid4 + +from django.db import models +from django.forms import ModelForm +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ +from model_utils.managers import InheritanceManager +from rest_framework.serializers import BaseSerializer +from structlog import get_logger + +from authentik.lib.models import InheritanceForeignKey, SerializerModel +from authentik.policies.models import PolicyBindingModel + +if TYPE_CHECKING: + from authentik.flows.stage import StageView + +LOGGER = get_logger() + + +class NotConfiguredAction(models.TextChoices): + """Decides how the FlowExecutor should proceed when a stage isn't configured""" + + SKIP = "skip" + # CONFIGURE = "configure" + + +class FlowDesignation(models.TextChoices): + """Designation of what a Flow should be used for. At a later point, this + should be replaced by a database entry.""" + + AUTHENTICATION = "authentication" + AUTHORIZATION = "authorization" + INVALIDATION = "invalidation" + ENROLLMENT = "enrollment" + UNRENOLLMENT = "unenrollment" + RECOVERY = "recovery" + STAGE_CONFIGURATION = "stage_configuration" + + +class Stage(SerializerModel): + """Stage is an instance of a component used in a flow. This can verify the user, + enroll the user or offer a way of recovery""" + + stage_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + name = models.TextField(unique=True) + + objects = InheritanceManager() + + @property + def type(self) -> Type["StageView"]: + """Return StageView class that implements logic for this stage""" + # This is a bit of a workaround, since we can't set class methods with setattr + if hasattr(self, "__in_memory_type"): + return getattr(self, "__in_memory_type") + raise NotImplementedError + + @property + def form(self) -> Type[ModelForm]: + """Return Form class used to edit this object""" + raise NotImplementedError + + @property + def ui_user_settings(self) -> Optional[str]: + """Entrypoint to integrate with User settings. Can either return None if no + user settings are available, or a string with the URL to fetch.""" + return None + + def __str__(self): + if hasattr(self, "__in_memory_type"): + return f"In-memory Stage {getattr(self, '__in_memory_type')}" + return f"Stage {self.name}" + + +def in_memory_stage(view: Type["StageView"]) -> Stage: + """Creates an in-memory stage instance, based on a `_type` as view.""" + stage = Stage() + # Because we can't pickle a locally generated function, + # we set the view as a separate property and reference a generic function + # that returns that member + setattr(stage, "__in_memory_type", view) + return stage + + +class Flow(SerializerModel, PolicyBindingModel): + """Flow describes how a series of Stages should be executed to authenticate/enroll/recover + a user. Additionally, policies can be applied, to specify which users + have access to this flow.""" + + flow_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + name = models.TextField() + slug = models.SlugField(unique=True, help_text=_("Visible in the URL.")) + + title = models.TextField(help_text=_("Shown as the Title in Flow pages.")) + + designation = models.CharField( + max_length=100, + choices=FlowDesignation.choices, + help_text=_( + ( + "Decides what this Flow is used for. For example, the Authentication flow " + "is redirect to when an un-authenticated user visits authentik." + ) + ), + ) + + background = models.FileField( + upload_to="flow-backgrounds/", + default="../static/dist/assets/images/flow_background.jpg", + blank=True, + help_text=_("Background shown during execution"), + ) + + stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) + + @property + def serializer(self) -> BaseSerializer: + from authentik.flows.api import FlowSerializer + + return FlowSerializer + + @staticmethod + def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]: + """Get a Flow by `**flow_filter` and check if the request from `request` can access it.""" + from authentik.policies.engine import PolicyEngine + + flows = Flow.objects.filter(**flow_filter).order_by("slug") + for flow in flows: + engine = PolicyEngine(flow, request.user, request) + engine.build() + result = engine.result + if result.passing: + LOGGER.debug("with_policy: flow passing", flow=flow) + return flow + LOGGER.warning( + "with_policy: flow not passing", flow=flow, messages=result.messages + ) + LOGGER.debug("with_policy: no flow found", filters=flow_filter) + return None + + def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]: + """Get a related flow with `designation`. Currently this only queries + Flows by `designation`, but will eventually use `self` for related lookups.""" + return Flow.with_policy(request, designation=designation) + + def __str__(self) -> str: + return f"Flow {self.name} ({self.slug})" + + class Meta: + + verbose_name = _("Flow") + verbose_name_plural = _("Flows") + + permissions = [ + ("export_flow", "Can export a Flow"), + ] + + +class FlowStageBinding(SerializerModel, PolicyBindingModel): + """Relationship between Flow and Stage. Order is required and unique for + each flow-stage Binding. Additionally, policies can be specified, which determine if + this Binding applies to the current user""" + + fsb_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + target = models.ForeignKey("Flow", on_delete=models.CASCADE) + stage = InheritanceForeignKey(Stage, on_delete=models.CASCADE) + + evaluate_on_plan = models.BooleanField( + default=True, + help_text=_( + ( + "Evaluate policies during the Flow planning process. " + "Disable this for input-based policies." + ) + ), + ) + re_evaluate_policies = models.BooleanField( + default=False, + help_text=_("Evaluate policies when the Stage is present to the user."), + ) + + order = models.IntegerField() + + objects = InheritanceManager() + + @property + def serializer(self) -> BaseSerializer: + from authentik.flows.api import FlowStageBindingSerializer + + return FlowStageBindingSerializer + + def __str__(self) -> str: + return f"{self.target} #{self.order} -> {self.stage}" + + class Meta: + + ordering = ["target", "order"] + + verbose_name = _("Flow Stage Binding") + verbose_name_plural = _("Flow Stage Bindings") + unique_together = (("target", "stage", "order"),) + + +class ConfigurableStage(models.Model): + """Abstract base class for a Stage that can be configured by the enduser. + The stage should create a default flow with the configure_stage designation during + migration.""" + + configure_flow = models.ForeignKey( + Flow, + on_delete=models.SET_NULL, + null=True, + 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." + ) + ), + ) + + class Meta: + + abstract = True diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py new file mode 100644 index 00000000..bcc7bd9d --- /dev/null +++ b/authentik/flows/planner.py @@ -0,0 +1,201 @@ +"""Flows Planner""" +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from django.core.cache import cache +from django.http import HttpRequest +from sentry_sdk.hub import Hub +from sentry_sdk.tracing import Span +from structlog import get_logger + +from authentik.audit.models import cleanse_dict +from authentik.core.models import User +from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException +from authentik.flows.markers import ReevaluateMarker, StageMarker +from authentik.flows.models import Flow, FlowStageBinding, Stage +from authentik.policies.engine import PolicyEngine + +LOGGER = get_logger() + +PLAN_CONTEXT_PENDING_USER = "pending_user" +PLAN_CONTEXT_SSO = "is_sso" +PLAN_CONTEXT_APPLICATION = "application" + + +def cache_key(flow: Flow, user: Optional[User] = None) -> str: + """Generate Cache key for flow""" + prefix = f"flow_{flow.pk}" + if user: + prefix += f"#{user.pk}" + return prefix + + +@dataclass +class FlowPlan: + """This data-class is the output of a FlowPlanner. It holds a flat list + of all Stages that should be run.""" + + flow_pk: str + + stages: List[Stage] = field(default_factory=list) + context: Dict[str, Any] = field(default_factory=dict) + markers: List[StageMarker] = field(default_factory=list) + + def append(self, stage: Stage, marker: Optional[StageMarker] = None): + """Append `stage` to all stages, optionall with stage marker""" + self.stages.append(stage) + self.markers.append(marker or StageMarker()) + + def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]: + """Return next pending stage from the bottom of the list""" + if not self.has_stages: + return None + stage = self.stages[0] + marker = self.markers[0] + + if marker.__class__ is not StageMarker: + LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker) + marked_stage = marker.process(self, stage, http_request) + if not marked_stage: + LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage) + self.stages.remove(stage) + self.markers.remove(marker) + if not self.has_stages: + return None + # pylint: disable=not-callable + return self.next(http_request) + return marked_stage + + def pop(self): + """Pop next pending stage from bottom of list""" + self.markers.pop(0) + self.stages.pop(0) + + @property + def has_stages(self) -> bool: + """Check if there are any stages left in this plan""" + return len(self.markers) + len(self.stages) > 0 + + +class FlowPlanner: + """Execute all policies to plan out a flat list of all Stages + that should be applied.""" + + use_cache: bool + allow_empty_flows: bool + + flow: Flow + + def __init__(self, flow: Flow): + self.use_cache = True + self.allow_empty_flows = False + self.flow = flow + + def plan( + self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None + ) -> FlowPlan: + """Check each of the flows' policies, check policies for each stage with PolicyBinding + and return ordered list""" + with Hub.current.start_span(op="flow.planner.plan") as span: + span: Span + span.set_data("flow", self.flow) + span.set_data("request", request) + + LOGGER.debug("f(plan): Starting planning process", flow=self.flow) + # Bit of a workaround here, if there is a pending user set in the default context + # we use that user for our cache key + # to make sure they don't get the generic response + if default_context and PLAN_CONTEXT_PENDING_USER in default_context: + user = default_context[PLAN_CONTEXT_PENDING_USER] + else: + user = request.user + # First off, check the flow's direct policy bindings + # to make sure the user even has access to the flow + engine = PolicyEngine(self.flow, user, request) + if default_context: + span.set_data("default_context", cleanse_dict(default_context)) + engine.request.context = default_context + engine.build() + result = engine.result + if not result.passing: + raise FlowNonApplicableException(result.messages) + # User is passing so far, check if we have a cached plan + cached_plan_key = cache_key(self.flow, user) + cached_plan = cache.get(cached_plan_key, None) + if cached_plan and self.use_cache: + LOGGER.debug( + "f(plan): Taking plan from cache", + flow=self.flow, + key=cached_plan_key, + ) + # Reset the context as this isn't factored into caching + cached_plan.context = default_context or {} + return cached_plan + LOGGER.debug("f(plan): building plan", flow=self.flow) + plan = self._build_plan(user, request, default_context) + cache.set(cache_key(self.flow, user), plan) + if not plan.stages and not self.allow_empty_flows: + raise EmptyFlowException() + return plan + + def _build_plan( + self, + user: User, + request: HttpRequest, + default_context: Optional[Dict[str, Any]], + ) -> FlowPlan: + """Build flow plan by checking each stage in their respective + order and checking the applied policies""" + with Hub.current.start_span(op="flow.planner.build_plan") as span: + span: Span + span.set_data("flow", self.flow) + span.set_data("user", user) + span.set_data("request", request) + + plan = FlowPlan(flow_pk=self.flow.pk.hex) + if default_context: + plan.context = default_context + # Check Flow policies + for binding in FlowStageBinding.objects.filter( + target__pk=self.flow.pk + ).order_by("order"): + binding: FlowStageBinding + stage = binding.stage + marker = StageMarker() + if binding.evaluate_on_plan: + LOGGER.debug( + "f(plan): evaluating on plan", + stage=binding.stage, + flow=self.flow, + ) + engine = PolicyEngine(binding, user, request) + engine.request.context = plan.context + engine.build() + if engine.passing: + LOGGER.debug( + "f(plan): Stage passing", + stage=binding.stage, + flow=self.flow, + ) + else: + stage = None + else: + LOGGER.debug( + "f(plan): not evaluating on plan", + stage=binding.stage, + flow=self.flow, + ) + if binding.re_evaluate_policies and stage: + LOGGER.debug( + "f(plan): Stage has re-evaluate marker", + stage=binding.stage, + flow=self.flow, + ) + marker = ReevaluateMarker(binding=binding, user=user) + if stage: + plan.append(stage, marker) + LOGGER.debug( + "f(plan): Finished building", + flow=self.flow, + ) + return plan diff --git a/authentik/flows/signals.py b/authentik/flows/signals.py new file mode 100644 index 00000000..c1c0c527 --- /dev/null +++ b/authentik/flows/signals.py @@ -0,0 +1,37 @@ +"""authentik flow signals""" +from django.core.cache import cache +from django.db.models.signals import post_save +from django.dispatch import receiver +from structlog import get_logger + +LOGGER = get_logger() + + +def delete_cache_prefix(prefix: str) -> int: + """Delete keys prefixed with `prefix` and return count of deleted keys.""" + keys = cache.keys(prefix) + cache.delete_many(keys) + return len(keys) + + +@receiver(post_save) +# pylint: disable=unused-argument +def invalidate_flow_cache(sender, instance, **_): + """Invalidate flow cache when flow is updated""" + from authentik.flows.models import Flow, FlowStageBinding, Stage + from authentik.flows.planner import cache_key + + if isinstance(instance, Flow): + total = delete_cache_prefix(f"{cache_key(instance)}*") + LOGGER.debug("Invalidating Flow cache", flow=instance, len=total) + if isinstance(instance, FlowStageBinding): + total = delete_cache_prefix(f"{cache_key(instance.target)}*") + LOGGER.debug( + "Invalidating Flow cache from FlowStageBinding", binding=instance, len=total + ) + if isinstance(instance, Stage): + total = 0 + for binding in FlowStageBinding.objects.filter(stage=instance): + prefix = cache_key(binding.target) + total += delete_cache_prefix(f"{prefix}*") + LOGGER.debug("Invalidating Flow cache from Stage", stage=instance, len=total) diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py new file mode 100644 index 00000000..edef963d --- /dev/null +++ b/authentik/flows/stage.py @@ -0,0 +1,29 @@ +"""authentik stage Base view""" +from typing import Any, Dict + +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView + +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.views import FlowExecutorView + + +class StageView(TemplateView): + """Abstract Stage, inherits TemplateView but can be combined with FormView""" + + template_name = "login/form_with_user.html" + + executor: FlowExecutorView + + request: HttpRequest = None + + def __init__(self, executor: FlowExecutorView): + self.executor = executor + + def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: + kwargs["title"] = self.executor.flow.title + if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: + kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + kwargs["primary_action"] = _("Continue") + return super().get_context_data(**kwargs) diff --git a/authentik/flows/templates/flows/denied_shell.html b/authentik/flows/templates/flows/denied_shell.html new file mode 100644 index 00000000..768c4b02 --- /dev/null +++ b/authentik/flows/templates/flows/denied_shell.html @@ -0,0 +1,57 @@ +{% extends 'login/base.html' %} + +{% load static %} +{% load i18n %} +{% load authentik_utils %} + +{% block card_title %} +{% trans 'Permission denied' %} +{% endblock %} + +{% block title %} +{% trans 'Permission denied' %} +{% endblock %} + +{% block card %} +
+ {% csrf_token %} + {% include 'partials/form.html' %} +
+

+ + {% trans 'Request has been denied.' %} +

+ {% if error %} +
+

+ {{ error }} +

+ {% endif %} + {% if policy_result %} +
+ + {% trans 'Explanation:' %} + +
    + {% for source_result in policy_result.source_results %} +
  • + {% blocktrans with name=source_result.source_policy.name result=source_result.passing %} + Policy '{{ name }}' returned result '{{ result }}' + {% endblocktrans %} + {% if source_result.messages %} +
      + {% for message in source_result.messages %} +
    • {{ message }}
    • + {% endfor %} +
    + {% endif %} +
  • + {% endfor %} +
+ {% endif %} +
+ {% if 'back' in request.GET %} + {% trans 'Back' %} + {% endif %} +
+{% endblock %} diff --git a/authentik/flows/templates/flows/error.html b/authentik/flows/templates/flows/error.html new file mode 100644 index 00000000..1f729ca3 --- /dev/null +++ b/authentik/flows/templates/flows/error.html @@ -0,0 +1,22 @@ +{% load i18n %} + + + + + diff --git a/authentik/flows/templates/flows/shell.html b/authentik/flows/templates/flows/shell.html new file mode 100644 index 00000000..2d23dbe1 --- /dev/null +++ b/authentik/flows/templates/flows/shell.html @@ -0,0 +1,32 @@ +{% extends 'login/base_full.html' %} + +{% load static %} +{% load i18n %} + +{% block head %} +{{ block.super }} + +{% endblock %} + +{% block main_container %} + +{% endblock %} diff --git a/passbook/flows/tests/__init__.py b/authentik/flows/tests/__init__.py similarity index 100% rename from passbook/flows/tests/__init__.py rename to authentik/flows/tests/__init__.py diff --git a/authentik/flows/tests/test_misc.py b/authentik/flows/tests/test_misc.py new file mode 100644 index 00000000..7546f4c8 --- /dev/null +++ b/authentik/flows/tests/test_misc.py @@ -0,0 +1,25 @@ +"""miscellaneous flow tests""" +from django.test import TestCase + +from authentik.flows.api import StageSerializer, StageViewSet +from authentik.flows.models import Stage +from authentik.stages.dummy.models import DummyStage + + +class TestFlowsMisc(TestCase): + """miscellaneous tests""" + + def test_models(self): + """Test that ui_user_settings returns none""" + self.assertIsNone(Stage().ui_user_settings) + + def test_api_serializer(self): + """Test that stage serializer returns the correct type""" + obj = DummyStage() + self.assertEqual(StageSerializer().get_type(obj), "dummy") + self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage") + + def test_api_viewset(self): + """Test that stage serializer returns the correct type""" + dummy = DummyStage.objects.create() + self.assertIn(dummy, StageViewSet().get_queryset()) diff --git a/authentik/flows/tests/test_models.py b/authentik/flows/tests/test_models.py new file mode 100644 index 00000000..864518c3 --- /dev/null +++ b/authentik/flows/tests/test_models.py @@ -0,0 +1,31 @@ +"""flow model tests""" +from typing import Callable, Type + +from django.forms import ModelForm +from django.test import TestCase + +from authentik.flows.models import Stage +from authentik.flows.stage import StageView + + +class TestStageProperties(TestCase): + """Generic model properties tests""" + + +def stage_tester_factory(model: Type[Stage]) -> Callable: + """Test a form""" + + def tester(self: TestStageProperties): + model_inst = model() + self.assertTrue(issubclass(model_inst.form, ModelForm)) + self.assertTrue(issubclass(model_inst.type, StageView)) + + return tester + + +for stage_type in Stage.__subclasses__(): + setattr( + TestStageProperties, + f"test_stage_{stage_type.__name__}", + stage_tester_factory(stage_type), + ) diff --git a/authentik/flows/tests/test_planner.py b/authentik/flows/tests/test_planner.py new file mode 100644 index 00000000..415f773f --- /dev/null +++ b/authentik/flows/tests/test_planner.py @@ -0,0 +1,189 @@ +"""flow planner tests""" +from unittest.mock import MagicMock, Mock, PropertyMock, patch + +from django.contrib.sessions.middleware import SessionMiddleware +from django.core.cache import cache +from django.http import HttpRequest +from django.shortcuts import reverse +from django.test import RequestFactory, TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.core.models import User +from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException +from authentik.flows.markers import ReevaluateMarker, StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.models import PolicyBinding +from authentik.policies.types import PolicyResult +from authentik.stages.dummy.models import DummyStage + +POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) +CACHE_MOCK = Mock(wraps=cache) + +POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) + + +def dummy_get_response(request: HttpRequest): # pragma: no cover + """Dummy get_response for SessionMiddleware""" + return None + + +class TestFlowPlanner(TestCase): + """Test planner logic""" + + def setUp(self): + self.request_factory = RequestFactory() + + def test_empty_plan(self): + """Test that empty plan raises exception""" + flow = Flow.objects.create( + name="test-empty", + slug="test-empty", + designation=FlowDesignation.AUTHENTICATION, + ) + request = self.request_factory.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = get_anonymous_user() + + with self.assertRaises(EmptyFlowException): + planner = FlowPlanner(flow) + planner.plan(request) + + @patch( + "authentik.policies.engine.PolicyEngine.result", + POLICY_RETURN_FALSE, + ) + def test_non_applicable_plan(self): + """Test that empty plan raises exception""" + flow = Flow.objects.create( + name="test-empty", + slug="test-empty", + designation=FlowDesignation.AUTHENTICATION, + ) + request = self.request_factory.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = get_anonymous_user() + + with self.assertRaises(FlowNonApplicableException): + planner = FlowPlanner(flow) + planner.plan(request) + + @patch("authentik.flows.planner.cache", CACHE_MOCK) + def test_planner_cache(self): + """Test planner cache""" + flow = Flow.objects.create( + name="test-cache", + slug="test-cache", + designation=FlowDesignation.AUTHENTICATION, + ) + FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy"), order=0 + ) + request = self.request_factory.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = get_anonymous_user() + + planner = FlowPlanner(flow) + planner.plan(request) + self.assertEqual( + CACHE_MOCK.set.call_count, 1 + ) # Ensure plan is written to cache + planner = FlowPlanner(flow) + planner.plan(request) + self.assertEqual( + CACHE_MOCK.set.call_count, 1 + ) # Ensure nothing is written to cache + self.assertEqual(CACHE_MOCK.get.call_count, 2) # Get is called twice + + def test_planner_default_context(self): + """Test planner with default_context""" + flow = Flow.objects.create( + name="test-default-context", + slug="test-default-context", + designation=FlowDesignation.AUTHENTICATION, + ) + FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy"), order=0 + ) + + user = User.objects.create(username="test-user") + request = self.request_factory.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = user + planner = FlowPlanner(flow) + planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user}) + key = cache_key(flow, user) + self.assertTrue(cache.get(key) is not None) + + def test_planner_marker_reevaluate(self): + """Test that the planner creates the proper marker""" + flow = Flow.objects.create( + name="test-default-context", + slug="test-default-context", + designation=FlowDesignation.AUTHENTICATION, + ) + + FlowStageBinding.objects.create( + target=flow, + stage=DummyStage.objects.create(name="dummy1"), + order=0, + re_evaluate_policies=True, + ) + + request = self.request_factory.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = get_anonymous_user() + + planner = FlowPlanner(flow) + plan = planner.plan(request) + + self.assertIsInstance(plan.markers[0], ReevaluateMarker) + + def test_planner_reevaluate_actual(self): + """Test planner with re-evaluate""" + flow = Flow.objects.create( + name="test-default-context", + slug="test-default-context", + designation=FlowDesignation.AUTHENTICATION, + ) + false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) + + binding = FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 + ) + binding2 = FlowStageBinding.objects.create( + target=flow, + stage=DummyStage.objects.create(name="dummy2"), + order=1, + re_evaluate_policies=True, + ) + + PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) + + request = self.request_factory.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = get_anonymous_user() + + middleware = SessionMiddleware(dummy_get_response) + middleware.process_request(request) + request.session.save() + + # Here we patch the dummy policy to evaluate to true so the stage is included + with patch( + "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE + ): + planner = FlowPlanner(flow) + plan = planner.plan(request) + + self.assertEqual(plan.stages[0], binding.stage) + self.assertEqual(plan.stages[1], binding2.stage) + + self.assertIsInstance(plan.markers[0], StageMarker) + self.assertIsInstance(plan.markers[1], ReevaluateMarker) diff --git a/authentik/flows/tests/test_transfer.py b/authentik/flows/tests/test_transfer.py new file mode 100644 index 00000000..f268232e --- /dev/null +++ b/authentik/flows/tests/test_transfer.py @@ -0,0 +1,136 @@ +"""Test flow transfer""" +from json import dumps + +from django.test import TransactionTestCase + +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.transfer.common import DataclassEncoder +from authentik.flows.transfer.exporter import FlowExporter +from authentik.flows.transfer.importer import FlowImporter, transaction_rollback +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.generators import generate_client_id +from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage +from authentik.stages.user_login.models import UserLoginStage + + +class TestFlowTransfer(TransactionTestCase): + """Test flow transfer""" + + def test_bundle_invalid_format(self): + """Test bundle with invalid format""" + importer = FlowImporter('{"version": 3}') + self.assertFalse(importer.validate()) + importer = FlowImporter( + ( + '{"version": 1,"entries":[{"identifiers":{},"attrs":{},' + '"model": "authentik_core.User"}]}' + ) + ) + self.assertFalse(importer.validate()) + + def test_export_validate_import(self): + """Test export and validate it""" + flow_slug = generate_client_id() + with transaction_rollback(): + login_stage = UserLoginStage.objects.create(name=generate_client_id()) + + flow = Flow.objects.create( + slug=flow_slug, + designation=FlowDesignation.AUTHENTICATION, + name=generate_client_id(), + title=generate_client_id(), + ) + FlowStageBinding.objects.update_or_create( + target=flow, + stage=login_stage, + order=0, + ) + + exporter = FlowExporter(flow) + export = exporter.export() + self.assertEqual(len(export.entries), 3) + export_json = exporter.export_to_string() + + importer = FlowImporter(export_json) + self.assertTrue(importer.validate()) + self.assertTrue(importer.apply()) + + self.assertTrue(Flow.objects.filter(slug=flow_slug).exists()) + + def test_export_validate_import_policies(self): + """Test export and validate it""" + flow_slug = generate_client_id() + stage_name = generate_client_id() + with transaction_rollback(): + flow_policy = ExpressionPolicy.objects.create( + name=generate_client_id(), + expression="return True", + ) + flow = Flow.objects.create( + slug=flow_slug, + designation=FlowDesignation.AUTHENTICATION, + name=generate_client_id(), + title=generate_client_id(), + ) + PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0) + + user_login = UserLoginStage.objects.create(name=stage_name) + fsb = FlowStageBinding.objects.create( + target=flow, stage=user_login, order=0 + ) + PolicyBinding.objects.create(policy=flow_policy, target=fsb, order=0) + + exporter = FlowExporter(flow) + export = exporter.export() + + export_json = dumps(export, cls=DataclassEncoder) + + importer = FlowImporter(export_json) + self.assertTrue(importer.validate()) + self.assertTrue(importer.apply()) + self.assertTrue(UserLoginStage.objects.filter(name=stage_name).exists()) + self.assertTrue(Flow.objects.filter(slug=flow_slug).exists()) + + def test_export_validate_import_prompt(self): + """Test export and validate it""" + with transaction_rollback(): + # First stage fields + username_prompt = Prompt.objects.create( + field_key="username", label="Username", order=0, type=FieldTypes.TEXT + ) + password = Prompt.objects.create( + field_key="password", + label="Password", + order=1, + type=FieldTypes.PASSWORD, + ) + password_repeat = Prompt.objects.create( + field_key="password_repeat", + label="Password (repeat)", + order=2, + type=FieldTypes.PASSWORD, + ) + + # Stages + first_stage = PromptStage.objects.create(name=generate_client_id()) + first_stage.fields.set([username_prompt, password, password_repeat]) + first_stage.save() + + flow = Flow.objects.create( + name=generate_client_id(), + slug=generate_client_id(), + designation=FlowDesignation.ENROLLMENT, + title=generate_client_id(), + ) + + FlowStageBinding.objects.create(target=flow, stage=first_stage, order=0) + + exporter = FlowExporter(flow) + export = exporter.export() + export_json = dumps(export, cls=DataclassEncoder) + + importer = FlowImporter(export_json) + + self.assertTrue(importer.validate()) + self.assertTrue(importer.apply()) diff --git a/authentik/flows/tests/test_transfer_docs.py b/authentik/flows/tests/test_transfer_docs.py new file mode 100644 index 00000000..debd3a1d --- /dev/null +++ b/authentik/flows/tests/test_transfer_docs.py @@ -0,0 +1,29 @@ +"""test example flows in docs""" +from glob import glob +from pathlib import Path +from typing import Callable + +from django.test import TransactionTestCase + +from authentik.flows.transfer.importer import FlowImporter + + +class TestTransferDocs(TransactionTestCase): + """Empty class, test methods are added dynamically""" + + +def pbflow_tester(file_name: str) -> Callable: + """This is used instead of subTest for better visibility""" + + def tester(self: TestTransferDocs): + with open(file_name, "r") as flow_json: + importer = FlowImporter(flow_json.read()) + self.assertTrue(importer.validate()) + self.assertTrue(importer.apply()) + + return tester + + +for flow_file in glob("website/static/flows/*.pbflow"): + method_name = Path(flow_file).stem.replace("-", "_").replace(".", "_") + setattr(TestTransferDocs, f"test_flow_{method_name}", pbflow_tester(flow_file)) diff --git a/authentik/flows/tests/test_views.py b/authentik/flows/tests/test_views.py new file mode 100644 index 00000000..ce10a2c9 --- /dev/null +++ b/authentik/flows/tests/test_views.py @@ -0,0 +1,353 @@ +"""flow views tests""" +from unittest.mock import MagicMock, PropertyMock, patch + +from django.http import HttpRequest, HttpResponse +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException +from authentik.flows.markers import ReevaluateMarker, StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import FlowPlan +from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN +from authentik.lib.config import CONFIG +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.http import AccessDeniedResponse +from authentik.policies.models import PolicyBinding +from authentik.policies.types import PolicyResult +from authentik.stages.dummy.models import DummyStage + +POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) +POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) + + +def to_stage_response(request: HttpRequest, source: HttpResponse): + """Mock for to_stage_response that returns the original response, so we can check + inheritance and member attributes""" + return source + + +TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response) + + +class TestFlowExecutor(TestCase): + """Test views logic""" + + def setUp(self): + self.client = Client() + + def test_existing_plan_diff_flow(self): + """Check that a plan for a different flow cancels the current plan""" + flow = Flow.objects.create( + name="test-existing-plan-diff", + slug="test-existing-plan-diff", + designation=FlowDesignation.AUTHENTICATION, + ) + stage = DummyStage.objects.create(name="dummy") + plan = FlowPlan( + flow_pk=flow.pk.hex + "a", stages=[stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + cancel_mock = MagicMock() + with patch("authentik.flows.views.FlowExecutorView.cancel", cancel_mock): + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug} + ), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(cancel_mock.call_count, 2) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + @patch( + "authentik.policies.engine.PolicyEngine.result", + POLICY_RETURN_FALSE, + ) + def test_invalid_non_applicable_flow(self): + """Tests that a non-applicable flow returns the correct error message""" + flow = Flow.objects.create( + name="test-non-applicable", + slug="test-non-applicable", + designation=FlowDesignation.AUTHENTICATION, + ) + + CONFIG.update_from_dict({"domain": "testserver"}) + response = self.client.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_invalid_empty_flow(self): + """Tests that an empty flow returns the correct error message""" + flow = Flow.objects.create( + name="test-empty", + slug="test-empty", + designation=FlowDesignation.AUTHENTICATION, + ) + + CONFIG.update_from_dict({"domain": "testserver"}) + response = self.client.get( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content) + + def test_invalid_flow_redirect(self): + """Tests that an invalid flow still redirects""" + flow = Flow.objects.create( + name="test-empty", + slug="test-empty", + designation=FlowDesignation.AUTHENTICATION, + ) + + CONFIG.update_from_dict({"domain": "testserver"}) + dest = "/unique-string" + url = reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}) + response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}") + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": dest}, + ) + + def test_multi_stage_flow(self): + """Test a full flow with multiple stages""" + flow = Flow.objects.create( + name="test-full", + slug="test-full", + designation=FlowDesignation.AUTHENTICATION, + ) + FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 + ) + FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1 + ) + + exec_url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug} + ) + # First Request, start planning, renders form + response = self.client.get(exec_url) + self.assertEqual(response.status_code, 200) + # Check that two stages are in plan + session = self.client.session + plan: FlowPlan = session[SESSION_KEY_PLAN] + self.assertEqual(len(plan.stages), 2) + # Second request, submit form, one stage left + response = self.client.post(exec_url) + # Second request redirects to the same URL + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, exec_url) + # Check that two stages are in plan + session = self.client.session + plan: FlowPlan = session[SESSION_KEY_PLAN] + self.assertEqual(len(plan.stages), 1) + + def test_reevaluate_remove_last(self): + """Test planner with re-evaluate (last stage is removed)""" + flow = Flow.objects.create( + name="test-default-context", + slug="test-default-context", + designation=FlowDesignation.AUTHENTICATION, + ) + false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) + + binding = FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 + ) + binding2 = FlowStageBinding.objects.create( + target=flow, + stage=DummyStage.objects.create(name="dummy2"), + order=1, + re_evaluate_policies=True, + ) + + PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) + + # Here we patch the dummy policy to evaluate to true so the stage is included + with patch( + "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE + ): + + exec_url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug} + ) + # First request, run the planner + response = self.client.get(exec_url) + self.assertEqual(response.status_code, 200) + + plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] + + self.assertEqual(plan.stages[0], binding.stage) + self.assertEqual(plan.stages[1], binding2.stage) + + self.assertIsInstance(plan.markers[0], StageMarker) + self.assertIsInstance(plan.markers[1], ReevaluateMarker) + + # Second request, this passes the first dummy stage + response = self.client.post(exec_url) + self.assertEqual(response.status_code, 302) + + # third request, this should trigger the re-evaluate + # We do this request without the patch, so the policy results in false + response = self.client.post(exec_url) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse("authentik_core:shell")) + + def test_reevaluate_remove_middle(self): + """Test planner with re-evaluate (middle stage is removed)""" + flow = Flow.objects.create( + name="test-default-context", + slug="test-default-context", + designation=FlowDesignation.AUTHENTICATION, + ) + false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) + + binding = FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 + ) + binding2 = FlowStageBinding.objects.create( + target=flow, + stage=DummyStage.objects.create(name="dummy2"), + order=1, + re_evaluate_policies=True, + ) + binding3 = FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2 + ) + + PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) + + # Here we patch the dummy policy to evaluate to true so the stage is included + with patch( + "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE + ): + + exec_url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug} + ) + # First request, run the planner + response = self.client.get(exec_url) + + self.assertEqual(response.status_code, 200) + plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] + + self.assertEqual(plan.stages[0], binding.stage) + self.assertEqual(plan.stages[1], binding2.stage) + self.assertEqual(plan.stages[2], binding3.stage) + + self.assertIsInstance(plan.markers[0], StageMarker) + self.assertIsInstance(plan.markers[1], ReevaluateMarker) + self.assertIsInstance(plan.markers[2], StageMarker) + + # Second request, this passes the first dummy stage + response = self.client.post(exec_url) + self.assertEqual(response.status_code, 302) + + plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] + + self.assertEqual(plan.stages[0], binding2.stage) + self.assertEqual(plan.stages[1], binding3.stage) + + self.assertIsInstance(plan.markers[0], StageMarker) + self.assertIsInstance(plan.markers[1], StageMarker) + + # third request, this should trigger the re-evaluate + # We do this request without the patch, so the policy results in false + response = self.client.post(exec_url) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + def test_reevaluate_remove_consecutive(self): + """Test planner with re-evaluate (consecutive stages are removed)""" + flow = Flow.objects.create( + name="test-default-context", + slug="test-default-context", + designation=FlowDesignation.AUTHENTICATION, + ) + false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) + + binding = FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 + ) + binding2 = FlowStageBinding.objects.create( + target=flow, + stage=DummyStage.objects.create(name="dummy2"), + order=1, + re_evaluate_policies=True, + ) + binding3 = FlowStageBinding.objects.create( + target=flow, + stage=DummyStage.objects.create(name="dummy3"), + order=2, + re_evaluate_policies=True, + ) + binding4 = FlowStageBinding.objects.create( + target=flow, stage=DummyStage.objects.create(name="dummy4"), order=2 + ) + + PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) + PolicyBinding.objects.create(policy=false_policy, target=binding3, order=0) + + # Here we patch the dummy policy to evaluate to true so the stage is included + with patch( + "authentik.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE + ): + + exec_url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug} + ) + # First request, run the planner + response = self.client.get(exec_url) + self.assertEqual(response.status_code, 200) + self.assertIn("dummy1", force_str(response.content)) + + plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] + + self.assertEqual(plan.stages[0], binding.stage) + self.assertEqual(plan.stages[1], binding2.stage) + self.assertEqual(plan.stages[2], binding3.stage) + self.assertEqual(plan.stages[3], binding4.stage) + + self.assertIsInstance(plan.markers[0], StageMarker) + self.assertIsInstance(plan.markers[1], ReevaluateMarker) + self.assertIsInstance(plan.markers[2], ReevaluateMarker) + self.assertIsInstance(plan.markers[3], StageMarker) + + # Second request, this passes the first dummy stage + response = self.client.post(exec_url) + self.assertEqual(response.status_code, 302) + + # third request, this should trigger the re-evaluate + # A get request will evaluate the policies and this will return stage 4 + # but it won't save it, hence we cant' check the plan + response = self.client.get(exec_url) + self.assertEqual(response.status_code, 200) + self.assertIn("dummy4", force_str(response.content)) + + # fourth request, this confirms the last stage (dummy4) + # We do this request without the patch, so the policy results in false + response = self.client.post(exec_url) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) diff --git a/authentik/flows/tests/test_views_helper.py b/authentik/flows/tests/test_views_helper.py new file mode 100644 index 00000000..3a5af523 --- /dev/null +++ b/authentik/flows/tests/test_views_helper.py @@ -0,0 +1,47 @@ +"""flow views tests""" +from django.shortcuts import reverse +from django.test import Client, TestCase + +from authentik.flows.models import Flow, FlowDesignation +from authentik.flows.planner import FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN + + +class TestHelperView(TestCase): + """Test helper views logic""" + + def setUp(self): + self.client = Client() + + def test_default_view(self): + """Test that ToDefaultFlow returns the expected URL""" + flow = Flow.objects.filter( + designation=FlowDesignation.INVALIDATION, + ).first() + response = self.client.get( + reverse("authentik_flows:default-invalidation"), + ) + expected_url = reverse( + "authentik_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug} + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, expected_url) + + def test_default_view_invalid_plan(self): + """Test that ToDefaultFlow returns the expected URL (with an invalid plan)""" + flow = Flow.objects.filter( + designation=FlowDesignation.INVALIDATION, + ).first() + plan = FlowPlan(flow_pk=flow.pk.hex + "aa") + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse("authentik_flows:default-invalidation"), + ) + expected_url = reverse( + "authentik_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug} + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, expected_url) diff --git a/passbook/flows/transfer/__init__.py b/authentik/flows/transfer/__init__.py similarity index 100% rename from passbook/flows/transfer/__init__.py rename to authentik/flows/transfer/__init__.py diff --git a/authentik/flows/transfer/common.py b/authentik/flows/transfer/common.py new file mode 100644 index 00000000..dda3a50e --- /dev/null +++ b/authentik/flows/transfer/common.py @@ -0,0 +1,68 @@ +"""transfer common classes""" +from dataclasses import asdict, dataclass, field, is_dataclass +from json.encoder import JSONEncoder +from typing import Any, Dict, List +from uuid import UUID + +from authentik.lib.models import SerializerModel +from authentik.lib.sentry import SentryIgnoredException + + +def get_attrs(obj: SerializerModel) -> Dict[str, Any]: + """Get object's attributes via their serializer, and covert it to a normal dict""" + data = dict(obj.serializer(obj).data) + to_remove = ("policies", "stages", "pk", "background") + for to_remove_name in to_remove: + if to_remove_name in data: + data.pop(to_remove_name) + return data + + +@dataclass +class FlowBundleEntry: + """Single entry of a bundle""" + + identifiers: Dict[str, Any] + model: str + attrs: Dict[str, Any] + + @staticmethod + def from_model( + model: SerializerModel, *extra_identifier_names: str + ) -> "FlowBundleEntry": + """Convert a SerializerModel instance to a Bundle Entry""" + identifiers = { + "pk": model.pk, + } + all_attrs = get_attrs(model) + + for extra_identifier_name in extra_identifier_names: + identifiers[extra_identifier_name] = all_attrs.pop(extra_identifier_name) + return FlowBundleEntry( + identifiers=identifiers, + model=f"{model._meta.app_label}.{model._meta.model_name}", + attrs=all_attrs, + ) + + +@dataclass +class FlowBundle: + """Dataclass used for a full export""" + + version: int = field(default=1) + entries: List[FlowBundleEntry] = field(default_factory=list) + + +class DataclassEncoder(JSONEncoder): + """Convert FlowBundleEntry to json""" + + def default(self, o): + if is_dataclass(o): + return asdict(o) + if isinstance(o, UUID): + return str(o) + return super().default(o) + + +class EntryInvalidError(SentryIgnoredException): + """Error raised when an entry is invalid""" diff --git a/authentik/flows/transfer/exporter.py b/authentik/flows/transfer/exporter.py new file mode 100644 index 00000000..bf79f135 --- /dev/null +++ b/authentik/flows/transfer/exporter.py @@ -0,0 +1,106 @@ +"""Flow exporter""" +from json import dumps +from typing import Iterator, List +from uuid import UUID + +from django.db.models import Q + +from authentik.flows.models import Flow, FlowStageBinding, Stage +from authentik.flows.transfer.common import ( + DataclassEncoder, + FlowBundle, + FlowBundleEntry, +) +from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel +from authentik.stages.prompt.models import PromptStage + + +class FlowExporter: + """Export flow with attached stages into json""" + + flow: Flow + with_policies: bool + with_stage_prompts: bool + + pbm_uuids: List[UUID] + + def __init__(self, flow: Flow): + self.flow = flow + self.with_policies = True + self.with_stage_prompts = True + + def _prepare_pbm(self): + self.pbm_uuids = [self.flow.pbm_uuid] + for stage_subclass in Stage.__subclasses__(): + if issubclass(stage_subclass, PolicyBindingModel): + self.pbm_uuids += stage_subclass.objects.filter( + flow=self.flow + ).values_list("pbm_uuid", flat=True) + self.pbm_uuids += FlowStageBinding.objects.filter(target=self.flow).values_list( + "pbm_uuid", flat=True + ) + + def walk_stages(self) -> Iterator[FlowBundleEntry]: + """Convert all stages attached to self.flow into FlowBundleEntry objects""" + stages = ( + Stage.objects.filter(flow=self.flow).select_related().select_subclasses() + ) + for stage in stages: + if isinstance(stage, PromptStage): + pass + yield FlowBundleEntry.from_model(stage, "name") + + def walk_stage_bindings(self) -> Iterator[FlowBundleEntry]: + """Convert all bindings attached to self.flow into FlowBundleEntry objects""" + bindings = FlowStageBinding.objects.filter(target=self.flow).select_related() + for binding in bindings: + yield FlowBundleEntry.from_model(binding, "target", "stage", "order") + + def walk_policies(self) -> Iterator[FlowBundleEntry]: + """Walk over all policies. This is done at the beginning of the export for stages that have + a direct foreign key to a policy.""" + # Special case for PromptStage as that has a direct M2M to policy, we have to ensure + # all policies referenced in there we also include here + prompt_stages = PromptStage.objects.filter(flow=self.flow).values_list( + "pk", flat=True + ) + query = Q(bindings__in=self.pbm_uuids) | Q(promptstage__in=prompt_stages) + policies = Policy.objects.filter(query).select_related() + for policy in policies: + yield FlowBundleEntry.from_model(policy) + + def walk_policy_bindings(self) -> Iterator[FlowBundleEntry]: + """Walk over all policybindings relative to us. This is run at the end of the export, as + we are sure all objects exist now.""" + bindings = PolicyBinding.objects.filter( + target__in=self.pbm_uuids + ).select_related() + for binding in bindings: + yield FlowBundleEntry.from_model(binding, "policy", "target", "order") + + def walk_stage_prompts(self) -> Iterator[FlowBundleEntry]: + """Walk over all prompts associated with any PromptStages""" + prompt_stages = PromptStage.objects.filter(flow=self.flow) + for stage in prompt_stages: + for prompt in stage.fields.all(): + yield FlowBundleEntry.from_model(prompt) + + def export(self) -> FlowBundle: + """Create a list of all objects including the flow""" + if self.with_policies: + self._prepare_pbm() + bundle = FlowBundle() + bundle.entries.append(FlowBundleEntry.from_model(self.flow, "slug")) + if self.with_stage_prompts: + bundle.entries.extend(self.walk_stage_prompts()) + if self.with_policies: + bundle.entries.extend(self.walk_policies()) + bundle.entries.extend(self.walk_stages()) + bundle.entries.extend(self.walk_stage_bindings()) + if self.with_policies: + bundle.entries.extend(self.walk_policy_bindings()) + return bundle + + def export_to_string(self) -> str: + """Call export and convert it to json""" + return dumps(self.export(), cls=DataclassEncoder) diff --git a/authentik/flows/transfer/importer.py b/authentik/flows/transfer/importer.py new file mode 100644 index 00000000..76e5bc2f --- /dev/null +++ b/authentik/flows/transfer/importer.py @@ -0,0 +1,179 @@ +"""Flow importer""" +from contextlib import contextmanager +from copy import deepcopy +from json import loads +from typing import Any, Dict + +from dacite import from_dict +from dacite.exceptions import DaciteError +from django.apps import apps +from django.db import transaction +from django.db.models import Model +from django.db.models.query_utils import Q +from django.db.utils import IntegrityError +from rest_framework.exceptions import ValidationError +from rest_framework.serializers import BaseSerializer, Serializer +from structlog import BoundLogger, get_logger + +from authentik.flows.models import Flow, FlowStageBinding, Stage +from authentik.flows.transfer.common import ( + EntryInvalidError, + FlowBundle, + FlowBundleEntry, +) +from authentik.lib.models import SerializerModel +from authentik.policies.models import Policy, PolicyBinding +from authentik.stages.prompt.models import Prompt + +ALLOWED_MODELS = (Flow, FlowStageBinding, Stage, Policy, PolicyBinding, Prompt) + + +@contextmanager +def transaction_rollback(): + """Enters an atomic transaction and always triggers a rollback at the end of the block.""" + atomic = transaction.atomic() + atomic.__enter__() + yield + atomic.__exit__(IntegrityError, None, None) + + +class FlowImporter: + """Import Flow from json""" + + __import: FlowBundle + + __pk_map: Dict[Any, Model] + + logger: BoundLogger + + def __init__(self, json_input: str): + self.logger = get_logger() + self.__pk_map = {} + import_dict = loads(json_input) + try: + self.__import = from_dict(FlowBundle, import_dict) + except DaciteError as exc: + raise EntryInvalidError from exc + + def __update_pks_for_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + """Replace any value if it is a known primary key of an other object""" + + def updater(value) -> Any: + if value in self.__pk_map: + self.logger.debug("updating reference in entry", value=value) + return self.__pk_map[value] + return value + + for key, value in attrs.items(): + if isinstance(value, dict): + for idx, _inner_key in enumerate(value): + value[_inner_key] = updater(value[_inner_key]) + elif isinstance(value, list): + for idx, _inner_value in enumerate(value): + attrs[key][idx] = updater(_inner_value) + else: + attrs[key] = updater(value) + return attrs + + def __query_from_identifier(self, attrs: Dict[str, Any]) -> Q: + """Generate an or'd query from all identifiers in an entry""" + # Since identifiers can also be pk-references to other objects (see FlowStageBinding) + # we have to ensure those references are also replaced + main_query = Q(pk=attrs["pk"]) + sub_query = Q() + for identifier, value in attrs.items(): + if identifier == "pk": + continue + sub_query &= Q(**{identifier: value}) + return main_query | sub_query + + def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer: + """Validate a single entry""" + model_app_label, model_name = entry.model.split(".") + model: SerializerModel = apps.get_model(model_app_label, model_name) + if not isinstance(model(), ALLOWED_MODELS): + raise EntryInvalidError(f"Model {model} not allowed") + + # If we try to validate without referencing a possible instance + # we'll get a duplicate error, hence we load the model here and return + # the full serializer for later usage + # Because a model might have multiple unique columns, we chain all identifiers together + # to create an OR query. + updated_identifiers = self.__update_pks_for_attrs(entry.identifiers) + for key, value in list(updated_identifiers.items()): + if isinstance(value, dict) and "pk" in value: + del updated_identifiers[key] + updated_identifiers[f"{key}"] = value["pk"] + existing_models = model.objects.filter( + self.__query_from_identifier(updated_identifiers) + ) + + serializer_kwargs = {} + if existing_models.exists(): + model_instance = existing_models.first() + self.logger.debug( + "initialise serializer with instance", + model=model, + instance=model_instance, + pk=model_instance.pk, + ) + serializer_kwargs["instance"] = model_instance + else: + self.logger.debug( + "initialise new instance", model=model, **updated_identifiers + ) + full_data = self.__update_pks_for_attrs(entry.attrs) + full_data.update(updated_identifiers) + serializer_kwargs["data"] = full_data + + serializer: Serializer = model().serializer(**serializer_kwargs) + try: + serializer.is_valid(raise_exception=True) + except ValidationError as exc: + raise EntryInvalidError(f"Serializer errors {serializer.errors}") from exc + return serializer + + def apply(self) -> bool: + """Apply (create/update) flow json, in database transaction""" + try: + with transaction.atomic(): + if not self._apply_models(): + self.logger.debug("Reverting changes due to error") + raise IntegrityError + except IntegrityError: + return False + else: + self.logger.debug("Committing changes") + return True + + def _apply_models(self) -> bool: + """Apply (create/update) flow json""" + self.__pk_map = {} + entries = deepcopy(self.__import.entries) + for entry in entries: + model_app_label, model_name = entry.model.split(".") + model: SerializerModel = apps.get_model(model_app_label, model_name) + # Validate each single entry + try: + serializer = self._validate_single(entry) + except EntryInvalidError as exc: + self.logger.error("entry not valid", entry=entry, error=exc) + return False + + model = serializer.save() + self.__pk_map[entry.identifiers["pk"]] = model.pk + self.logger.debug("updated model", model=model, pk=model.pk) + return True + + def validate(self) -> bool: + """Validate loaded flow export, ensure all models are allowed + and serializers have no errors""" + self.logger.debug("Starting flow import validaton") + if self.__import.version != 1: + self.logger.warning("Invalid bundle version") + return False + with transaction_rollback(): + successful = self._apply_models() + if not successful: + self.logger.debug("Flow validation failed") + return successful diff --git a/authentik/flows/urls.py b/authentik/flows/urls.py new file mode 100644 index 00000000..ad440097 --- /dev/null +++ b/authentik/flows/urls.py @@ -0,0 +1,49 @@ +"""flow urls""" +from django.urls import path + +from authentik.flows.models import FlowDesignation +from authentik.flows.views import ( + CancelView, + ConfigureFlowInitView, + FlowExecutorShellView, + FlowExecutorView, + ToDefaultFlow, +) + +urlpatterns = [ + path( + "-/default/authentication/", + ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION), + name="default-authentication", + ), + path( + "-/default/invalidation/", + ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION), + name="default-invalidation", + ), + path( + "-/default/recovery/", + ToDefaultFlow.as_view(designation=FlowDesignation.RECOVERY), + name="default-recovery", + ), + path( + "-/default/enrollment/", + ToDefaultFlow.as_view(designation=FlowDesignation.ENROLLMENT), + name="default-enrollment", + ), + path( + "-/default/unenrollment/", + ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT), + name="default-unenrollment", + ), + path("-/cancel/", CancelView.as_view(), name="cancel"), + path( + "-/configure//", + ConfigureFlowInitView.as_view(), + name="configure", + ), + path("b//", FlowExecutorView.as_view(), name="flow-executor"), + path( + "/", FlowExecutorShellView.as_view(), name="flow-executor-shell" + ), +] diff --git a/authentik/flows/views.py b/authentik/flows/views.py new file mode 100644 index 00000000..41b9dc35 --- /dev/null +++ b/authentik/flows/views.py @@ -0,0 +1,326 @@ +"""authentik multi-stage authentication engine""" +from traceback import format_tb +from typing import Any, Dict, Optional + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import ( + Http404, + HttpRequest, + HttpResponse, + HttpResponseRedirect, + JsonResponse, +) +from django.shortcuts import get_object_or_404, redirect, reverse +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.views.decorators.clickjacking import xframe_options_sameorigin +from django.views.generic import TemplateView, View +from structlog import get_logger + +from authentik.audit.models import cleanse_dict +from authentik.core.models import USER_ATTRIBUTE_DEBUG +from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException +from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner +from authentik.lib.utils.reflection import class_to_path +from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs +from authentik.policies.http import AccessDeniedResponse + +LOGGER = get_logger() +# Argument used to redirect user after login +NEXT_ARG_NAME = "next" +SESSION_KEY_PLAN = "authentik_flows_plan" +SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre" +SESSION_KEY_GET = "authentik_flows_get" + + +@method_decorator(xframe_options_sameorigin, name="dispatch") +class FlowExecutorView(View): + """Stage 1 Flow executor, passing requests to Stage Views""" + + flow: Flow + + plan: Optional[FlowPlan] = None + current_stage: Stage + current_stage_view: View + + def setup(self, request: HttpRequest, flow_slug: str): + super().setup(request, flow_slug=flow_slug) + self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) + + def handle_invalid_flow(self, exc: BaseException) -> HttpResponse: + """When a flow is non-applicable check if user is on the correct domain""" + if NEXT_ARG_NAME in self.request.GET: + if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)): + LOGGER.debug("f(exec): Redirecting to next on fail") + return redirect(self.request.GET.get(NEXT_ARG_NAME)) + message = exc.__doc__ if exc.__doc__ else str(exc) + return self.stage_invalid(error_message=message) + + def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: + # Early check if theres an active Plan for the current session + if SESSION_KEY_PLAN in self.request.session: + self.plan = self.request.session[SESSION_KEY_PLAN] + if self.plan.flow_pk != self.flow.pk.hex: + LOGGER.warning( + "f(exec): Found existing plan for other flow, deleteing plan", + flow_slug=flow_slug, + ) + # Existing plan is deleted from session and instance + self.plan = None + self.cancel() + LOGGER.debug("f(exec): Continuing existing plan", flow_slug=flow_slug) + + # Don't check session again as we've either already loaded the plan or we need to plan + if not self.plan: + LOGGER.debug( + "f(exec): No active Plan found, initiating planner", flow_slug=flow_slug + ) + try: + self.plan = self._initiate_plan() + except FlowNonApplicableException as exc: + LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc) + return to_stage_response(self.request, self.handle_invalid_flow(exc)) + except EmptyFlowException as exc: + LOGGER.warning("f(exec): Flow is empty", exc=exc) + return to_stage_response(self.request, self.handle_invalid_flow(exc)) + # We don't save the Plan after getting the next stage + # as it hasn't been successfully passed yet + next_stage = self.plan.next(self.request) + if not next_stage: + LOGGER.debug("f(exec): no more stages, flow is done.") + return self._flow_done() + self.current_stage = next_stage + LOGGER.debug( + "f(exec): Current stage", + current_stage=self.current_stage, + flow_slug=self.flow.slug, + ) + stage_cls = self.current_stage.type + self.current_stage_view = stage_cls(self) + self.current_stage_view.args = self.args + self.current_stage_view.kwargs = self.kwargs + self.current_stage_view.request = request + return super().dispatch(request) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """pass get request to current stage""" + LOGGER.debug( + "f(exec): Passing GET", + view_class=class_to_path(self.current_stage_view.__class__), + stage=self.current_stage, + flow_slug=self.flow.slug, + ) + try: + stage_response = self.current_stage_view.get(request, *args, **kwargs) + return to_stage_response(request, stage_response) + except Exception as exc: # pylint: disable=broad-except + LOGGER.exception(exc) + return to_stage_response(request, FlowErrorResponse(request, exc)) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """pass post request to current stage""" + LOGGER.debug( + "f(exec): Passing POST", + view_class=class_to_path(self.current_stage_view.__class__), + stage=self.current_stage, + flow_slug=self.flow.slug, + ) + try: + stage_response = self.current_stage_view.post(request, *args, **kwargs) + return to_stage_response(request, stage_response) + except Exception as exc: # pylint: disable=broad-except + LOGGER.exception(exc) + return to_stage_response(request, FlowErrorResponse(request, exc)) + + def _initiate_plan(self) -> FlowPlan: + planner = FlowPlanner(self.flow) + plan = planner.plan(self.request) + self.request.session[SESSION_KEY_PLAN] = plan + return plan + + def _flow_done(self) -> HttpResponse: + """User Successfully passed all stages""" + # Since this is wrapped by the ExecutorShell, the next argument is saved in the session + # extract the next param before cancel as that cleans it + next_param = self.request.session.get(SESSION_KEY_GET, {}).get( + NEXT_ARG_NAME, "authentik_core:shell" + ) + self.cancel() + return redirect_with_qs(next_param) + + def stage_ok(self) -> HttpResponse: + """Callback called by stages upon successful completion. + Persists updated plan and context to session.""" + LOGGER.debug( + "f(exec): Stage ok", + stage_class=class_to_path(self.current_stage_view.__class__), + flow_slug=self.flow.slug, + ) + self.plan.pop() + self.request.session[SESSION_KEY_PLAN] = self.plan + if self.plan.stages: + LOGGER.debug( + "f(exec): Continuing with next stage", + reamining=len(self.plan.stages), + flow_slug=self.flow.slug, + ) + return redirect_with_qs( + "authentik_flows:flow-executor", self.request.GET, **self.kwargs + ) + # User passed all stages + LOGGER.debug( + "f(exec): User passed all stages", + flow_slug=self.flow.slug, + context=cleanse_dict(self.plan.context), + ) + return self._flow_done() + + def stage_invalid(self, error_message: Optional[str] = None) -> HttpResponse: + """Callback used stage when data is correct but a policy denies access + or the user account is disabled. + + Optionally, an exception can be passed, which will be shown if the current user + is a superuser.""" + LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug) + self.cancel() + response = AccessDeniedResponse( + self.request, template="flows/denied_shell.html" + ) + response.error_message = error_message + return to_stage_response(self.request, response) + + def cancel(self): + """Cancel current execution and return a redirect""" + keys_to_delete = [ + SESSION_KEY_APPLICATION_PRE, + SESSION_KEY_PLAN, + SESSION_KEY_GET, + ] + for key in keys_to_delete: + if key in self.request.session: + del self.request.session[key] + + +class FlowErrorResponse(TemplateResponse): + """Response class when an unhandled error occurs during a stage. Normal users + are shown an error message, superusers are shown a full stacktrace.""" + + error: Exception + + def __init__(self, request: HttpRequest, error: Exception) -> None: + # For some reason pyright complains about keyword argument usage here + # pyright: reportGeneralTypeIssues=false + super().__init__(request=request, template="flows/error.html") + self.error = error + + def resolve_context( + self, context: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + if not context: + context = {} + context["error"] = self.error + if self._request.user and self._request.user.is_authenticated: + if self._request.user.is_superuser or self._request.user.attributes.get( + USER_ATTRIBUTE_DEBUG, False + ): + context["tb"] = "".join(format_tb(self.error.__traceback__)) + return context + + +class FlowExecutorShellView(TemplateView): + """Executor Shell view, loads a dummy card with a spinner + that loads the next stage in the background.""" + + template_name = "flows/shell.html" + + def get_context_data(self, **kwargs) -> Dict[str, Any]: + flow: Flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) + kwargs["background_url"] = flow.background.url + kwargs["exec_url"] = reverse( + "authentik_flows:flow-executor", kwargs=self.kwargs + ) + self.request.session[SESSION_KEY_GET] = self.request.GET + return kwargs + + +class CancelView(View): + """View which canels the currently active plan""" + + def get(self, request: HttpRequest) -> HttpResponse: + """View which canels the currently active plan""" + if SESSION_KEY_PLAN in request.session: + del request.session[SESSION_KEY_PLAN] + LOGGER.debug("Canceled current plan") + return redirect("authentik_core:shell") + + +class ToDefaultFlow(View): + """Redirect to default flow matching by designation""" + + designation: Optional[FlowDesignation] = None + + def dispatch(self, request: HttpRequest) -> HttpResponse: + flow = Flow.with_policy(request, designation=self.designation) + if not flow: + raise Http404 + # If user already has a pending plan, clear it so we don't have to later. + if SESSION_KEY_PLAN in self.request.session: + plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] + if plan.flow_pk != flow.pk.hex: + LOGGER.warning( + "f(def): Found existing plan for other flow, deleteing plan", + flow_slug=flow.slug, + ) + del self.request.session[SESSION_KEY_PLAN] + return redirect_with_qs( + "authentik_flows:flow-executor-shell", request.GET, flow_slug=flow.slug + ) + + +def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse: + """Convert normal HttpResponse into JSON Response""" + if isinstance(source, HttpResponseRedirect) or source.status_code == 302: + redirect_url = source["Location"] + if request.path != redirect_url: + return JsonResponse({"type": "redirect", "to": redirect_url}) + return source + if isinstance(source, TemplateResponse): + return JsonResponse( + {"type": "template", "body": source.render().content.decode("utf-8")} + ) + # Check for actual HttpResponse (without isinstance as we dont want to check inheritance) + if source.__class__ == HttpResponse: + return JsonResponse( + {"type": "template", "body": source.content.decode("utf-8")} + ) + return source + + +class ConfigureFlowInitView(LoginRequiredMixin, View): + """Initiate planner for selected change flow and redirect to flow executor, + or raise Http404 if no configure_flow has been set.""" + + def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse: + """Initiate planner for selected change flow and redirect to flow executor, + or raise Http404 if no configure_flow has been set.""" + try: + stage: Stage = Stage.objects.get_subclass(pk=stage_uuid) + except Stage.DoesNotExist as exc: + raise Http404 from exc + if not isinstance(stage, ConfigurableStage): + LOGGER.debug("Stage does not inherit ConfigurableStage", stage=stage) + raise Http404 + if not stage.configure_flow: + LOGGER.debug("Stage has no configure_flow set", stage=stage) + raise Http404 + + plan = FlowPlanner(stage.configure_flow).plan( + request, {PLAN_CONTEXT_PENDING_USER: request.user} + ) + request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_flows:flow-executor-shell", + self.request.GET, + flow_slug=stage.configure_flow.slug, + ) diff --git a/passbook/lib/__init__.py b/authentik/lib/__init__.py similarity index 100% rename from passbook/lib/__init__.py rename to authentik/lib/__init__.py diff --git a/authentik/lib/apps.py b/authentik/lib/apps.py new file mode 100644 index 00000000..e155f53f --- /dev/null +++ b/authentik/lib/apps.py @@ -0,0 +1,10 @@ +"""authentik lib app config""" +from django.apps import AppConfig + + +class AuthentikLibConfig(AppConfig): + """authentik lib app config""" + + name = "authentik.lib" + label = "authentik_lib" + verbose_name = "authentik lib" diff --git a/authentik/lib/config.py b/authentik/lib/config.py new file mode 100644 index 00000000..5565b34d --- /dev/null +++ b/authentik/lib/config.py @@ -0,0 +1,173 @@ +"""authentik core config loader""" +import os +from collections.abc import Mapping +from contextlib import contextmanager +from glob import glob +from json import dumps +from time import time +from typing import Any, Dict +from urllib.parse import urlparse + +import yaml +from django.conf import ImproperlyConfigured +from django.http import HttpRequest + +SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob( + "/etc/authentik/config.d/*.yml", recursive=True +) +ENV_PREFIX = "AUTHENTIK" +ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") + + +def context_processor(request: HttpRequest) -> Dict[str, Any]: + """Context Processor that injects config object into every template""" + kwargs = {"config": CONFIG.raw} + return kwargs + + +class ConfigLoader: + """Search through SEARCH_PATHS and load configuration. Environment variables starting with + `ENV_PREFIX` are also applied. + + A variable like AUTHENTIK_POSTGRESQL__HOST would translate to postgresql.host""" + + loaded_file = [] + + __config = {} + + def __init__(self): + super().__init__() + base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../..")) + for path in SEARCH_PATHS: + # Check if path is relative, and if so join with base_dir + if not os.path.isabs(path): + path = os.path.join(base_dir, path) + if os.path.isfile(path) and os.path.exists(path): + # Path is an existing file, so we just read it and update our config with it + self.update_from_file(path) + elif os.path.isdir(path) and os.path.exists(path): + # Path is an existing dir, so we try to read the env config from it + env_paths = [ + os.path.join(path, ENVIRONMENT + ".yml"), + os.path.join(path, ENVIRONMENT + ".env.yml"), + ] + for env_file in env_paths: + if os.path.isfile(env_file) and os.path.exists(env_file): + # Update config with env file + self.update_from_file(env_file) + self.update_from_env() + + def _log(self, level: str, message: str, **kwargs): + """Custom Log method, we want to ensure ConfigLoader always logs JSON even when + 'structlog' or 'logging' hasn't been configured yet.""" + output = { + "event": message, + "level": level, + "logger": self.__class__.__module__, + "timestamp": time(), + } + output.update(kwargs) + print(dumps(output)) + + def update(self, root, updatee): + """Recursively update dictionary""" + for key, value in updatee.items(): + if isinstance(value, Mapping): + root[key] = self.update(root.get(key, {}), value) + else: + if isinstance(value, str): + value = self.parse_uri(value) + root[key] = value + return root + + def parse_uri(self, value): + """Parse string values which start with a URI""" + url = urlparse(value) + if url.scheme == "env": + value = os.getenv(url.netloc, url.query) + return value + + def update_from_file(self, path: str): + """Update config from file contents""" + try: + with open(path) as file: + try: + self.update(self.__config, yaml.safe_load(file)) + self._log("debug", "Loaded config", file=path) + self.loaded_file.append(path) + except yaml.YAMLError as exc: + raise ImproperlyConfigured from exc + except PermissionError as exc: + self._log( + "warning", "Permission denied while reading file", path=path, error=exc + ) + + def update_from_dict(self, update: dict): + """Update config from dict""" + self.__config.update(update) + + def update_from_env(self): + """Check environment variables""" + outer = {} + idx = 0 + for key, value in os.environ.items(): + if not key.startswith(ENV_PREFIX): + continue + relative_key = key.replace(f"{ENV_PREFIX}_", "").replace("__", ".").lower() + # Recursively convert path from a.b.c into outer[a][b][c] + current_obj = outer + dot_parts = relative_key.split(".") + for dot_part in dot_parts[:-1]: + if dot_part not in current_obj: + current_obj[dot_part] = {} + current_obj = current_obj[dot_part] + current_obj[dot_parts[-1]] = value + idx += 1 + if idx > 0: + self._log("debug", "Loaded environment variables", count=idx) + self.update(self.__config, outer) + + @contextmanager + def patch(self, path: str, value: Any): + """Context manager for unittests to patch a value""" + original_value = self.y(path) + self.y_set(path, value) + yield + self.y_set(path, original_value) + + @property + def raw(self) -> dict: + """Get raw config dictionary""" + return self.__config + + # pylint: disable=invalid-name + def y(self, path: str, default=None, sep=".") -> Any: + """Access attribute by using yaml path""" + # Walk sub_dicts before parsing path + root = self.raw + # Walk each component of the path + for comp in path.split(sep): + if root and comp in root: + root = root.get(comp) + else: + return default + return root + + def y_set(self, path: str, value: Any, sep="."): + """Set value using same syntax as y()""" + # Walk sub_dicts before parsing path + root = self.raw + # Walk each component of the path + path_parts = path.split(sep) + for comp in path_parts[:-1]: + if comp not in root: + root[comp] = {} + root = root.get(comp) + root[path_parts[-1]] = value + + def y_bool(self, path: str, default=False) -> bool: + """Wrapper for y that converts value into boolean""" + return str(self.y(path, default)).lower() == "true" + + +CONFIG = ConfigLoader() diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml new file mode 100644 index 00000000..4f9e1bd5 --- /dev/null +++ b/authentik/lib/default.yml @@ -0,0 +1,37 @@ +# This is the default configuration file +postgresql: + host: localhost + name: authentik + user: authentik + password: 'env://POSTGRES_PASSWORD' + +redis: + host: localhost + password: '' + cache_db: 0 + message_queue_db: 1 + ws_db: 2 + +debug: false +log_level: info + +# Error reporting, sends stacktrace to sentry.beryju.org +error_reporting: + enabled: false + environment: customer + send_pii: false + +outposts: + docker_image_base: "beryju/authentik" # this is prepended to -proxy:version + +authentik: + avatars: gravatar # gravatar or none + branding: + title: authentik + logo: /static/dist/assets/icons/icon_left_brand.svg + # Optionally add links to the footer on the login page + footer_links: + - name: Documentation + href: https://goauthentik.io/docs/ + - name: authentik Website + href: https://goauthentik.io/ diff --git a/passbook/lib/expression/__init__.py b/authentik/lib/expression/__init__.py similarity index 100% rename from passbook/lib/expression/__init__.py rename to authentik/lib/expression/__init__.py diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py new file mode 100644 index 00000000..35b79b3a --- /dev/null +++ b/authentik/lib/expression/evaluator.py @@ -0,0 +1,112 @@ +"""authentik expression policy evaluator""" +import re +from textwrap import indent +from typing import Any, Dict, Iterable, Optional + +from django.core.exceptions import ValidationError +from requests import Session +from sentry_sdk.hub import Hub +from sentry_sdk.tracing import Span +from structlog import get_logger + +from authentik.core.models import User + +LOGGER = get_logger() + + +class BaseEvaluator: + """Validate and evaluate python-based expressions""" + + # Globals that can be used by function + _globals: Dict[str, Any] + # Context passed as locals to exec() + _context: Dict[str, Any] + + # Filename used for exec + _filename: str + + def __init__(self): + # update authentik/policies/expression/templates/policy/expression/form.html + # update website/docs/policies/expression.md + self._globals = { + "regex_match": BaseEvaluator.expr_filter_regex_match, + "regex_replace": BaseEvaluator.expr_filter_regex_replace, + "ak_is_group_member": BaseEvaluator.expr_func_is_group_member, + "ak_user_by": BaseEvaluator.expr_func_user_by, + "ak_logger": get_logger(), + "requests": Session(), + } + self._context = {} + self._filename = "BaseEvalautor" + + @staticmethod + def expr_filter_regex_match(value: Any, regex: str) -> bool: + """Expression Filter to run re.search""" + return re.search(regex, value) is None + + @staticmethod + def expr_filter_regex_replace(value: Any, regex: str, repl: str) -> str: + """Expression Filter to run re.sub""" + return re.sub(regex, repl, value) + + @staticmethod + def expr_func_user_by(**filters) -> Optional[User]: + """Get user by filters""" + users = User.objects.filter(**filters) + if users: + return users.first() + return None + + @staticmethod + def expr_func_is_group_member(user: User, **group_filters) -> bool: + """Check if `user` is member of group with name `group_name`""" + return user.groups.filter(**group_filters).exists() + + def wrap_expression(self, expression: str, params: Iterable[str]) -> str: + """Wrap expression in a function, call it, and save the result as `result`""" + handler_signature = ",".join(params) + full_expression = "" + full_expression += "from ipaddress import ip_address, ip_network\n" + full_expression += f"def handler({handler_signature}):\n" + full_expression += indent(expression, " ") + full_expression += f"\nresult = handler({handler_signature})" + return full_expression + + def evaluate(self, expression_source: str) -> Any: + """Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised. + If any exception is raised during execution, it is raised. + The result is returned without any type-checking.""" + with Hub.current.start_span(op="lib.evaluator.evaluate") as span: + span: Span + span.set_data("expression", expression_source) + param_keys = self._context.keys() + ast_obj = compile( + self.wrap_expression(expression_source, param_keys), + self._filename, + "exec", + ) + try: + _locals = self._context + # Yes this is an exec, yes it is potentially bad. Since we limit what variables are + # available here, and these policies can only be edited by admins, this is a risk + # we're willing to take. + # pylint: disable=exec-used + exec(ast_obj, self._globals, _locals) # nosec # noqa + result = _locals["result"] + except Exception as exc: + LOGGER.warning("Expression error", exc=exc) + raise + return result + + def validate(self, expression: str) -> bool: + """Validate expression's syntax, raise ValidationError if Syntax is invalid""" + param_keys = self._context.keys() + try: + compile( + self.wrap_expression(expression, param_keys), + self._filename, + "exec", + ) + return True + except (ValueError, SyntaxError) as exc: + raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc diff --git a/authentik/lib/logging.py b/authentik/lib/logging.py new file mode 100644 index 00000000..7ec26f48 --- /dev/null +++ b/authentik/lib/logging.py @@ -0,0 +1,23 @@ +"""logging helpers""" +from logging import Logger +from os import getpid +from typing import Callable + + +# pylint: disable=unused-argument +def add_process_id(logger: Logger, method_name: str, event_dict): + """Add the current process ID""" + event_dict["pid"] = getpid() + return event_dict + + +def add_common_fields(environment: str) -> Callable: + """Add a common field to easily search for authentik logs""" + + def add_common_field(logger: Logger, method_name: str, event_dict): + """Add a common field to easily search for authentik logs""" + event_dict["app"] = "authentik" + event_dict["app_environment"] = environment + return event_dict + + return add_common_field diff --git a/passbook/lib/models.py b/authentik/lib/models.py similarity index 100% rename from passbook/lib/models.py rename to authentik/lib/models.py diff --git a/authentik/lib/sentry.py b/authentik/lib/sentry.py new file mode 100644 index 00000000..acd3c710 --- /dev/null +++ b/authentik/lib/sentry.py @@ -0,0 +1,64 @@ +"""authentik sentry integration""" +from aioredis.errors import ConnectionClosedError, ReplyError +from billiard.exceptions import WorkerLostError +from botocore.client import ClientError +from celery.exceptions import CeleryError +from channels_redis.core import ChannelFull +from django.core.exceptions import DisallowedHost, ValidationError +from django.db import InternalError, OperationalError, ProgrammingError +from django_redis.exceptions import ConnectionInterrupted +from ldap3.core.exceptions import LDAPException +from redis.exceptions import ConnectionError as RedisConnectionError +from redis.exceptions import RedisError, ResponseError +from rest_framework.exceptions import APIException +from structlog import get_logger +from websockets.exceptions import WebSocketException + +LOGGER = get_logger() + + +class SentryIgnoredException(Exception): + """Base Class for all errors that are suppressed, and not sent to sentry.""" + + +def before_send(event, hint): + """Check if error is database error, and ignore if so""" + ignored_classes = ( + # Inbuilt types + KeyboardInterrupt, + ConnectionResetError, + OSError, + # Django DB Errors + OperationalError, + InternalError, + ProgrammingError, + DisallowedHost, + ValidationError, + # Redis errors + RedisConnectionError, + ConnectionInterrupted, + RedisError, + ResponseError, + ReplyError, + ConnectionClosedError, + # websocket errors + ChannelFull, + WebSocketException, + # rest_framework error + APIException, + # celery errors + WorkerLostError, + CeleryError, + # S3 errors + ClientError, + # custom baseclass + SentryIgnoredException, + # ldap errors + LDAPException, + ) + if "exc_info" in hint: + _, exc_value, _ = hint["exc_info"] + if isinstance(exc_value, ignored_classes): + LOGGER.info("Supressing error %r", exc_value) + return None + return event diff --git a/passbook/lib/tasks.py b/authentik/lib/tasks.py similarity index 100% rename from passbook/lib/tasks.py rename to authentik/lib/tasks.py diff --git a/authentik/lib/templates/lib/arrayfield.html b/authentik/lib/templates/lib/arrayfield.html new file mode 100644 index 00000000..cba450c3 --- /dev/null +++ b/authentik/lib/templates/lib/arrayfield.html @@ -0,0 +1,17 @@ +{% load authentik_utils %} + +{% spaceless %} +
+ {% for widget in widget.subwidgets %} +
+ {% include widget.template_name %} +
+ +
+
+ {% endfor %} +
+
+{% endspaceless %} diff --git a/passbook/lib/templatetags/__init__.py b/authentik/lib/templatetags/__init__.py similarity index 100% rename from passbook/lib/templatetags/__init__.py rename to authentik/lib/templatetags/__init__.py diff --git a/authentik/lib/templatetags/authentik_is_active.py b/authentik/lib/templatetags/authentik_is_active.py new file mode 100644 index 00000000..a99e34bb --- /dev/null +++ b/authentik/lib/templatetags/authentik_is_active.py @@ -0,0 +1,55 @@ +"""authentik lib navbar Templatetag""" +from django import template +from django.http import HttpRequest +from structlog import get_logger + +register = template.Library() + +LOGGER = get_logger() +ACTIVE_STRING = "pf-m-current" + + +@register.simple_tag(takes_context=True) +def is_active(context, *args: str, **_) -> str: + """Return whether a navbar link is active or not.""" + request: HttpRequest = context.get("request") + if not request.resolver_match: + return "" + match = request.resolver_match + for url in args: + if ":" in url: + app_name, url = url.split(":") + if match.app_name == app_name and match.url_name == url: + return ACTIVE_STRING + else: + if match.url_name == url: + return ACTIVE_STRING + return "" + + +@register.simple_tag(takes_context=True) +def is_active_url(context, view: str) -> str: + """Return whether a navbar link is active or not.""" + request: HttpRequest = context.get("request") + if not request.resolver_match: + return "" + + match = request.resolver_match + current_full_url = f"{match.app_name}:{match.url_name}" + + if current_full_url == view: + return ACTIVE_STRING + return "" + + +@register.simple_tag(takes_context=True) +def is_active_app(context, *args: str) -> str: + """Return True if current link is from app""" + + request: HttpRequest = context.get("request") + if not request.resolver_match: + return "" + for app_name in args: + if request.resolver_match.app_name == app_name: + return ACTIVE_STRING + return "" diff --git a/authentik/lib/templatetags/authentik_utils.py b/authentik/lib/templatetags/authentik_utils.py new file mode 100644 index 00000000..16cd0e15 --- /dev/null +++ b/authentik/lib/templatetags/authentik_utils.py @@ -0,0 +1,113 @@ +"""authentik lib Templatetags""" +from hashlib import md5 +from urllib.parse import urlencode + +from django import template +from django.db.models import Model +from django.http.request import HttpRequest +from django.template import Context +from django.templatetags.static import static +from django.utils.html import escape, mark_safe +from structlog import get_logger + +from authentik.core.models import User +from authentik.lib.config import CONFIG +from authentik.lib.utils.urls import is_url_absolute + +register = template.Library() +LOGGER = get_logger() + +GRAVATAR_URL = "https://secure.gravatar.com" + + +@register.simple_tag(takes_context=True) +def back(context: Context) -> str: + """Return a link back (either from GET parameter or referer.""" + if "request" not in context: + return "" + request = context.get("request") + url = "" + if "HTTP_REFERER" in request.META: + url = request.META.get("HTTP_REFERER") + if "back" in request.GET: + url = request.GET.get("back") + + if not is_url_absolute(url): + return url + return "" + + +@register.filter("fieldtype") +def fieldtype(field): + """Return classname""" + if isinstance(field.__class__, Model) or issubclass(field.__class__, Model): + return verbose_name(field) + return field.__class__.__name__ + + +@register.simple_tag +def config(path, default=""): + """Get a setting from the database. Returns default is setting doesn't exist.""" + return CONFIG.y(path, default) + + +@register.filter(name="css_class") +def css_class(field, css): + """Add css class to form field""" + return field.as_widget(attrs={"class": css}) + + +@register.simple_tag +def avatar(user: User) -> str: + """Get avatar, depending on authentik.avatar setting""" + mode = CONFIG.raw.get("authentik").get("avatars") + if mode == "none": + return static("authentik/user_default.png") + if mode == "gravatar": + parameters = [ + ("s", "158"), + ("r", "g"), + ] + # gravatar uses md5 for their URLs, so md5 can't be avoided + mail_hash = md5(user.email.encode("utf-8")).hexdigest() # nosec + gravatar_url = ( + f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" + ) + return escape(gravatar_url) + raise ValueError(f"Invalid avatar mode {mode}") + + +@register.filter +def verbose_name(obj) -> str: + """Return Object's Verbose Name""" + if not obj: + return "" + if hasattr(obj, "verbose_name"): + return obj.verbose_name + return obj._meta.verbose_name + + +@register.filter +def form_verbose_name(obj) -> str: + """Return ModelForm's Object's Verbose Name""" + if not obj: + return "" + return verbose_name(obj._meta.model) + + +@register.filter +def doc(obj) -> str: + """Return docstring of object""" + return mark_safe(obj.__doc__.replace("\n", "
")) + + +@register.simple_tag(takes_context=True) +def query_transform(context: Context, **kwargs) -> str: + """Append objects to the current querystring""" + if "request" not in context: + return "" + request: HttpRequest = context["request"] + updated = request.GET.copy() + for key, value in kwargs.items(): + updated[key] = value + return updated.urlencode() diff --git a/authentik/lib/tests.py b/authentik/lib/tests.py new file mode 100644 index 00000000..9075a2a0 --- /dev/null +++ b/authentik/lib/tests.py @@ -0,0 +1,30 @@ +"""base model tests""" +from typing import Callable, Type + +from django.test import TestCase +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import Stage +from authentik.lib.models import SerializerModel +from authentik.lib.utils.reflection import all_subclasses + + +class TestModels(TestCase): + """Generic model properties tests""" + + +def model_tester_factory(test_model: Type[Stage]) -> Callable: + """Test a form""" + + def tester(self: TestModels): + model_inst = test_model() + try: + self.assertTrue(issubclass(model_inst.serializer, BaseSerializer)) + except NotImplementedError: + pass + + return tester + + +for model in all_subclasses(SerializerModel): + setattr(TestModels, f"test_model_{model.__name__}", model_tester_factory(model)) diff --git a/passbook/lib/utils/__init__.py b/authentik/lib/utils/__init__.py similarity index 100% rename from passbook/lib/utils/__init__.py rename to authentik/lib/utils/__init__.py diff --git a/passbook/lib/utils/http.py b/authentik/lib/utils/http.py similarity index 100% rename from passbook/lib/utils/http.py rename to authentik/lib/utils/http.py diff --git a/authentik/lib/utils/reflection.py b/authentik/lib/utils/reflection.py new file mode 100644 index 00000000..1cfd73d8 --- /dev/null +++ b/authentik/lib/utils/reflection.py @@ -0,0 +1,43 @@ +"""authentik lib reflection utilities""" +from importlib import import_module + +from django.conf import settings + + +def all_subclasses(cls, sort=True): + """Recursively return all subclassess of cls""" + classes = set(cls.__subclasses__()).union( + [s for c in cls.__subclasses__() for s in all_subclasses(c, sort=sort)] + ) + # Check if we're in debug mode, if not exclude classes which have `__debug_only__` + if not settings.DEBUG: + # Filter class out when __debug_only__ is not False + classes = [x for x in classes if not getattr(x, "__debug_only__", False)] + # classes = filter(lambda x: not getattr(x, "__debug_only__", False), classes) + if sort: + return sorted(classes, key=lambda x: x.__name__) + return classes + + +def class_to_path(cls): + """Turn Class (Class or instance) into module path""" + return f"{cls.__module__}.{cls.__name__}" + + +def path_to_class(path): + """Import module and return class""" + if not path: + return None + parts = path.split(".") + package = ".".join(parts[:-1]) + _class = getattr(import_module(package), parts[-1]) + return _class + + +def get_apps(): + """Get list of all authentik apps""" + from django.apps.registry import apps + + for _app in apps.get_app_configs(): + if _app.name.startswith("authentik"): + yield _app diff --git a/authentik/lib/utils/template.py b/authentik/lib/utils/template.py new file mode 100644 index 00000000..a5486164 --- /dev/null +++ b/authentik/lib/utils/template.py @@ -0,0 +1,8 @@ +"""authentik lib template utilities""" +from django.template import Context, loader + + +def render_to_string(template_path: str, ctx: Context) -> str: + """Render a template to string""" + template = loader.get_template(template_path) + return template.render(ctx) diff --git a/passbook/lib/utils/time.py b/authentik/lib/utils/time.py similarity index 100% rename from passbook/lib/utils/time.py rename to authentik/lib/utils/time.py diff --git a/authentik/lib/utils/ui.py b/authentik/lib/utils/ui.py new file mode 100644 index 00000000..cfe7d6c3 --- /dev/null +++ b/authentik/lib/utils/ui.py @@ -0,0 +1,11 @@ +"""authentik UI utils""" +from typing import Any, List + + +def human_list(_list: List[Any]) -> str: + """Convert a list of items into 'a, b or c'""" + last_item = _list.pop() + if len(_list) < 1: + return last_item + result = ", ".join(_list) + return "%s or %s" % (result, last_item) diff --git a/passbook/lib/utils/urls.py b/authentik/lib/utils/urls.py similarity index 100% rename from passbook/lib/utils/urls.py rename to authentik/lib/utils/urls.py diff --git a/authentik/lib/views.py b/authentik/lib/views.py new file mode 100644 index 00000000..bfa28414 --- /dev/null +++ b/authentik/lib/views.py @@ -0,0 +1,41 @@ +"""authentik helper views""" +from django.http import HttpRequest +from django.template.response import TemplateResponse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import CreateView +from guardian.shortcuts import assign_perm + + +class CreateAssignPermView(CreateView): + """Assign permissions to object after creation""" + + permissions = [ + "%s.view_%s", + "%s.change_%s", + "%s.delete_%s", + ] + + def form_valid(self, form): + response = super().form_valid(form) + for permission in self.permissions: + full_permission = permission % ( + self.object._meta.app_label, + self.object._meta.model_name, + ) + assign_perm(full_permission, self.request.user, self.object) + return response + + +def bad_request_message( + request: HttpRequest, + message: str, + title="Bad Request", + template="error/generic.html", +) -> TemplateResponse: + """Return generic error page with message, with status code set to 400""" + return TemplateResponse( + request, + template, + {"message": message, "title": _(title)}, + status=400, + ) diff --git a/passbook/lib/widgets.py b/authentik/lib/widgets.py similarity index 100% rename from passbook/lib/widgets.py rename to authentik/lib/widgets.py diff --git a/passbook/outposts/__init__.py b/authentik/outposts/__init__.py similarity index 100% rename from passbook/outposts/__init__.py rename to authentik/outposts/__init__.py diff --git a/authentik/outposts/api.py b/authentik/outposts/api.py new file mode 100644 index 00000000..d6815a95 --- /dev/null +++ b/authentik/outposts/api.py @@ -0,0 +1,66 @@ +"""Outpost API Views""" +from rest_framework.serializers import JSONField, ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.outposts.models import ( + DockerServiceConnection, + KubernetesServiceConnection, + Outpost, +) + + +class OutpostSerializer(ModelSerializer): + """Outpost Serializer""" + + _config = JSONField() + + class Meta: + + model = Outpost + fields = ["pk", "name", "providers", "service_connection", "_config"] + + +class OutpostViewSet(ModelViewSet): + """Outpost Viewset""" + + queryset = Outpost.objects.all() + serializer_class = OutpostSerializer + + +class DockerServiceConnectionSerializer(ModelSerializer): + """DockerServiceConnection Serializer""" + + class Meta: + + model = DockerServiceConnection + fields = [ + "pk", + "name", + "local", + "url", + "tls_verification", + "tls_authentication", + ] + + +class DockerServiceConnectionViewSet(ModelViewSet): + """DockerServiceConnection Viewset""" + + queryset = DockerServiceConnection.objects.all() + serializer_class = DockerServiceConnectionSerializer + + +class KubernetesServiceConnectionSerializer(ModelSerializer): + """KubernetesServiceConnection Serializer""" + + class Meta: + + model = KubernetesServiceConnection + fields = ["pk", "name", "local", "kubeconfig"] + + +class KubernetesServiceConnectionViewSet(ModelViewSet): + """KubernetesServiceConnection Viewset""" + + queryset = KubernetesServiceConnection.objects.all() + serializer_class = KubernetesServiceConnectionSerializer diff --git a/authentik/outposts/apps.py b/authentik/outposts/apps.py new file mode 100644 index 00000000..b9690724 --- /dev/null +++ b/authentik/outposts/apps.py @@ -0,0 +1,74 @@ +"""authentik outposts app config""" +from importlib import import_module +from os import R_OK, access +from os.path import expanduser +from pathlib import Path +from socket import gethostname +from urllib.parse import urlparse + +import yaml +from django.apps import AppConfig +from django.db import ProgrammingError +from docker.constants import DEFAULT_UNIX_SOCKET +from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME +from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION +from structlog import get_logger + +LOGGER = get_logger() + + +class AuthentikOutpostConfig(AppConfig): + """authentik outposts app config""" + + name = "authentik.outposts" + label = "authentik_outposts" + mountpoint = "outposts/" + verbose_name = "authentik Outpost" + + def ready(self): + import_module("authentik.outposts.signals") + try: + AuthentikOutpostConfig.init_local_connection() + except ProgrammingError: + pass + + @staticmethod + def init_local_connection(): + """Check if local kubernetes or docker connections should be created""" + from authentik.outposts.models import ( + KubernetesServiceConnection, + DockerServiceConnection, + ) + + if Path(SERVICE_TOKEN_FILENAME).exists(): + LOGGER.debug("Detected in-cluster Kubernetes Config") + if not KubernetesServiceConnection.objects.filter(local=True).exists(): + LOGGER.debug("Created Service Connection for in-cluster") + KubernetesServiceConnection.objects.create( + name="Local Kubernetes Cluster", local=True, kubeconfig={} + ) + # For development, check for the existence of a kubeconfig file + kubeconfig_path = expanduser(KUBE_CONFIG_DEFAULT_LOCATION) + if Path(kubeconfig_path).exists(): + LOGGER.debug("Detected kubeconfig") + kubeconfig_local_name = f"k8s-{gethostname()}" + if not KubernetesServiceConnection.objects.filter( + name=kubeconfig_local_name + ).exists(): + LOGGER.debug("Creating kubeconfig Service Connection") + with open(kubeconfig_path, "r") as _kubeconfig: + KubernetesServiceConnection.objects.create( + name=kubeconfig_local_name, + kubeconfig=yaml.safe_load(_kubeconfig), + ) + unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path + socket = Path(unix_socket_path) + if socket.exists() and access(socket, R_OK): + LOGGER.debug("Detected local docker socket") + if not DockerServiceConnection.objects.filter(local=True).exists(): + LOGGER.debug("Created Service Connection for docker") + DockerServiceConnection.objects.create( + name="Local Docker connection", + local=True, + url=unix_socket_path, + ) diff --git a/authentik/outposts/channels.py b/authentik/outposts/channels.py new file mode 100644 index 00000000..cebe1604 --- /dev/null +++ b/authentik/outposts/channels.py @@ -0,0 +1,89 @@ +"""Outpost websocket handler""" +from dataclasses import asdict, dataclass, field +from datetime import datetime +from enum import IntEnum +from typing import Any, Dict + +from dacite import from_dict +from dacite.data import Data +from guardian.shortcuts import get_objects_for_user +from structlog import get_logger + +from authentik.core.channels import AuthJsonConsumer +from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState + +LOGGER = get_logger() + + +class WebsocketMessageInstruction(IntEnum): + """Commands which can be triggered over Websocket""" + + # Simple message used by either side when a message is acknowledged + ACK = 0 + + # Message used by outposts to report their alive status + HELLO = 1 + + # Message sent by us to trigger an Update + TRIGGER_UPDATE = 2 + + +@dataclass +class WebsocketMessage: + """Complete Websocket Message that is being sent""" + + instruction: int + args: Dict[str, Any] = field(default_factory=dict) + + +class OutpostConsumer(AuthJsonConsumer): + """Handler for Outposts that connect over websockets for health checks and live updates""" + + outpost: Outpost + + def connect(self): + if not super().connect(): + return + uuid = self.scope["url_route"]["kwargs"]["pk"] + outpost = get_objects_for_user( + self.user, "authentik_outposts.view_outpost" + ).filter(pk=uuid) + if not outpost.exists(): + self.close() + return + self.accept() + self.outpost = outpost.first() + OutpostState( + uid=self.channel_name, last_seen=datetime.now(), _outpost=self.outpost + ).save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) + LOGGER.debug("added channel to cache", channel_name=self.channel_name) + + # pylint: disable=unused-argument + def disconnect(self, close_code): + OutpostState.for_channel(self.outpost, self.channel_name).delete() + LOGGER.debug("removed channel from cache", channel_name=self.channel_name) + + def receive_json(self, content: Data): + msg = from_dict(WebsocketMessage, content) + state = OutpostState( + uid=self.channel_name, + last_seen=datetime.now(), + _outpost=self.outpost, + ) + if msg.instruction == WebsocketMessageInstruction.HELLO: + state.version = msg.args.get("version", None) + elif msg.instruction == WebsocketMessageInstruction.ACK: + return + state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) + + response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) + self.send_json(asdict(response)) + + # pylint: disable=unused-argument + def event_update(self, event): + """Event handler which is called by post_save signals, Send update instruction""" + self.send_json( + asdict( + WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE) + ) + ) diff --git a/passbook/outposts/controllers/__init__.py b/authentik/outposts/controllers/__init__.py similarity index 100% rename from passbook/outposts/controllers/__init__.py rename to authentik/outposts/controllers/__init__.py diff --git a/authentik/outposts/controllers/base.py b/authentik/outposts/controllers/base.py new file mode 100644 index 00000000..57b9cf68 --- /dev/null +++ b/authentik/outposts/controllers/base.py @@ -0,0 +1,46 @@ +"""Base Controller""" +from typing import Dict, List + +from structlog import get_logger +from structlog.testing import capture_logs + +from authentik.lib.sentry import SentryIgnoredException +from authentik.outposts.models import Outpost, OutpostServiceConnection + + +class ControllerException(SentryIgnoredException): + """Exception raised when anything fails during controller run""" + + +class BaseController: + """Base Outpost deployment controller""" + + deployment_ports: Dict[str, int] + + outpost: Outpost + connection: OutpostServiceConnection + + def __init__(self, outpost: Outpost, connection: OutpostServiceConnection): + self.outpost = outpost + self.connection = connection + self.logger = get_logger() + self.deployment_ports = {} + + # pylint: disable=invalid-name + def up(self): + """Called by scheduled task to reconcile deployment/service/etc""" + raise NotImplementedError + + def up_with_logs(self) -> List[str]: + """Call .up() but capture all log output and return it.""" + with capture_logs() as logs: + self.up() + return [x["event"] for x in logs] + + def down(self): + """Handler to delete everything we've created""" + raise NotImplementedError + + def get_static_deployment(self) -> str: + """Return a static deployment configuration""" + raise NotImplementedError diff --git a/authentik/outposts/controllers/docker.py b/authentik/outposts/controllers/docker.py new file mode 100644 index 00000000..e12d7872 --- /dev/null +++ b/authentik/outposts/controllers/docker.py @@ -0,0 +1,160 @@ +"""Docker controller""" +from time import sleep +from typing import Dict, Tuple + +from django.conf import settings +from docker import DockerClient +from docker.errors import DockerException, NotFound +from docker.models.containers import Container +from yaml import safe_dump + +from authentik import __version__ +from authentik.lib.config import CONFIG +from authentik.outposts.controllers.base import BaseController, ControllerException +from authentik.outposts.models import ( + DockerServiceConnection, + Outpost, + ServiceConnectionInvalid, +) + + +class DockerController(BaseController): + """Docker controller""" + + client: DockerClient + + container: Container + connection: DockerServiceConnection + + def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None: + super().__init__(outpost, connection) + try: + self.client = connection.client() + except ServiceConnectionInvalid as exc: + raise ControllerException from exc + + def _get_labels(self) -> Dict[str, str]: + return {} + + def _get_env(self) -> Dict[str, str]: + return { + "AUTHENTIK_HOST": self.outpost.config.authentik_host, + "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure), + "AUTHENTIK_TOKEN": self.outpost.token.key, + } + + def _comp_env(self, container: Container) -> bool: + """Check if container's env is equal to what we would set. Return true if container needs + to be rebuilt.""" + should_be = self._get_env() + container_env = container.attrs.get("Config", {}).get("Env", {}) + for key, expected_value in should_be.items(): + if key not in container_env: + continue + if container_env[key] != expected_value: + return True + return False + + def _get_container(self) -> Tuple[Container, bool]: + container_name = f"authentik-proxy-{self.outpost.uuid.hex}" + try: + return self.client.containers.get(container_name), False + except NotFound: + self.logger.info("Container does not exist, creating") + image_prefix = CONFIG.y("outposts.docker_image_base") + image_name = f"{image_prefix}-{self.outpost.type}:{__version__}" + self.client.images.pull(image_name) + container_args = { + "image": image_name, + "name": f"authentik-proxy-{self.outpost.uuid.hex}", + "detach": True, + "ports": {x: x for _, x in self.deployment_ports.items()}, + "environment": self._get_env(), + "labels": self._get_labels(), + } + if settings.TEST: + del container_args["ports"] + container_args["network_mode"] = "host" + return ( + self.client.containers.create(**container_args), + True, + ) + + def up(self): + try: + container, has_been_created = self._get_container() + # Check if the container is out of date, delete it and retry + if len(container.image.tags) > 0: + tag: str = container.image.tags[0] + _, _, version = tag.partition(":") + if version != __version__: + self.logger.info( + "Container has mismatched version, re-creating...", + has=version, + should=__version__, + ) + container.kill() + container.remove(force=True) + return self.up() + # Check that container values match our values + if self._comp_env(container): + self.logger.info("Container has outdated config, re-creating...") + container.kill() + container.remove(force=True) + return self.up() + # Check that container is healthy + if ( + container.status == "running" + and container.attrs.get("State", {}).get("Health", {}).get("Status", "") + != "healthy" + ): + # At this point we know the config is correct, but the container isn't healthy, + # so we just restart it with the same config + if has_been_created: + # Since we've just created the container, give it some time to start. + # If its still not up by then, restart it + self.logger.info( + "Container is unhealthy and new, giving it time to boot." + ) + sleep(60) + self.logger.info("Container is unhealthy, restarting...") + container.restart() + return None + # Check that container is running + if container.status != "running": + self.logger.info("Container is not running, restarting...") + container.start() + return None + return None + except DockerException as exc: + raise ControllerException from exc + + def down(self): + try: + container, _ = self._get_container() + container.kill() + container.remove() + except DockerException as exc: + raise ControllerException from exc + + def get_static_deployment(self) -> str: + """Generate docker-compose yaml for proxy, version 3.5""" + ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()] + image_prefix = CONFIG.y("outposts.docker_image_base") + compose = { + "version": "3.5", + "services": { + f"authentik_{self.outpost.type}": { + "image": f"{image_prefix}-{self.outpost.type}:{__version__}", + "ports": ports, + "environment": { + "AUTHENTIK_HOST": self.outpost.config.authentik_host, + "AUTHENTIK_INSECURE": str( + self.outpost.config.authentik_host_insecure + ), + "AUTHENTIK_TOKEN": self.outpost.token.key, + }, + } + }, + } + return safe_dump(compose, default_flow_style=False) diff --git a/passbook/outposts/controllers/k8s/__init__.py b/authentik/outposts/controllers/k8s/__init__.py similarity index 100% rename from passbook/outposts/controllers/k8s/__init__.py rename to authentik/outposts/controllers/k8s/__init__.py diff --git a/authentik/outposts/controllers/k8s/base.py b/authentik/outposts/controllers/k8s/base.py new file mode 100644 index 00000000..0fbf5588 --- /dev/null +++ b/authentik/outposts/controllers/k8s/base.py @@ -0,0 +1,126 @@ +"""Base Kubernetes Reconciler""" +from typing import TYPE_CHECKING, Generic, TypeVar + +from kubernetes.client import V1ObjectMeta +from kubernetes.client.rest import ApiException +from structlog import get_logger + +from authentik import __version__ +from authentik.lib.sentry import SentryIgnoredException + +if TYPE_CHECKING: + from authentik.outposts.controllers.kubernetes import KubernetesController + +# pylint: disable=invalid-name +T = TypeVar("T") + + +class ReconcileTrigger(SentryIgnoredException): + """Base trigger raised by child classes to notify us""" + + +class NeedsRecreate(ReconcileTrigger): + """Exception to trigger a complete recreate of the Kubernetes Object""" + + +class NeedsUpdate(ReconcileTrigger): + """Exception to trigger an update to the Kubernetes Object""" + + +class KubernetesObjectReconciler(Generic[T]): + """Base Kubernetes Reconciler, handles the basic logic.""" + + controller: "KubernetesController" + + def __init__(self, controller: "KubernetesController"): + self.controller = controller + self.namespace = controller.outpost.config.kubernetes_namespace + self.logger = get_logger() + + @property + def name(self) -> str: + """Get the name of the object this reconciler manages""" + raise NotImplementedError + + def up(self): + """Create object if it doesn't exist, update if needed or recreate if needed.""" + current = None + reference = self.get_reference_object() + try: + try: + current = self.retrieve() + except ApiException as exc: + if exc.status == 404: + self.logger.debug("Failed to get current, triggering recreate") + raise NeedsRecreate from exc + self.logger.debug("Other unhandled error", exc=exc) + raise exc + else: + self.logger.debug("Got current, running reconcile") + self.reconcile(current, reference) + except NeedsRecreate: + self.logger.debug("Recreate requested") + if current: + self.logger.debug("Deleted old") + self.delete(current) + else: + self.logger.debug("No old found, creating") + self.logger.debug("Created") + self.create(reference) + except NeedsUpdate: + self.logger.debug("Updating") + self.update(current, reference) + else: + self.logger.debug("Nothing to do...") + + def down(self): + """Delete object if found""" + try: + current = self.retrieve() + self.delete(current) + self.logger.debug("Removing") + except ApiException as exc: + if exc.status == 404: + self.logger.debug("Failed to get current, assuming non-existant") + return + self.logger.debug("Other unhandled error", exc=exc) + raise exc + + def get_reference_object(self) -> T: + """Return object as it should be""" + raise NotImplementedError + + def reconcile(self, current: T, reference: T): + """Check what operations should be done, should be raised as + ReconcileTrigger""" + raise NotImplementedError + + def create(self, reference: T): + """API Wrapper to create object""" + raise NotImplementedError + + def retrieve(self) -> T: + """API Wrapper to retrive object""" + raise NotImplementedError + + def delete(self, reference: T): + """API Wrapper to delete object""" + raise NotImplementedError + + def update(self, current: T, reference: T): + """API Wrapper to update object""" + raise NotImplementedError + + def get_object_meta(self, **kwargs) -> V1ObjectMeta: + """Get common object metadata""" + return V1ObjectMeta( + namespace=self.namespace, + labels={ + "app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}", + "app.kubernetes.io/instance": self.controller.outpost.name, + "app.kubernetes.io/version": __version__, + "app.kubernetes.io/managed-by": "goauthentik.io", + "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex, + }, + **kwargs, + ) diff --git a/authentik/outposts/controllers/k8s/deployment.py b/authentik/outposts/controllers/k8s/deployment.py new file mode 100644 index 00000000..981cade8 --- /dev/null +++ b/authentik/outposts/controllers/k8s/deployment.py @@ -0,0 +1,134 @@ +"""Kubernetes Deployment Reconciler""" +from typing import TYPE_CHECKING, Dict + +from kubernetes.client import ( + AppsV1Api, + V1Container, + V1ContainerPort, + V1Deployment, + V1DeploymentSpec, + V1EnvVar, + V1EnvVarSource, + V1LabelSelector, + V1ObjectMeta, + V1PodSpec, + V1PodTemplateSpec, + V1SecretKeySelector, +) + +from authentik import __version__ +from authentik.lib.config import CONFIG +from authentik.outposts.controllers.k8s.base import ( + KubernetesObjectReconciler, + NeedsUpdate, +) +from authentik.outposts.models import Outpost + +if TYPE_CHECKING: + from authentik.outposts.controllers.kubernetes import KubernetesController + + +class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): + """Kubernetes Deployment Reconciler""" + + outpost: Outpost + + def __init__(self, controller: "KubernetesController") -> None: + super().__init__(controller) + self.api = AppsV1Api(controller.client) + self.outpost = self.controller.outpost + + @property + def name(self) -> str: + return f"authentik-outpost-{self.controller.outpost.uuid.hex}" + + def reconcile(self, current: V1Deployment, reference: V1Deployment): + if current.spec.replicas != reference.spec.replicas: + raise NeedsUpdate() + if ( + current.spec.template.spec.containers[0].image + != reference.spec.template.spec.containers[0].image + ): + raise NeedsUpdate() + + def get_pod_meta(self) -> Dict[str, str]: + """Get common object metadata""" + return { + "app.kubernetes.io/name": "authentik-outpost", + "app.kubernetes.io/managed-by": "goauthentik.io", + "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex, + } + + def get_reference_object(self) -> V1Deployment: + """Get deployment object for outpost""" + # Generate V1ContainerPort objects + container_ports = [] + for port_name, port in self.controller.deployment_ports.items(): + container_ports.append(V1ContainerPort(container_port=port, name=port_name)) + meta = self.get_object_meta(name=self.name) + secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" + image_prefix = CONFIG.y("outposts.docker_image_base") + return V1Deployment( + metadata=meta, + spec=V1DeploymentSpec( + replicas=self.outpost.config.kubernetes_replicas, + selector=V1LabelSelector(match_labels=self.get_pod_meta()), + template=V1PodTemplateSpec( + metadata=V1ObjectMeta(labels=self.get_pod_meta()), + spec=V1PodSpec( + containers=[ + V1Container( + name=str(self.outpost.type), + image=f"{image_prefix}-{self.outpost.type}:{__version__}", + ports=container_ports, + env=[ + V1EnvVar( + name="AUTHENTIK_HOST", + value_from=V1EnvVarSource( + secret_key_ref=V1SecretKeySelector( + name=secret_name, + key="authentik_host", + ) + ), + ), + V1EnvVar( + name="AUTHENTIK_TOKEN", + value_from=V1EnvVarSource( + secret_key_ref=V1SecretKeySelector( + name=secret_name, + key="token", + ) + ), + ), + V1EnvVar( + name="AUTHENTIK_INSECURE", + value_from=V1EnvVarSource( + secret_key_ref=V1SecretKeySelector( + name=secret_name, + key="authentik_host_insecure", + ) + ), + ), + ], + ) + ] + ), + ), + ), + ) + + def create(self, reference: V1Deployment): + return self.api.create_namespaced_deployment(self.namespace, reference) + + def delete(self, reference: V1Deployment): + return self.api.delete_namespaced_deployment( + reference.metadata.name, self.namespace + ) + + def retrieve(self) -> V1Deployment: + return self.api.read_namespaced_deployment(self.name, self.namespace) + + def update(self, current: V1Deployment, reference: V1Deployment): + return self.api.patch_namespaced_deployment( + current.metadata.name, self.namespace, reference + ) diff --git a/authentik/outposts/controllers/k8s/secret.py b/authentik/outposts/controllers/k8s/secret.py new file mode 100644 index 00000000..a70866c0 --- /dev/null +++ b/authentik/outposts/controllers/k8s/secret.py @@ -0,0 +1,67 @@ +"""Kubernetes Secret Reconciler""" +from base64 import b64encode +from typing import TYPE_CHECKING + +from kubernetes.client import CoreV1Api, V1Secret + +from authentik.outposts.controllers.k8s.base import ( + KubernetesObjectReconciler, + NeedsUpdate, +) + +if TYPE_CHECKING: + from authentik.outposts.controllers.kubernetes import KubernetesController + + +def b64string(source: str) -> str: + """Base64 Encode string""" + return b64encode(source.encode()).decode("utf-8") + + +class SecretReconciler(KubernetesObjectReconciler[V1Secret]): + """Kubernetes Secret Reconciler""" + + def __init__(self, controller: "KubernetesController") -> None: + super().__init__(controller) + self.api = CoreV1Api(controller.client) + + @property + def name(self) -> str: + return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" + + def reconcile(self, current: V1Secret, reference: V1Secret): + for key in reference.data.keys(): + if current.data[key] != reference.data[key]: + raise NeedsUpdate() + + def get_reference_object(self) -> V1Secret: + """Get deployment object for outpost""" + meta = self.get_object_meta(name=self.name) + return V1Secret( + metadata=meta, + data={ + "authentik_host": b64string( + self.controller.outpost.config.authentik_host + ), + "authentik_host_insecure": b64string( + str(self.controller.outpost.config.authentik_host_insecure) + ), + "token": b64string(self.controller.outpost.token.token_uuid.hex), + }, + ) + + def create(self, reference: V1Secret): + return self.api.create_namespaced_secret(self.namespace, reference) + + def delete(self, reference: V1Secret): + return self.api.delete_namespaced_secret( + reference.metadata.name, self.namespace + ) + + def retrieve(self) -> V1Secret: + return self.api.read_namespaced_secret(self.name, self.namespace) + + def update(self, current: V1Secret, reference: V1Secret): + return self.api.patch_namespaced_secret( + current.metadata.name, self.namespace, reference + ) diff --git a/authentik/outposts/controllers/k8s/service.py b/authentik/outposts/controllers/k8s/service.py new file mode 100644 index 00000000..b710832f --- /dev/null +++ b/authentik/outposts/controllers/k8s/service.py @@ -0,0 +1,60 @@ +"""Kubernetes Service Reconciler""" +from typing import TYPE_CHECKING + +from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec + +from authentik.outposts.controllers.k8s.base import ( + KubernetesObjectReconciler, + NeedsUpdate, +) +from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler + +if TYPE_CHECKING: + from authentik.outposts.controllers.kubernetes import KubernetesController + + +class ServiceReconciler(KubernetesObjectReconciler[V1Service]): + """Kubernetes Service Reconciler""" + + def __init__(self, controller: "KubernetesController") -> None: + super().__init__(controller) + self.api = CoreV1Api(controller.client) + + @property + def name(self) -> str: + return f"authentik-outpost-{self.controller.outpost.uuid.hex}" + + def reconcile(self, current: V1Service, reference: V1Service): + if len(current.spec.ports) != len(reference.spec.ports): + raise NeedsUpdate() + for port in reference.spec.ports: + if port not in current.spec.ports: + raise NeedsUpdate() + + def get_reference_object(self) -> V1Service: + """Get deployment object for outpost""" + meta = self.get_object_meta(name=self.name) + ports = [] + for port_name, port in self.controller.deployment_ports.items(): + ports.append(V1ServicePort(name=port_name, port=port)) + selector_labels = DeploymentReconciler(self.controller).get_pod_meta() + return V1Service( + metadata=meta, + spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"), + ) + + def create(self, reference: V1Service): + return self.api.create_namespaced_service(self.namespace, reference) + + def delete(self, reference: V1Service): + return self.api.delete_namespaced_service( + reference.metadata.name, self.namespace + ) + + def retrieve(self) -> V1Service: + return self.api.read_namespaced_service(self.name, self.namespace) + + def update(self, current: V1Service, reference: V1Service): + return self.api.patch_namespaced_service( + current.metadata.name, self.namespace, reference + ) diff --git a/authentik/outposts/controllers/kubernetes.py b/authentik/outposts/controllers/kubernetes.py new file mode 100644 index 00000000..f75edf82 --- /dev/null +++ b/authentik/outposts/controllers/kubernetes.py @@ -0,0 +1,81 @@ +"""Kubernetes deployment controller""" +from io import StringIO +from typing import Dict, List, Type + +from kubernetes.client import OpenApiException +from kubernetes.client.api_client import ApiClient +from structlog.testing import capture_logs +from yaml import dump_all + +from authentik.outposts.controllers.base import BaseController, ControllerException +from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler +from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler +from authentik.outposts.controllers.k8s.secret import SecretReconciler +from authentik.outposts.controllers.k8s.service import ServiceReconciler +from authentik.outposts.models import KubernetesServiceConnection, Outpost + + +class KubernetesController(BaseController): + """Manage deployment of outpost in kubernetes""" + + reconcilers: Dict[str, Type[KubernetesObjectReconciler]] + reconcile_order: List[str] + + client: ApiClient + connection: KubernetesServiceConnection + + def __init__( + self, outpost: Outpost, connection: KubernetesServiceConnection + ) -> None: + super().__init__(outpost, connection) + self.client = connection.client() + self.reconcilers = { + "secret": SecretReconciler, + "deployment": DeploymentReconciler, + "service": ServiceReconciler, + } + self.reconcile_order = ["secret", "deployment", "service"] + + def up(self): + try: + for reconcile_key in self.reconcile_order: + reconciler = self.reconcilers[reconcile_key](self) + reconciler.up() + + except OpenApiException as exc: + raise ControllerException from exc + + def up_with_logs(self) -> List[str]: + try: + all_logs = [] + for reconcile_key in self.reconcile_order: + with capture_logs() as logs: + reconciler = self.reconcilers[reconcile_key](self) + reconciler.up() + all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs] + return all_logs + except OpenApiException as exc: + raise ControllerException from exc + + def down(self): + try: + for reconcile_key in self.reconcile_order: + reconciler = self.reconcilers[reconcile_key](self) + reconciler.down() + + except OpenApiException as exc: + raise ControllerException from exc + + def get_static_deployment(self) -> str: + documents = [] + for reconcile_key in self.reconcile_order: + reconciler = self.reconcilers[reconcile_key](self) + documents.append(reconciler.get_reference_object().to_dict()) + + with StringIO() as _str: + dump_all( + documents, + stream=_str, + default_flow_style=False, + ) + return _str.getvalue() diff --git a/authentik/outposts/docker_tls.py b/authentik/outposts/docker_tls.py new file mode 100644 index 00000000..0ecbc838 --- /dev/null +++ b/authentik/outposts/docker_tls.py @@ -0,0 +1,56 @@ +"""Create Docker TLSConfig from CertificateKeyPair""" +from pathlib import Path +from tempfile import gettempdir +from typing import Optional + +from docker.tls import TLSConfig + +from authentik.crypto.models import CertificateKeyPair + + +class DockerInlineTLS: + """Create Docker TLSConfig from CertificateKeyPair""" + + verification_kp: Optional[CertificateKeyPair] + authentication_kp: Optional[CertificateKeyPair] + + def __init__( + self, + verification_kp: Optional[CertificateKeyPair], + authentication_kp: Optional[CertificateKeyPair], + ) -> None: + self.verification_kp = verification_kp + self.authentication_kp = authentication_kp + + def write_file(self, name: str, contents: str) -> str: + """Wrapper for mkstemp that uses fdopen""" + path = Path(gettempdir(), name) + with open(path, "w") as _file: + _file.write(contents) + return str(path) + + def write(self) -> TLSConfig: + """Create TLSConfig with Certificate Keypairs""" + # So yes, this is quite ugly. But sadly, there is no clean way to pass + # docker-py (which is using requests (which is using urllib3)) a certificate + # for verification or authentication as string. + # Because we run in docker, and our tmpfs is isolated to us, we can just + # write out the certificates and keys to files and use their paths + config_args = {} + if self.verification_kp: + ca_cert_path = self.write_file( + f"{self.verification_kp.pk.hex}-cert.pem", + self.verification_kp.certificate_data, + ) + config_args["ca_cert"] = ca_cert_path + if self.authentication_kp: + auth_cert_path = self.write_file( + f"{self.authentication_kp.pk.hex}-cert.pem", + self.authentication_kp.certificate_data, + ) + auth_key_path = self.write_file( + f"{self.authentication_kp.pk.hex}-key.pem", + self.authentication_kp.key_data, + ) + config_args["client_cert"] = (auth_cert_path, auth_key_path) + return TLSConfig(**config_args) diff --git a/authentik/outposts/forms.py b/authentik/outposts/forms.py new file mode 100644 index 00000000..812f7e5a --- /dev/null +++ b/authentik/outposts/forms.py @@ -0,0 +1,88 @@ +"""Outpost forms""" + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from authentik.admin.fields import CodeMirrorWidget, YAMLField +from authentik.crypto.models import CertificateKeyPair +from authentik.outposts.models import ( + DockerServiceConnection, + KubernetesServiceConnection, + Outpost, + OutpostServiceConnection, +) +from authentik.providers.proxy.models import ProxyProvider + + +class OutpostForm(forms.ModelForm): + """Outpost Form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["providers"].queryset = ProxyProvider.objects.all() + self.fields[ + "service_connection" + ].queryset = OutpostServiceConnection.objects.select_subclasses() + + class Meta: + + model = Outpost + fields = [ + "name", + "type", + "service_connection", + "providers", + "_config", + ] + widgets = { + "name": forms.TextInput(), + "_config": CodeMirrorWidget, + } + field_classes = { + "_config": YAMLField, + } + labels = {"_config": _("Configuration")} + + +class DockerServiceConnectionForm(forms.ModelForm): + """Docker service-connection form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["tls_authentication"].queryset = CertificateKeyPair.objects.filter( + key_data__isnull=False + ) + + class Meta: + + model = DockerServiceConnection + fields = ["name", "local", "url", "tls_verification", "tls_authentication"] + widgets = { + "name": forms.TextInput, + "url": forms.TextInput, + } + labels = { + "url": _("URL"), + "tls_verification": _("TLS Verification Certificate"), + "tls_authentication": _("TLS Authentication Certificate"), + } + + +class KubernetesServiceConnectionForm(forms.ModelForm): + """Kubernetes service-connection form""" + + class Meta: + + model = KubernetesServiceConnection + fields = [ + "name", + "local", + "kubeconfig", + ] + widgets = { + "name": forms.TextInput, + "kubeconfig": CodeMirrorWidget, + } + field_classes = { + "kubeconfig": YAMLField, + } diff --git a/authentik/outposts/migrations/0001_initial.py b/authentik/outposts/migrations/0001_initial.py new file mode 100644 index 00000000..ec5769d6 --- /dev/null +++ b/authentik/outposts/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1 on 2020-08-25 20:45 + +import uuid + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0008_auto_20200824_1532"), + ] + + operations = [ + migrations.CreateModel( + name="Outpost", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField()), + ( + "channels", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), size=None + ), + ), + ("providers", models.ManyToManyField(to="authentik_core.Provider")), + ], + ), + ] diff --git a/authentik/outposts/migrations/0002_auto_20200826_1306.py b/authentik/outposts/migrations/0002_auto_20200826_1306.py new file mode 100644 index 00000000..4a916f37 --- /dev/null +++ b/authentik/outposts/migrations/0002_auto_20200826_1306.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1 on 2020-08-26 13:06 + +from django.db import migrations, models + +import authentik.outposts.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="outpost", + name="_config", + field=models.JSONField( + default=authentik.outposts.models.default_outpost_config + ), + ), + migrations.AddField( + model_name="outpost", + name="type", + field=models.TextField(choices=[("proxy", "Proxy")], default="proxy"), + ), + ] diff --git a/authentik/outposts/migrations/0003_auto_20200827_2108.py b/authentik/outposts/migrations/0003_auto_20200827_2108.py new file mode 100644 index 00000000..0c76de4c --- /dev/null +++ b/authentik/outposts/migrations/0003_auto_20200827_2108.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1 on 2020-08-27 21:08 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0002_auto_20200826_1306"), + ] + + operations = [ + migrations.AddField( + model_name="outpost", + name="deployment_type", + field=models.TextField( + choices=[ + ("docker_compose", "Docker Compose"), + ("kubernetes", "Kubernetes"), + ("custom", "Custom"), + ], + default="custom", + help_text="Select between authentik-managed deployment types or a custom deployment.", + ), + ), + migrations.AlterField( + model_name="outpost", + name="channels", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), default=list, size=None + ), + ), + ] diff --git a/authentik/outposts/migrations/0004_auto_20200830_1056.py b/authentik/outposts/migrations/0004_auto_20200830_1056.py new file mode 100644 index 00000000..e4b9d3d7 --- /dev/null +++ b/authentik/outposts/migrations/0004_auto_20200830_1056.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1 on 2020-08-30 10:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0003_auto_20200827_2108"), + ] + + operations = [ + migrations.AlterField( + model_name="outpost", + name="deployment_type", + field=models.TextField( + choices=[("kubernetes", "Kubernetes"), ("custom", "Custom")], + default="custom", + help_text="Select between authentik-managed deployment types or a custom deployment.", + ), + ), + ] diff --git a/authentik/outposts/migrations/0005_auto_20200909_1733.py b/authentik/outposts/migrations/0005_auto_20200909_1733.py new file mode 100644 index 00000000..9ec22e02 --- /dev/null +++ b/authentik/outposts/migrations/0005_auto_20200909_1733.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.1 on 2020-09-09 17:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0004_auto_20200830_1056"), + ] + + operations = [ + migrations.AlterField( + model_name="outpost", + name="deployment_type", + field=models.TextField( + choices=[("custom", "Custom")], + default="custom", + help_text="Select between authentik-managed deployment types or a custom deployment.", + ), + ), + ] diff --git a/authentik/outposts/migrations/0006_auto_20201003_2239.py b/authentik/outposts/migrations/0006_auto_20201003_2239.py new file mode 100644 index 00000000..ffc5bc0f --- /dev/null +++ b/authentik/outposts/migrations/0006_auto_20201003_2239.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.2 on 2020-10-03 22:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0005_auto_20200909_1733"), + ] + + operations = [ + migrations.AlterField( + model_name="outpost", + name="deployment_type", + field=models.TextField( + choices=[ + ("docker", "Docker"), + ("custom", "Custom"), + ], + default="custom", + help_text="Select between authentik-managed deployment types or a custom deployment.", + ), + ), + ] diff --git a/authentik/outposts/migrations/0007_remove_outpost_channels.py b/authentik/outposts/migrations/0007_remove_outpost_channels.py new file mode 100644 index 00000000..e4b0950d --- /dev/null +++ b/authentik/outposts/migrations/0007_remove_outpost_channels.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.2 on 2020-10-14 08:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0006_auto_20201003_2239"), + ] + + operations = [ + migrations.RemoveField( + model_name="outpost", + name="channels", + ), + ] diff --git a/authentik/outposts/migrations/0008_auto_20201014_1547.py b/authentik/outposts/migrations/0008_auto_20201014_1547.py new file mode 100644 index 00000000..bbed57b1 --- /dev/null +++ b/authentik/outposts/migrations/0008_auto_20201014_1547.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1.2 on 2020-10-14 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0007_remove_outpost_channels"), + ] + + operations = [ + migrations.AlterField( + model_name="outpost", + name="deployment_type", + field=models.TextField( + choices=[ + ("kubernetes", "Kubernetes"), + ("docker", "Docker"), + ("custom", "Custom"), + ], + default="custom", + help_text="Select between authentik-managed deployment types or a custom deployment.", + ), + ), + ] diff --git a/authentik/outposts/migrations/0009_fix_missing_token_identifier.py b/authentik/outposts/migrations/0009_fix_missing_token_identifier.py new file mode 100644 index 00000000..c6a70abb --- /dev/null +++ b/authentik/outposts/migrations/0009_fix_missing_token_identifier.py @@ -0,0 +1,36 @@ +# Generated by Django 3.1.2 on 2020-10-17 14:26 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + User = apps.get_model("authentik_core", "User") + Token = apps.get_model("authentik_core", "Token") + from authentik.outposts.models import Outpost + + for outpost in ( + Outpost.objects.using(schema_editor.connection.alias).all().only("pk") + ): + user_identifier = outpost.user_identifier + users = User.objects.filter(username=user_identifier) + if not users.exists(): + continue + tokens = Token.objects.filter(user=users.first()) + for token in tokens: + if token.identifier != outpost.token_identifier: + token.identifier = outpost.token_identifier + token.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0014_auto_20201018_1158"), + ("authentik_outposts", "0008_auto_20201014_1547"), + ] + + operations = [ + migrations.RunPython(fix_missing_token_identifier), + ] diff --git a/authentik/outposts/migrations/0010_service_connection.py b/authentik/outposts/migrations/0010_service_connection.py new file mode 100644 index 00000000..51a0c328 --- /dev/null +++ b/authentik/outposts/migrations/0010_service_connection.py @@ -0,0 +1,168 @@ +# Generated by Django 3.1.3 on 2020-11-04 09:11 + +import uuid + +import django.db.models.deletion +from django.apps.registry import Apps +from django.core.exceptions import FieldError +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import authentik.lib.models + + +def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + Outpost = apps.get_model("authentik_outposts", "Outpost") + DockerServiceConnection = apps.get_model( + "authentik_outposts", "DockerServiceConnection" + ) + KubernetesServiceConnection = apps.get_model( + "authentik_outposts", "KubernetesServiceConnection" + ) + + docker = DockerServiceConnection.objects.filter(local=True).first() + k8s = KubernetesServiceConnection.objects.filter(local=True).first() + + try: + for outpost in ( + Outpost.objects.using(db_alias).all().exclude(deployment_type="custom") + ): + if outpost.deployment_type == "kubernetes": + outpost.service_connection = k8s + elif outpost.deployment_type == "docker": + outpost.service_connection = docker + outpost.save() + except FieldError: + # This is triggered during e2e tests when this function is called on an already-upgraded + # schema + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0009_fix_missing_token_identifier"), + ] + + operations = [ + migrations.CreateModel( + name="OutpostServiceConnection", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField()), + ( + "local", + models.BooleanField( + default=False, + help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration", + unique=True, + ), + ), + ], + ), + migrations.CreateModel( + name="DockerServiceConnection", + fields=[ + ( + "outpostserviceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_outposts.outpostserviceconnection", + ), + ), + ("url", models.TextField()), + ("tls", models.BooleanField()), + ], + bases=("authentik_outposts.outpostserviceconnection",), + ), + migrations.CreateModel( + name="KubernetesServiceConnection", + fields=[ + ( + "outpostserviceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_outposts.outpostserviceconnection", + ), + ), + ("kubeconfig", models.JSONField()), + ], + bases=("authentik_outposts.outpostserviceconnection",), + ), + migrations.AddField( + model_name="outpost", + name="service_connection", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Select Service-Connection authentik should use to manage this outpost. Leave empty if authentik should not handle the deployment.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_outposts.outpostserviceconnection", + ), + ), + migrations.RunPython(migrate_to_service_connection), + migrations.RemoveField( + model_name="outpost", + name="deployment_type", + ), + migrations.AlterModelOptions( + name="dockerserviceconnection", + options={ + "verbose_name": "Docker Service-Connection", + "verbose_name_plural": "Docker Service-Connections", + }, + ), + migrations.AlterModelOptions( + name="kubernetesserviceconnection", + options={ + "verbose_name": "Kubernetes Service-Connection", + "verbose_name_plural": "Kubernetes Service-Connections", + }, + ), + migrations.AlterField( + model_name="outpost", + name="service_connection", + field=authentik.lib.models.InheritanceForeignKey( + blank=True, + default=None, + help_text="Select Service-Connection authentik should use to manage this outpost. Leave empty if authentik should not handle the deployment.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_outposts.outpostserviceconnection", + ), + ), + migrations.AlterModelOptions( + name="outpostserviceconnection", + options={ + "verbose_name": "Outpost Service-Connection", + "verbose_name_plural": "Outpost Service-Connections", + }, + ), + migrations.AlterField( + model_name="kubernetesserviceconnection", + name="kubeconfig", + field=models.JSONField( + default=None, + help_text="Paste your kubeconfig here. authentik will automatically use the currently selected context.", + ), + preserve_default=False, + ), + ] diff --git a/authentik/outposts/migrations/0011_docker_tls_auth.py b/authentik/outposts/migrations/0011_docker_tls_auth.py new file mode 100644 index 00000000..0905d44a --- /dev/null +++ b/authentik/outposts/migrations/0011_docker_tls_auth.py @@ -0,0 +1,45 @@ +# Generated by Django 3.1.3 on 2020-11-18 21:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0002_create_self_signed_kp"), + ("authentik_outposts", "0010_service_connection"), + ] + + operations = [ + migrations.RemoveField( + model_name="dockerserviceconnection", + name="tls", + ), + migrations.AddField( + model_name="dockerserviceconnection", + name="tls_authentication", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Certificate/Key used for authentication. Can be left empty for no authentication.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_crypto.certificatekeypair", + ), + ), + migrations.AddField( + model_name="dockerserviceconnection", + name="tls_verification", + field=models.ForeignKey( + blank=True, + default=None, + help_text="CA which the endpoint's Certificate is verified against. Can be left empty for no validation.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_crypto.certificatekeypair", + ), + ), + ] diff --git a/authentik/outposts/migrations/0012_service_connection_non_unique.py b/authentik/outposts/migrations/0012_service_connection_non_unique.py new file mode 100644 index 00000000..f7f7815a --- /dev/null +++ b/authentik/outposts/migrations/0012_service_connection_non_unique.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.3 on 2020-11-18 21:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_outposts", "0011_docker_tls_auth"), + ] + + operations = [ + migrations.AlterField( + model_name="outpostserviceconnection", + name="local", + field=models.BooleanField( + default=False, + help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration", + ), + ), + ] diff --git a/authentik/outposts/migrations/0013_auto_20201203_2009.py b/authentik/outposts/migrations/0013_auto_20201203_2009.py new file mode 100644 index 00000000..58c67dcf --- /dev/null +++ b/authentik/outposts/migrations/0013_auto_20201203_2009.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.4 on 2020-12-03 20:09 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + alias = schema_editor.connection.alias + User = apps.get_model("authentik_core", "User") + Outpost = apps.get_model("authentik_outposts", "Outpost") + + for outpost in Outpost.objects.using(alias).all(): + matching = User.objects.using(alias).filter( + username=f"pb-outpost-{outpost.uuid.hex}" + ) + if matching.exists(): + matching.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0016_auto_20201202_2234"), + ("authentik_outposts", "0012_service_connection_non_unique"), + ] + + operations = [ + migrations.RunPython(remove_pb_prefix_users), + ] diff --git a/passbook/outposts/migrations/__init__.py b/authentik/outposts/migrations/__init__.py similarity index 100% rename from passbook/outposts/migrations/__init__.py rename to authentik/outposts/migrations/__init__.py diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py new file mode 100644 index 00000000..e21280d8 --- /dev/null +++ b/authentik/outposts/models.py @@ -0,0 +1,427 @@ +"""Outpost models""" +from dataclasses import asdict, dataclass, field +from datetime import datetime +from typing import Dict, Iterable, List, Optional, Type, Union +from uuid import uuid4 + +from dacite import from_dict +from django.core.cache import cache +from django.db import models, transaction +from django.db.models.base import Model +from django.forms.models import ModelForm +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ +from docker.client import DockerClient +from docker.errors import DockerException +from guardian.models import UserObjectPermission +from guardian.shortcuts import assign_perm +from kubernetes.client import VersionApi, VersionInfo +from kubernetes.client.api_client import ApiClient +from kubernetes.client.configuration import Configuration +from kubernetes.client.exceptions import OpenApiException +from kubernetes.config.config_exception import ConfigException +from kubernetes.config.incluster_config import load_incluster_config +from kubernetes.config.kube_config import load_kube_config_from_dict +from model_utils.managers import InheritanceManager +from packaging.version import LegacyVersion, Version, parse +from structlog import get_logger +from urllib3.exceptions import HTTPError + +from authentik import __version__ +from authentik.core.models import USER_ATTRIBUTE_SA, Provider, Token, TokenIntents, User +from authentik.crypto.models import CertificateKeyPair +from authentik.lib.config import CONFIG +from authentik.lib.models import InheritanceForeignKey +from authentik.lib.sentry import SentryIgnoredException +from authentik.lib.utils.template import render_to_string +from authentik.outposts.docker_tls import DockerInlineTLS + +OUR_VERSION = parse(__version__) +OUTPOST_HELLO_INTERVAL = 10 +LOGGER = get_logger() + + +class ServiceConnectionInvalid(SentryIgnoredException): + """"Exception raised when a Service Connection has invalid parameters""" + + +@dataclass +class OutpostConfig: + """Configuration an outpost uses to configure it self""" + + authentik_host: str + authentik_host_insecure: bool = False + + log_level: str = CONFIG.y("log_level") + error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled") + error_reporting_environment: str = CONFIG.y( + "error_reporting.environment", "customer" + ) + + kubernetes_replicas: int = field(default=1) + kubernetes_namespace: str = field(default="default") + kubernetes_ingress_annotations: Dict[str, str] = field(default_factory=dict) + kubernetes_ingress_secret_name: str = field(default="authentik-outpost") + + +class OutpostModel(Model): + """Base model for providers that need more objects than just themselves""" + + def get_required_objects(self) -> Iterable[models.Model]: + """Return a list of all required objects""" + return [self] + + class Meta: + + abstract = True + + +class OutpostType(models.TextChoices): + """Outpost types, currently only the reverse proxy is available""" + + PROXY = "proxy" + + +def default_outpost_config(): + """Get default outpost config""" + return asdict(OutpostConfig(authentik_host="")) + + +@dataclass +class OutpostServiceConnectionState: + """State of an Outpost Service Connection""" + + version: str + healthy: bool + + +class OutpostServiceConnection(models.Model): + """Connection details for an Outpost Controller, like Docker or Kubernetes""" + + uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) + name = models.TextField() + + local = models.BooleanField( + default=False, + help_text=_( + ( + "If enabled, use the local connection. Required Docker " + "socket/Kubernetes Integration" + ) + ), + ) + + objects = InheritanceManager() + + @property + def state(self) -> OutpostServiceConnectionState: + """Get state of service connection""" + state_key = f"outpost_service_connection_{self.pk.hex}" + state = cache.get(state_key, None) + if not state: + state = self._get_state() + cache.set(state_key, state, timeout=0) + return state + + def _get_state(self) -> OutpostServiceConnectionState: + raise NotImplementedError + + @property + def form(self) -> Type[ModelForm]: + """Return Form class used to edit this object""" + raise NotImplementedError + + class Meta: + + verbose_name = _("Outpost Service-Connection") + verbose_name_plural = _("Outpost Service-Connections") + + +class DockerServiceConnection(OutpostServiceConnection): + """Service Connection to a Docker endpoint""" + + url = models.TextField() + tls_verification = models.ForeignKey( + CertificateKeyPair, + null=True, + blank=True, + default=None, + related_name="+", + on_delete=models.SET_DEFAULT, + help_text=_( + ( + "CA which the endpoint's Certificate is verified against. " + "Can be left empty for no validation." + ) + ), + ) + tls_authentication = models.ForeignKey( + CertificateKeyPair, + null=True, + blank=True, + default=None, + related_name="+", + on_delete=models.SET_DEFAULT, + help_text=_( + "Certificate/Key used for authentication. Can be left empty for no authentication." + ), + ) + + @property + def form(self) -> Type[ModelForm]: + from authentik.outposts.forms import DockerServiceConnectionForm + + return DockerServiceConnectionForm + + def __str__(self) -> str: + return f"Docker Service-Connection {self.name}" + + def client(self) -> DockerClient: + """Get DockerClient""" + try: + client = None + if self.local: + client = DockerClient.from_env() + else: + client = DockerClient( + base_url=self.url, + tls=DockerInlineTLS( + verification_kp=self.tls_verification, + authentication_kp=self.tls_authentication, + ).write(), + ) + client.containers.list() + except DockerException as exc: + LOGGER.error(exc) + raise ServiceConnectionInvalid from exc + return client + + def _get_state(self) -> OutpostServiceConnectionState: + try: + client = self.client() + return OutpostServiceConnectionState( + version=client.info()["ServerVersion"], healthy=True + ) + except ServiceConnectionInvalid: + return OutpostServiceConnectionState(version="", healthy=False) + + class Meta: + + verbose_name = _("Docker Service-Connection") + verbose_name_plural = _("Docker Service-Connections") + + +class KubernetesServiceConnection(OutpostServiceConnection): + """Service Connection to a Kubernetes cluster""" + + kubeconfig = models.JSONField( + help_text=_( + ( + "Paste your kubeconfig here. authentik will automatically use " + "the currently selected context." + ) + ) + ) + + @property + def form(self) -> Type[ModelForm]: + from authentik.outposts.forms import KubernetesServiceConnectionForm + + return KubernetesServiceConnectionForm + + def __str__(self) -> str: + return f"Kubernetes Service-Connection {self.name}" + + def _get_state(self) -> OutpostServiceConnectionState: + try: + client = self.client() + api_instance = VersionApi(client) + version: VersionInfo = api_instance.get_code() + return OutpostServiceConnectionState( + version=version.git_version, healthy=True + ) + except (OpenApiException, HTTPError): + return OutpostServiceConnectionState(version="", healthy=False) + + def client(self) -> ApiClient: + """Get Kubernetes client configured from kubeconfig""" + config = Configuration() + try: + if self.local: + load_incluster_config(client_configuration=config) + else: + load_kube_config_from_dict(self.kubeconfig, client_configuration=config) + return ApiClient(config) + except ConfigException as exc: + raise ServiceConnectionInvalid from exc + + class Meta: + + verbose_name = _("Kubernetes Service-Connection") + verbose_name_plural = _("Kubernetes Service-Connections") + + +class Outpost(models.Model): + """Outpost instance which manages a service user and token""" + + uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) + name = models.TextField() + + type = models.TextField(choices=OutpostType.choices, default=OutpostType.PROXY) + service_connection = InheritanceForeignKey( + OutpostServiceConnection, + default=None, + null=True, + blank=True, + help_text=_( + ( + "Select Service-Connection authentik should use to manage this outpost. " + "Leave empty if authentik should not handle the deployment." + ) + ), + on_delete=models.SET_DEFAULT, + ) + + _config = models.JSONField(default=default_outpost_config) + + providers = models.ManyToManyField(Provider) + + @property + def config(self) -> OutpostConfig: + """Load config as OutpostConfig object""" + return from_dict(OutpostConfig, self._config) + + @config.setter + def config(self, value): + """Dump config into json""" + self._config = asdict(value) + + @property + def state_cache_prefix(self) -> str: + """Key by which the outposts status is saved""" + return f"outpost_{self.uuid.hex}_state" + + @property + def state(self) -> List["OutpostState"]: + """Get outpost's health status""" + return OutpostState.for_outpost(self) + + @property + def user_identifier(self): + """Username for service user""" + return f"ak-outpost-{self.uuid.hex}" + + @property + def user(self) -> User: + """Get/create user with access to all required objects""" + users = User.objects.filter(username=self.user_identifier) + if not users.exists(): + user: User = User.objects.create(username=self.user_identifier) + user.attributes[USER_ATTRIBUTE_SA] = True + user.set_unusable_password() + user.save() + else: + user = users.first() + # To ensure the user only has the correct permissions, we delete all of them and re-add + # the ones the user needs + with transaction.atomic(): + UserObjectPermission.objects.filter(user=user).delete() + for model in self.get_required_objects(): + code_name = f"{model._meta.app_label}.view_{model._meta.model_name}" + assign_perm(code_name, user, model) + return user + + @property + def token_identifier(self) -> str: + """Get Token identifier""" + return f"ak-outpost-{self.pk}-api" + + @property + def token(self) -> Token: + """Get/create token for auto-generated user""" + token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API) + if token.exists(): + return token.first() + return Token.objects.create( + user=self.user, + identifier=self.token_identifier, + intent=TokenIntents.INTENT_API, + description=f"Autogenerated by authentik for Outpost {self.name}", + expiring=False, + ) + + def get_required_objects(self) -> Iterable[models.Model]: + """Get an iterator of all objects the user needs read access to""" + objects = [self] + for provider in ( + Provider.objects.filter(outpost=self).select_related().select_subclasses() + ): + if isinstance(provider, OutpostModel): + objects.extend(provider.get_required_objects()) + else: + objects.append(provider) + return objects + + def html_deployment_view(self, request: HttpRequest) -> Optional[str]: + """return template and context modal to view token and other config info""" + return render_to_string( + "outposts/deployment_modal.html", + {"outpost": self, "full_url": request.build_absolute_uri("/")}, + ) + + def __str__(self) -> str: + return f"Outpost {self.name}" + + +@dataclass +class OutpostState: + """Outpost instance state, last_seen and version""" + + uid: str + last_seen: Optional[datetime] = field(default=None) + version: Optional[str] = field(default=None) + version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION) + + _outpost: Optional[Outpost] = field(default=None) + + @property + def version_outdated(self) -> bool: + """Check if outpost version matches our version""" + if not self.version: + return False + return parse(self.version) < OUR_VERSION + + @staticmethod + def for_outpost(outpost: Outpost) -> List["OutpostState"]: + """Get all states for an outpost""" + keys = cache.keys(f"{outpost.state_cache_prefix}_*") + states = [] + for key in keys: + channel = key.replace(f"{outpost.state_cache_prefix}_", "") + states.append(OutpostState.for_channel(outpost, channel)) + return states + + @staticmethod + def for_channel(outpost: Outpost, channel: str) -> "OutpostState": + """Get state for a single channel""" + key = f"{outpost.state_cache_prefix}_{channel}" + default_data = {"uid": channel} + data = cache.get(key, default_data) + if isinstance(data, str): + cache.delete(key) + data = default_data + state = from_dict(OutpostState, data) + state.uid = channel + # pylint: disable=protected-access + state._outpost = outpost + return state + + def save(self, timeout=OUTPOST_HELLO_INTERVAL): + """Save current state to cache""" + full_key = f"{self._outpost.state_cache_prefix}_{self.uid}" + return cache.set(full_key, asdict(self), timeout=timeout) + + def delete(self): + """Manually delete from cache, used on channel disconnect""" + full_key = f"{self._outpost.state_cache_prefix}_{self.uid}" + cache.delete(full_key) diff --git a/authentik/outposts/settings.py b/authentik/outposts/settings.py new file mode 100644 index 00000000..d6820a77 --- /dev/null +++ b/authentik/outposts/settings.py @@ -0,0 +1,15 @@ +"""Outposts Settings""" +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + "outposts_controller": { + "task": "authentik.outposts.tasks.outpost_controller_all", + "schedule": crontab(minute="*/5"), + "options": {"queue": "authentik_scheduled"}, + }, + "outposts_service_connection_check": { + "task": "authentik.outposts.tasks.outpost_service_connection_monitor", + "schedule": crontab(minute=0, hour="*"), + "options": {"queue": "authentik_scheduled"}, + }, +} diff --git a/authentik/outposts/signals.py b/authentik/outposts/signals.py new file mode 100644 index 00000000..33bd66e5 --- /dev/null +++ b/authentik/outposts/signals.py @@ -0,0 +1,36 @@ +"""authentik outpost signals""" +from django.db.models import Model +from django.db.models.signals import post_save, pre_delete +from django.dispatch import receiver +from structlog import get_logger + +from authentik.lib.utils.reflection import class_to_path +from authentik.outposts.models import Outpost +from authentik.outposts.tasks import outpost_post_save, outpost_pre_delete + +LOGGER = get_logger() + + +@receiver(post_save) +# pylint: disable=unused-argument +def post_save_update(sender, instance: Model, **_): + """If an Outpost is saved, Ensure that token is created/updated + + If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, + we send a message down the relevant OutpostModels WS connection to trigger an update""" + if instance.__module__ == "django.db.migrations.recorder": + return + if instance.__module__ == "__fake__": + return + outpost_post_save.delay(class_to_path(instance.__class__), instance.pk) + + +@receiver(pre_delete, sender=Outpost) +# pylint: disable=unused-argument +def pre_delete_cleanup(sender, instance: Outpost, **_): + """Ensure that Outpost's user is deleted (which will delete the token through cascade)""" + instance.user.delete() + # To ensure that deployment is cleaned up *consistently* we call the controller, and wait + # for it to finish. We don't want to call it in this thread, as we don't have the K8s + # credentials here + outpost_pre_delete.delay(instance.pk.hex).get() diff --git a/authentik/outposts/tasks.py b/authentik/outposts/tasks.py new file mode 100644 index 00000000..e0e45552 --- /dev/null +++ b/authentik/outposts/tasks.py @@ -0,0 +1,165 @@ +"""outpost tasks""" +from typing import Any + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.core.cache import cache +from django.db.models.base import Model +from django.utils.text import slugify +from structlog import get_logger + +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.lib.utils.reflection import path_to_class +from authentik.outposts.controllers.base import ControllerException +from authentik.outposts.models import ( + DockerServiceConnection, + KubernetesServiceConnection, + Outpost, + OutpostModel, + OutpostServiceConnection, + OutpostState, + OutpostType, +) +from authentik.providers.proxy.controllers.docker import ProxyDockerController +from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController +from authentik.root.celery import CELERY_APP + +LOGGER = get_logger() + + +@CELERY_APP.task() +def outpost_controller_all(): + """Launch Controller for all Outposts which support it""" + for outpost in Outpost.objects.exclude(service_connection=None): + outpost_controller.delay(outpost.pk.hex) + + +@CELERY_APP.task() +def outpost_service_connection_state(state_pk: Any): + """Update cached state of a service connection""" + connection: OutpostServiceConnection = ( + OutpostServiceConnection.objects.filter(pk=state_pk).select_subclasses().first() + ) + cache.delete(f"outpost_service_connection_{connection.pk.hex}") + _ = connection.state + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def outpost_service_connection_monitor(self: MonitoredTask): + """Regularly check the state of Outpost Service Connections""" + for connection in OutpostServiceConnection.objects.select_subclasses(): + cache.delete(f"outpost_service_connection_{connection.pk.hex}") + _ = connection.state + self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def outpost_controller(self: MonitoredTask, outpost_pk: str): + """Create/update/monitor the deployment of an Outpost""" + logs = [] + outpost: Outpost = Outpost.objects.get(pk=outpost_pk) + self.set_uid(slugify(outpost.name)) + try: + if outpost.type == OutpostType.PROXY: + service_connection = outpost.service_connection + if isinstance(service_connection, DockerServiceConnection): + logs = ProxyDockerController(outpost, service_connection).up_with_logs() + if isinstance(service_connection, KubernetesServiceConnection): + logs = ProxyKubernetesController( + outpost, service_connection + ).up_with_logs() + LOGGER.debug("---------------Outpost Controller logs starting----------------") + for log in logs: + LOGGER.debug(log) + LOGGER.debug("-----------------Outpost Controller logs end-------------------") + except ControllerException as exc: + self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) + else: + self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, logs)) + + +@CELERY_APP.task() +def outpost_pre_delete(outpost_pk: str): + """Delete outpost objects before deleting the DB Object""" + outpost = Outpost.objects.get(pk=outpost_pk) + if outpost.type == OutpostType.PROXY: + service_connection = outpost.service_connection + if isinstance(service_connection, DockerServiceConnection): + ProxyDockerController(outpost, service_connection).down() + if isinstance(service_connection, KubernetesServiceConnection): + ProxyKubernetesController(outpost, service_connection).down() + + +@CELERY_APP.task() +def outpost_post_save(model_class: str, model_pk: Any): + """If an Outpost is saved, Ensure that token is created/updated + + If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, + we send a message down the relevant OutpostModels WS connection to trigger an update""" + model: Model = path_to_class(model_class) + try: + instance = model.objects.get(pk=model_pk) + except model.DoesNotExist: + LOGGER.warning("Model does not exist", model=model, pk=model_pk) + return + + if isinstance(instance, Outpost): + LOGGER.debug("Ensuring token for outpost", instance=instance) + _ = instance.token + LOGGER.debug("Trigger reconcile for outpost") + outpost_controller.delay(instance.pk) + return + + if isinstance(instance, (OutpostModel, Outpost)): + LOGGER.debug( + "triggering outpost update from outpostmodel/outpost", instance=instance + ) + outpost_send_update(instance) + return + + if isinstance(instance, OutpostServiceConnection): + LOGGER.debug("triggering ServiceConnection state update", instance=instance) + outpost_service_connection_state.delay(instance.pk) + + for field in instance._meta.get_fields(): + # Each field is checked if it has a `related_model` attribute (when ForeginKeys or M2Ms) + # are used, and if it has a value + if not hasattr(field, "related_model"): + continue + if not field.related_model: + continue + if not issubclass(field.related_model, OutpostModel): + continue + + field_name = f"{field.name}_set" + if not hasattr(instance, field_name): + continue + + LOGGER.debug("triggering outpost update from from field", field=field.name) + # Because the Outpost Model has an M2M to Provider, + # we have to iterate over the entire QS + for reverse in getattr(instance, field_name).all(): + outpost_send_update(reverse) + + +def outpost_send_update(model_instace: Model): + """Send outpost update to all registered outposts, irregardless to which authentik + instance they are connected""" + channel_layer = get_channel_layer() + if isinstance(model_instace, OutpostModel): + for outpost in model_instace.outpost_set.all(): + _outpost_single_update(outpost, channel_layer) + elif isinstance(model_instace, Outpost): + _outpost_single_update(model_instace, channel_layer) + + +def _outpost_single_update(outpost: Outpost, layer=None): + """Update outpost instances connected to a single outpost""" + # Ensure token again, because this function is called when anything related to an + # OutpostModel is saved, so we can be sure permissions are right + _ = outpost.token + if not layer: # pragma: no cover + layer = get_channel_layer() + for state in OutpostState.for_outpost(outpost): + LOGGER.debug("sending update", channel=state.uid, outpost=outpost) + async_to_sync(layer.send)(state.uid, {"type": "event.update"}) diff --git a/authentik/outposts/templates/outposts/deployment_modal.html b/authentik/outposts/templates/outposts/deployment_modal.html new file mode 100644 index 00000000..cbd29db9 --- /dev/null +++ b/authentik/outposts/templates/outposts/deployment_modal.html @@ -0,0 +1,43 @@ +{% load i18n %} + + + +
+
+

{% trans 'Outpost Deployment Info' %}

+
+ + +
+
diff --git a/authentik/outposts/tests.py b/authentik/outposts/tests.py new file mode 100644 index 00000000..9491da0b --- /dev/null +++ b/authentik/outposts/tests.py @@ -0,0 +1,59 @@ +"""outpost tests""" +from django.test import TestCase +from guardian.models import UserObjectPermission + +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow +from authentik.outposts.models import Outpost, OutpostType +from authentik.providers.proxy.models import ProxyProvider + + +class OutpostTests(TestCase): + """Outpost Tests""" + + def test_service_account_permissions(self): + """Test that the service account has correct permissions""" + provider: ProxyProvider = ProxyProvider.objects.create( + name="test", + internal_host="http://localhost", + external_host="http://localhost", + authorization_flow=Flow.objects.first(), + ) + outpost: Outpost = Outpost.objects.create( + name="test", + type=OutpostType.PROXY, + ) + + # Before we add a provider, the user should only have access to the outpost + permissions = UserObjectPermission.objects.filter(user=outpost.user) + self.assertEqual(len(permissions), 1) + self.assertEqual(permissions[0].object_pk, str(outpost.pk)) + + # We add a provider, user should only have access to outpost and provider + outpost.providers.add(provider) + outpost.save() + permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( + "content_type__model" + ) + self.assertEqual(len(permissions), 2) + self.assertEqual(permissions[0].object_pk, str(outpost.pk)) + self.assertEqual(permissions[1].object_pk, str(provider.pk)) + + # Provider requires a certificate-key-pair, user should have permissions for it + keypair = CertificateKeyPair.objects.first() + provider.certificate = keypair + provider.save() + permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( + "content_type__model" + ) + self.assertEqual(len(permissions), 3) + self.assertEqual(permissions[0].object_pk, str(keypair.pk)) + self.assertEqual(permissions[1].object_pk, str(outpost.pk)) + self.assertEqual(permissions[2].object_pk, str(provider.pk)) + + # Remove provider from outpost, user should only have access to outpost + outpost.providers.remove(provider) + outpost.save() + permissions = UserObjectPermission.objects.filter(user=outpost.user) + self.assertEqual(len(permissions), 1) + self.assertEqual(permissions[0].object_pk, str(outpost.pk)) diff --git a/authentik/outposts/urls.py b/authentik/outposts/urls.py new file mode 100644 index 00000000..f6830ee5 --- /dev/null +++ b/authentik/outposts/urls.py @@ -0,0 +1,11 @@ +"""authentik outposts urls""" +from django.urls import path + +from authentik.outposts.views import KubernetesManifestView, SetupView + +urlpatterns = [ + path( + "/k8s/", KubernetesManifestView.as_view(), name="k8s-manifest" + ), + path("/", SetupView.as_view(), name="setup"), +] diff --git a/authentik/outposts/views.py b/authentik/outposts/views.py new file mode 100644 index 00000000..f8606253 --- /dev/null +++ b/authentik/outposts/views.py @@ -0,0 +1,89 @@ +"""authentik outpost views""" +from typing import Any, Dict, List + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Model +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 +from django.views import View +from django.views.generic import TemplateView +from guardian.shortcuts import get_objects_for_user +from structlog import get_logger + +from authentik.core.models import User +from authentik.outposts.controllers.docker import DockerController +from authentik.outposts.models import ( + DockerServiceConnection, + KubernetesServiceConnection, + Outpost, + OutpostType, +) +from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController + +LOGGER = get_logger() + + +def get_object_for_user_or_404(user: User, perm: str, **filters) -> Model: + """Wrapper that combines get_objects_for_user and get_object_or_404""" + return get_object_or_404(get_objects_for_user(user, perm), **filters) + + +class DockerComposeView(LoginRequiredMixin, View): + """Generate docker-compose yaml""" + + def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse: + """Render docker-compose file""" + outpost: Outpost = get_object_for_user_or_404( + request.user, + "authentik_outposts.view_outpost", + pk=outpost_pk, + ) + manifest = "" + if outpost.type == OutpostType.PROXY: + controller = DockerController(outpost, DockerServiceConnection()) + manifest = controller.get_static_deployment() + + return HttpResponse(manifest, content_type="text/vnd.yaml") + + +class KubernetesManifestView(LoginRequiredMixin, View): + """Generate Kubernetes Deployment and SVC for proxy""" + + def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse: + """Render deployment template""" + outpost: Outpost = get_object_for_user_or_404( + request.user, + "authentik_outposts.view_outpost", + pk=outpost_pk, + ) + manifest = "" + if outpost.type == OutpostType.PROXY: + controller = ProxyKubernetesController( + outpost, KubernetesServiceConnection() + ) + manifest = controller.get_static_deployment() + + return HttpResponse(manifest, content_type="text/vnd.yaml") + + +class SetupView(LoginRequiredMixin, TemplateView): + """Setup view""" + + def get_template_names(self) -> List[str]: + allowed = ["dc", "custom", "k8s_manual", "k8s_integration"] + setup_type = self.request.GET.get("type", "dc") + if setup_type not in allowed: + setup_type = allowed[0] + return [f"outposts/setup_{setup_type}.html"] + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + outpost: Outpost = get_object_for_user_or_404( + self.request.user, + "authentik_outposts.view_outpost", + pk=self.kwargs["outpost_pk"], + ) + kwargs.update( + {"host": self.request.build_absolute_uri("/"), "outpost": outpost} + ) + return kwargs diff --git a/passbook/policies/__init__.py b/authentik/policies/__init__.py similarity index 100% rename from passbook/policies/__init__.py rename to authentik/policies/__init__.py diff --git a/authentik/policies/api.py b/authentik/policies/api.py new file mode 100644 index 00000000..4fb93634 --- /dev/null +++ b/authentik/policies/api.py @@ -0,0 +1,100 @@ +"""policy API Views""" +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.serializers import ( + ModelSerializer, + PrimaryKeyRelatedField, + SerializerMethodField, +) +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet + +from authentik.policies.forms import GENERAL_FIELDS +from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel + + +class PolicyBindingModelForeignKey(PrimaryKeyRelatedField): + """rest_framework PrimaryKeyRelatedField which resolves + model_manager's InheritanceQuerySet""" + + def use_pk_only_optimization(self): + return False + + def to_internal_value(self, data): + if self.pk_field is not None: + data = self.pk_field.to_internal_value(data) + try: + # Due to inheritance, a direct DB lookup for the primary key + # won't return anything. This is because the direct lookup + # checks the PK of PolicyBindingModel (for example), + # but we get given the Primary Key of the inheriting class + for model in self.get_queryset().select_subclasses().all().select_related(): + if model.pk == data: + return model + # as a fallback we still try a direct lookup + return self.get_queryset().get_subclass(pk=data) + except ObjectDoesNotExist: + self.fail("does_not_exist", pk_value=data) + except (TypeError, ValueError): + self.fail("incorrect_type", data_type=type(data).__name__) + + def to_representation(self, value): + correct_model = PolicyBindingModel.objects.get_subclass(pbm_uuid=value.pbm_uuid) + return correct_model.pk + + +class PolicySerializer(ModelSerializer): + """Policy Serializer""" + + __type__ = SerializerMethodField(method_name="get_type") + + def get_type(self, obj): + """Get object type so that we know which API Endpoint to use to get the full object""" + return obj._meta.object_name.lower().replace("policy", "") + + def to_representation(self, instance: Policy): + # pyright: reportGeneralTypeIssues=false + if instance.__class__ == Policy: + return super().to_representation(instance) + return instance.serializer(instance=instance).data + + class Meta: + + model = Policy + fields = ["pk"] + GENERAL_FIELDS + ["__type__"] + depth = 3 + + +class PolicyViewSet(ReadOnlyModelViewSet): + """Policy Viewset""" + + queryset = Policy.objects.all() + serializer_class = PolicySerializer + + def get_queryset(self): + return Policy.objects.select_subclasses() + + +class PolicyBindingSerializer(ModelSerializer): + """PolicyBinding Serializer""" + + # Because we're not interested in the PolicyBindingModel's PK but rather the subclasses PK, + # we have to manually declare this field + target = PolicyBindingModelForeignKey( + queryset=PolicyBindingModel.objects.select_subclasses(), + required=True, + ) + + policy_obj = PolicySerializer(read_only=True, source="policy") + + class Meta: + + model = PolicyBinding + fields = ["pk", "policy", "policy_obj", "target", "enabled", "order", "timeout"] + + +class PolicyBindingViewSet(ModelViewSet): + """PolicyBinding Viewset""" + + queryset = PolicyBinding.objects.all() + serializer_class = PolicyBindingSerializer + filterset_fields = ["policy", "target", "enabled", "order", "timeout"] + search_fields = ["policy__name"] diff --git a/authentik/policies/apps.py b/authentik/policies/apps.py new file mode 100644 index 00000000..d36284fa --- /dev/null +++ b/authentik/policies/apps.py @@ -0,0 +1,15 @@ +"""authentik policies app config""" +from importlib import import_module + +from django.apps import AppConfig + + +class AuthentikPoliciesConfig(AppConfig): + """authentik policies app config""" + + name = "authentik.policies" + label = "authentik_policies" + verbose_name = "authentik Policies" + + def ready(self): + import_module("authentik.policies.signals") diff --git a/passbook/policies/dummy/__init__.py b/authentik/policies/dummy/__init__.py similarity index 100% rename from passbook/policies/dummy/__init__.py rename to authentik/policies/dummy/__init__.py diff --git a/authentik/policies/dummy/api.py b/authentik/policies/dummy/api.py new file mode 100644 index 00000000..009524ab --- /dev/null +++ b/authentik/policies/dummy/api.py @@ -0,0 +1,21 @@ +"""Dummy Policy API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS + + +class DummyPolicySerializer(ModelSerializer): + """Dummy Policy Serializer""" + + class Meta: + model = DummyPolicy + fields = GENERAL_SERIALIZER_FIELDS + ["result", "wait_min", "wait_max"] + + +class DummyPolicyViewSet(ModelViewSet): + """Dummy Viewset""" + + queryset = DummyPolicy.objects.all() + serializer_class = DummyPolicySerializer diff --git a/authentik/policies/dummy/apps.py b/authentik/policies/dummy/apps.py new file mode 100644 index 00000000..32792df9 --- /dev/null +++ b/authentik/policies/dummy/apps.py @@ -0,0 +1,11 @@ +"""Authentik policy dummy app config""" + +from django.apps import AppConfig + + +class AuthentikPolicyDummyConfig(AppConfig): + """Authentik policy_dummy app config""" + + name = "authentik.policies.dummy" + label = "authentik_policies_dummy" + verbose_name = "authentik Policies.Dummy" diff --git a/authentik/policies/dummy/forms.py b/authentik/policies/dummy/forms.py new file mode 100644 index 00000000..bccdd107 --- /dev/null +++ b/authentik/policies/dummy/forms.py @@ -0,0 +1,20 @@ +"""authentik Policy forms""" + +from django import forms +from django.utils.translation import gettext as _ + +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.forms import GENERAL_FIELDS + + +class DummyPolicyForm(forms.ModelForm): + """DummyPolicyForm Form""" + + class Meta: + + model = DummyPolicy + fields = GENERAL_FIELDS + ["result", "wait_min", "wait_max"] + widgets = { + "name": forms.TextInput(), + } + labels = {"result": _("Allow user")} diff --git a/authentik/policies/dummy/migrations/0001_initial.py b/authentik/policies/dummy/migrations/0001_initial.py new file mode 100644 index 00000000..4da576a7 --- /dev/null +++ b/authentik/policies/dummy/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="DummyPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.Policy", + ), + ), + ("result", models.BooleanField(default=False)), + ("wait_min", models.IntegerField(default=5)), + ("wait_max", models.IntegerField(default=30)), + ], + options={ + "verbose_name": "Dummy Policy", + "verbose_name_plural": "Dummy Policies", + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/passbook/policies/dummy/migrations/__init__.py b/authentik/policies/dummy/migrations/__init__.py similarity index 100% rename from passbook/policies/dummy/migrations/__init__.py rename to authentik/policies/dummy/migrations/__init__.py diff --git a/authentik/policies/dummy/models.py b/authentik/policies/dummy/models.py new file mode 100644 index 00000000..23fe29c2 --- /dev/null +++ b/authentik/policies/dummy/models.py @@ -0,0 +1,50 @@ +"""Dummy policy""" +from random import SystemRandom +from time import sleep +from typing import Type + +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import BaseSerializer +from structlog import get_logger + +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult + +LOGGER = get_logger() + + +class DummyPolicy(Policy): + """Policy used for debugging the PolicyEngine. Returns a fixed result, + but takes a random time to process.""" + + __debug_only__ = True + + result = models.BooleanField(default=False) + wait_min = models.IntegerField(default=5) + wait_max = models.IntegerField(default=30) + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.dummy.api import DummyPolicySerializer + + return DummyPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.dummy.forms import DummyPolicyForm + + return DummyPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + """Wait random time then return result""" + wait = SystemRandom().randrange(self.wait_min, self.wait_max) + LOGGER.debug("Policy waiting", policy=self, delay=wait) + sleep(wait) + return PolicyResult(self.result, "dummy") + + class Meta: + + verbose_name = _("Dummy Policy") + verbose_name_plural = _("Dummy Policies") diff --git a/authentik/policies/dummy/tests.py b/authentik/policies/dummy/tests.py new file mode 100644 index 00000000..8d0cefd5 --- /dev/null +++ b/authentik/policies/dummy/tests.py @@ -0,0 +1,39 @@ +"""dummy policy tests""" +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.policies.dummy.forms import DummyPolicyForm +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.engine import PolicyRequest + + +class TestDummyPolicy(TestCase): + """Test dummy policy""" + + def setUp(self): + super().setUp() + self.request = PolicyRequest(user=get_anonymous_user()) + + def test_policy(self): + """test policy .passes""" + policy: DummyPolicy = DummyPolicy.objects.create( + name="dummy", wait_min=1, wait_max=2 + ) + result = policy.passes(self.request) + self.assertFalse(result.passing) + self.assertEqual(result.messages, ("dummy",)) + + def test_form(self): + """test form""" + form = DummyPolicyForm( + data={ + "name": "dummy", + "negate": False, + "order": 0, + "timeout": 1, + "result": True, + "wait_min": 1, + "wait_max": 2, + } + ) + self.assertTrue(form.is_valid()) diff --git a/authentik/policies/engine.py b/authentik/policies/engine.py new file mode 100644 index 00000000..9e8dda5e --- /dev/null +++ b/authentik/policies/engine.py @@ -0,0 +1,135 @@ +"""authentik policy engine""" +from multiprocessing import Pipe, set_start_method +from multiprocessing.connection import Connection +from typing import Iterator, List, Optional + +from django.core.cache import cache +from django.http import HttpRequest +from sentry_sdk.hub import Hub +from sentry_sdk.tracing import Span +from structlog import get_logger + +from authentik.core.models import User +from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel +from authentik.policies.process import PolicyProcess, cache_key +from authentik.policies.types import PolicyRequest, PolicyResult + +LOGGER = get_logger() +# This is only really needed for macOS, because Python 3.8 changed the default to spawn +# spawn causes issues with objects that aren't picklable, and also the django setup +set_start_method("fork") + + +class PolicyProcessInfo: + """Dataclass to hold all information and communication channels to a process""" + + process: PolicyProcess + connection: Connection + result: Optional[PolicyResult] + binding: PolicyBinding + + def __init__( + self, process: PolicyProcess, connection: Connection, binding: PolicyBinding + ): + self.process = process + self.connection = connection + self.binding = binding + self.result = None + + +class PolicyEngine: + """Orchestrate policy checking, launch tasks and return result""" + + use_cache: bool + request: PolicyRequest + + __pbm: PolicyBindingModel + __cached_policies: List[PolicyResult] + __processes: List[PolicyProcessInfo] + + def __init__( + self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None + ): + if not isinstance(pbm, PolicyBindingModel): # pragma: no cover + raise ValueError(f"{pbm} is not instance of PolicyBindingModel") + self.__pbm = pbm + self.request = PolicyRequest(user) + if request: + self.request.http_request = request + self.__cached_policies = [] + self.__processes = [] + self.use_cache = True + + def _iter_bindings(self) -> Iterator[PolicyBinding]: + """Make sure all Policies are their respective classes""" + return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by( + "order" + ) + + def _check_policy_type(self, policy: Policy): + """Check policy type, make sure it's not the root class as that has no logic implemented""" + # pyright: reportGeneralTypeIssues=false + if policy.__class__ == Policy: + raise TypeError(f"Policy '{policy}' is root type") + + def build(self) -> "PolicyEngine": + """Build wrapper which monitors performance""" + with Hub.current.start_span(op="policy.engine.build") as span: + span: Span + span.set_data("pbm", self.__pbm) + span.set_data("request", self.request) + for binding in self._iter_bindings(): + self._check_policy_type(binding.policy) + key = cache_key(binding, self.request) + cached_policy = cache.get(key, None) + if cached_policy and self.use_cache: + LOGGER.debug( + "P_ENG: Taking result from cache", + policy=binding.policy, + cache_key=key, + ) + self.__cached_policies.append(cached_policy) + continue + LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy) + our_end, task_end = Pipe(False) + task = PolicyProcess(binding, self.request, task_end) + LOGGER.debug("P_ENG: Starting Process", policy=binding.policy) + task.start() + self.__processes.append( + PolicyProcessInfo(process=task, connection=our_end, binding=binding) + ) + # If all policies are cached, we have an empty list here. + for proc_info in self.__processes: + proc_info.process.join(proc_info.binding.timeout) + # Only call .recv() if no result is saved, otherwise we just deadlock here + if not proc_info.result: + proc_info.result = proc_info.connection.recv() + return self + + @property + def result(self) -> PolicyResult: + """Get policy-checking result""" + process_results: List[PolicyResult] = [ + x.result for x in self.__processes if x.result + ] + final_result = PolicyResult(False) + final_result.messages = [] + final_result.source_results = list(process_results + self.__cached_policies) + for result in process_results + self.__cached_policies: + LOGGER.debug( + "P_ENG: result", passing=result.passing, messages=result.messages + ) + if result.messages: + final_result.messages.extend(result.messages) + if not result.passing: + final_result.messages = tuple(final_result.messages) + final_result.passing = False + return final_result + final_result.messages = tuple(final_result.messages) + final_result.passing = True + return final_result + + @property + def passing(self) -> bool: + """Only get true/false if user passes""" + return self.result.passing diff --git a/authentik/policies/exceptions.py b/authentik/policies/exceptions.py new file mode 100644 index 00000000..994095ff --- /dev/null +++ b/authentik/policies/exceptions.py @@ -0,0 +1,6 @@ +"""policy exceptions""" +from authentik.lib.sentry import SentryIgnoredException + + +class PolicyException(SentryIgnoredException): + """Exception that should be raised during Policy Evaluation, and can be recovered from.""" diff --git a/passbook/policies/expiry/__init__.py b/authentik/policies/expiry/__init__.py similarity index 100% rename from passbook/policies/expiry/__init__.py rename to authentik/policies/expiry/__init__.py diff --git a/authentik/policies/expiry/api.py b/authentik/policies/expiry/api.py new file mode 100644 index 00000000..820ca7f6 --- /dev/null +++ b/authentik/policies/expiry/api.py @@ -0,0 +1,21 @@ +"""Password Expiry Policy API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.expiry.models import PasswordExpiryPolicy +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS + + +class PasswordExpiryPolicySerializer(ModelSerializer): + """Password Expiry Policy Serializer""" + + class Meta: + model = PasswordExpiryPolicy + fields = GENERAL_SERIALIZER_FIELDS + ["days", "deny_only"] + + +class PasswordExpiryPolicyViewSet(ModelViewSet): + """Password Expiry Viewset""" + + queryset = PasswordExpiryPolicy.objects.all() + serializer_class = PasswordExpiryPolicySerializer diff --git a/authentik/policies/expiry/apps.py b/authentik/policies/expiry/apps.py new file mode 100644 index 00000000..db29f9fc --- /dev/null +++ b/authentik/policies/expiry/apps.py @@ -0,0 +1,11 @@ +"""Authentik policy_expiry app config""" + +from django.apps import AppConfig + + +class AuthentikPolicyExpiryConfig(AppConfig): + """Authentik policy_expiry app config""" + + name = "authentik.policies.expiry" + label = "authentik_policies_expiry" + verbose_name = "authentik Policies.Expiry" diff --git a/authentik/policies/expiry/forms.py b/authentik/policies/expiry/forms.py new file mode 100644 index 00000000..223c00e7 --- /dev/null +++ b/authentik/policies/expiry/forms.py @@ -0,0 +1,22 @@ +"""authentik PasswordExpiry Policy forms""" + +from django import forms +from django.utils.translation import gettext as _ + +from authentik.policies.expiry.models import PasswordExpiryPolicy +from authentik.policies.forms import GENERAL_FIELDS + + +class PasswordExpiryPolicyForm(forms.ModelForm): + """Edit PasswordExpiryPolicy instances""" + + class Meta: + + model = PasswordExpiryPolicy + fields = GENERAL_FIELDS + ["days", "deny_only"] + widgets = { + "name": forms.TextInput(), + "order": forms.NumberInput(), + "days": forms.NumberInput(), + } + labels = {"deny_only": _("Only fail the policy, don't set user's password.")} diff --git a/authentik/policies/expiry/migrations/0001_initial.py b/authentik/policies/expiry/migrations/0001_initial.py new file mode 100644 index 00000000..401b6bfa --- /dev/null +++ b/authentik/policies/expiry/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PasswordExpiryPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.Policy", + ), + ), + ("deny_only", models.BooleanField(default=False)), + ("days", models.IntegerField()), + ], + options={ + "verbose_name": "Password Expiry Policy", + "verbose_name_plural": "Password Expiry Policies", + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/passbook/policies/expiry/migrations/__init__.py b/authentik/policies/expiry/migrations/__init__.py similarity index 100% rename from passbook/policies/expiry/migrations/__init__.py rename to authentik/policies/expiry/migrations/__init__.py diff --git a/authentik/policies/expiry/models.py b/authentik/policies/expiry/models.py new file mode 100644 index 00000000..d9c1c4d2 --- /dev/null +++ b/authentik/policies/expiry/models.py @@ -0,0 +1,62 @@ +"""authentik password_expiry_policy Models""" +from datetime import timedelta +from typing import Type + +from django.db import models +from django.forms import ModelForm +from django.utils.timezone import now +from django.utils.translation import gettext as _ +from rest_framework.serializers import BaseSerializer +from structlog import get_logger + +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult + +LOGGER = get_logger() + + +class PasswordExpiryPolicy(Policy): + """If password change date is more than x days in the past, invalidate the user's password + and show a notice""" + + deny_only = models.BooleanField(default=False) + days = models.IntegerField() + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.expiry.api import PasswordExpiryPolicySerializer + + return PasswordExpiryPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.expiry.forms import PasswordExpiryPolicyForm + + return PasswordExpiryPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + """If password change date is more than x days in the past, call set_unusable_password + and show a notice""" + actual_days = (now() - request.user.password_change_date).days + days_since_expiry = ( + now() - (request.user.password_change_date + timedelta(days=self.days)) + ).days + if actual_days >= self.days: + if not self.deny_only: + request.user.set_unusable_password() + request.user.save() + message = _( + ( + "Password expired %(days)d days ago. " + "Please update your password." + ) + % {"days": days_since_expiry} + ) + return PolicyResult(False, message) + return PolicyResult(False, _("Password has expired.")) + return PolicyResult(True) + + class Meta: + + verbose_name = _("Password Expiry Policy") + verbose_name_plural = _("Password Expiry Policies") diff --git a/passbook/policies/expression/__init__.py b/authentik/policies/expression/__init__.py similarity index 100% rename from passbook/policies/expression/__init__.py rename to authentik/policies/expression/__init__.py diff --git a/authentik/policies/expression/api.py b/authentik/policies/expression/api.py new file mode 100644 index 00000000..c02f945a --- /dev/null +++ b/authentik/policies/expression/api.py @@ -0,0 +1,21 @@ +"""Expression Policy API""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS + + +class ExpressionPolicySerializer(ModelSerializer): + """Group Membership Policy Serializer""" + + class Meta: + model = ExpressionPolicy + fields = GENERAL_SERIALIZER_FIELDS + ["expression"] + + +class ExpressionPolicyViewSet(ModelViewSet): + """Source Viewset""" + + queryset = ExpressionPolicy.objects.all() + serializer_class = ExpressionPolicySerializer diff --git a/authentik/policies/expression/apps.py b/authentik/policies/expression/apps.py new file mode 100644 index 00000000..de7df61f --- /dev/null +++ b/authentik/policies/expression/apps.py @@ -0,0 +1,11 @@ +"""Authentik policy_expression app config""" + +from django.apps import AppConfig + + +class AuthentikPolicyExpressionConfig(AppConfig): + """Authentik policy_expression app config""" + + name = "authentik.policies.expression" + label = "authentik_policies_expression" + verbose_name = "authentik Policies.Expression" diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py new file mode 100644 index 00000000..9dfba8c3 --- /dev/null +++ b/authentik/policies/expression/evaluator.py @@ -0,0 +1,72 @@ +"""authentik expression policy evaluator""" +from ipaddress import ip_address, ip_network +from typing import List + +from django.http import HttpRequest +from structlog import get_logger + +from authentik.flows.planner import PLAN_CONTEXT_SSO +from authentik.lib.expression.evaluator import BaseEvaluator +from authentik.lib.utils.http import get_client_ip +from authentik.policies.types import PolicyRequest, PolicyResult + +LOGGER = get_logger() + + +class PolicyEvaluator(BaseEvaluator): + """Validate and evaluate python-based expressions""" + + _messages: List[str] + + def __init__(self, policy_name: str): + super().__init__() + self._messages = [] + self._context["ak_message"] = self.expr_func_message + self._context["ip_address"] = ip_address + self._context["ip_network"] = ip_network + self._filename = policy_name or "PolicyEvaluator" + + def expr_func_message(self, message: str): + """Wrapper to append to messages list, which is returned with PolicyResult""" + self._messages.append(message) + + def set_policy_request(self, request: PolicyRequest): + """Update context based on policy request (if http request is given, update that too)""" + # update website/docs/policies/expression.md + self._context["ak_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False) + if request.http_request: + self.set_http_request(request.http_request) + self._context["request"] = request + self._context["context"] = request.context + + def set_http_request(self, request: HttpRequest): + """Update context based on http request""" + # update website/docs/policies/expression.md + self._context["ak_client_ip"] = ip_address( + get_client_ip(request) or "255.255.255.255" + ) + self._context["request"] = request + + def evaluate(self, expression_source: str) -> PolicyResult: + """Parse and evaluate expression. Policy is expected to return a truthy object. + Messages can be added using 'do ak_message()'.""" + try: + result = super().evaluate(expression_source) + except (ValueError, SyntaxError) as exc: + return PolicyResult(False, str(exc)) + except Exception as exc: # pylint: disable=broad-except + LOGGER.warning("Expression error", exc=exc) + return PolicyResult(False, str(exc)) + else: + policy_result = PolicyResult(False) + policy_result.messages = tuple(self._messages) + if result is None: + LOGGER.warning( + "Expression policy returned None", + src=expression_source, + req=self._context, + ) + policy_result.passing = False + if result: + policy_result.passing = bool(result) + return policy_result diff --git a/authentik/policies/expression/forms.py b/authentik/policies/expression/forms.py new file mode 100644 index 00000000..505c6ec3 --- /dev/null +++ b/authentik/policies/expression/forms.py @@ -0,0 +1,31 @@ +"""authentik Expression Policy forms""" + +from django import forms + +from authentik.admin.fields import CodeMirrorWidget +from authentik.policies.expression.evaluator import PolicyEvaluator +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.forms import GENERAL_FIELDS + + +class ExpressionPolicyForm(forms.ModelForm): + """ExpressionPolicy Form""" + + template_name = "policy/expression/form.html" + + def clean_expression(self): + """Test Syntax""" + expression = self.cleaned_data.get("expression") + PolicyEvaluator(self.instance.name).validate(expression) + return expression + + class Meta: + + model = ExpressionPolicy + fields = GENERAL_FIELDS + [ + "expression", + ] + widgets = { + "name": forms.TextInput(), + "expression": CodeMirrorWidget(mode="python"), + } diff --git a/authentik/policies/expression/migrations/0001_initial.py b/authentik/policies/expression/migrations/0001_initial.py new file mode 100644 index 00000000..2087532a --- /dev/null +++ b/authentik/policies/expression/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="ExpressionPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.Policy", + ), + ), + ("expression", models.TextField()), + ], + options={ + "verbose_name": "Expression Policy", + "verbose_name_plural": "Expression Policies", + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/authentik/policies/expression/migrations/0002_auto_20200926_1156.py b/authentik/policies/expression/migrations/0002_auto_20200926_1156.py new file mode 100644 index 00000000..0a9f1ddc --- /dev/null +++ b/authentik/policies/expression/migrations/0002_auto_20200926_1156.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.1 on 2020-09-26 11:56 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def remove_pb_flow_plan(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + ExpressionPolicy = apps.get_model( + "authentik_policies_expression", "ExpressionPolicy" + ) + + db_alias = schema_editor.connection.alias + + for policy in ExpressionPolicy.objects.using(db_alias).all(): + policy.expression = policy.expression.replace("pb_flow_plan.", "context.") + policy.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_expression", "0001_initial"), + ] + + operations = [ + migrations.RunPython(remove_pb_flow_plan), + ] diff --git a/authentik/policies/expression/migrations/0003_auto_20201203_1223.py b/authentik/policies/expression/migrations/0003_auto_20201203_1223.py new file mode 100644 index 00000000..f0a0c408 --- /dev/null +++ b/authentik/policies/expression/migrations/0003_auto_20201203_1223.py @@ -0,0 +1,32 @@ +# Generated by Django 3.1.3 on 2020-12-03 12:23 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def replace_pb_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + ExpressionPolicy = apps.get_model( + "authentik_policies_expression", "ExpressionPolicy" + ) + + db_alias = schema_editor.connection.alias + + for policy in ExpressionPolicy.objects.using(db_alias).all(): + # Because the previous migration had a broken replace, we have to replace here again + policy.expression = policy.expression.replace("pb_flow_plan.", "context.") + policy.expression = policy.expression.replace( + "pb_is_sso_flow", "ak_is_sso_flow" + ) + policy.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_expression", "0002_auto_20200926_1156"), + ] + + operations = [ + migrations.RunPython(replace_pb_prefix), + ] diff --git a/passbook/policies/expression/migrations/__init__.py b/authentik/policies/expression/migrations/__init__.py similarity index 100% rename from passbook/policies/expression/migrations/__init__.py rename to authentik/policies/expression/migrations/__init__.py diff --git a/authentik/policies/expression/models.py b/authentik/policies/expression/models.py new file mode 100644 index 00000000..66e92eb1 --- /dev/null +++ b/authentik/policies/expression/models.py @@ -0,0 +1,44 @@ +"""authentik expression Policy Models""" +from typing import Type + +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from rest_framework.serializers import BaseSerializer + +from authentik.policies.expression.evaluator import PolicyEvaluator +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult + + +class ExpressionPolicy(Policy): + """Execute arbitrary Python code to implement custom checks and validation.""" + + expression = models.TextField() + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.expression.api import ExpressionPolicySerializer + + return ExpressionPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.expression.forms import ExpressionPolicyForm + + return ExpressionPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + """Evaluate and render expression. Returns PolicyResult(false) on error.""" + evaluator = PolicyEvaluator(self.name) + evaluator.set_policy_request(request) + return evaluator.evaluate(self.expression) + + def save(self, *args, **kwargs): + PolicyEvaluator(self.name).validate(self.expression) + return super().save(*args, **kwargs) + + class Meta: + + verbose_name = _("Expression Policy") + verbose_name_plural = _("Expression Policies") diff --git a/authentik/policies/expression/templates/policy/expression/form.html b/authentik/policies/expression/templates/policy/expression/form.html new file mode 100644 index 00000000..c9da91d5 --- /dev/null +++ b/authentik/policies/expression/templates/policy/expression/form.html @@ -0,0 +1,14 @@ +{% extends "generic/form.html" %} + +{% load i18n %} + +{% block beneath_form %} +
+ +
+

+ Expression using Python. See here for a list of all variables. +

+
+
+{% endblock %} diff --git a/authentik/policies/expression/tests.py b/authentik/policies/expression/tests.py new file mode 100644 index 00000000..8cd51bcc --- /dev/null +++ b/authentik/policies/expression/tests.py @@ -0,0 +1,62 @@ +"""evaluator tests""" +from django.core.exceptions import ValidationError +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.policies.expression.evaluator import PolicyEvaluator +from authentik.policies.types import PolicyRequest + + +class TestEvaluator(TestCase): + """Evaluator tests""" + + def setUp(self): + self.request = PolicyRequest(user=get_anonymous_user()) + + def test_valid(self): + """test simple value expression""" + template = "return True" + evaluator = PolicyEvaluator("test") + evaluator.set_policy_request(self.request) + self.assertEqual(evaluator.evaluate(template).passing, True) + + def test_messages(self): + """test expression with message return""" + template = 'ak_message("some message");return False' + evaluator = PolicyEvaluator("test") + evaluator.set_policy_request(self.request) + result = evaluator.evaluate(template) + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("some message",)) + + def test_invalid_syntax(self): + """test invalid syntax""" + template = ";" + evaluator = PolicyEvaluator("test") + evaluator.set_policy_request(self.request) + result = evaluator.evaluate(template) + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("invalid syntax (test, line 3)",)) + + def test_undefined(self): + """test undefined result""" + template = "{{ foo.bar }}" + evaluator = PolicyEvaluator("test") + evaluator.set_policy_request(self.request) + result = evaluator.evaluate(template) + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("name 'foo' is not defined",)) + + def test_validate(self): + """test validate""" + template = "True" + evaluator = PolicyEvaluator("test") + result = evaluator.validate(template) + self.assertEqual(result, True) + + def test_validate_invalid(self): + """test validate""" + template = ";" + evaluator = PolicyEvaluator("test") + with self.assertRaises(ValidationError): + evaluator.validate(template) diff --git a/authentik/policies/forms.py b/authentik/policies/forms.py new file mode 100644 index 00000000..78a5e0da --- /dev/null +++ b/authentik/policies/forms.py @@ -0,0 +1,26 @@ +"""General fields""" + +from django import forms + +from authentik.lib.widgets import GroupedModelChoiceField +from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel + +GENERAL_FIELDS = ["name"] +GENERAL_SERIALIZER_FIELDS = ["pk", "name"] + + +class PolicyBindingForm(forms.ModelForm): + """Form to edit Policy to PolicyBindingModel Binding""" + + target = GroupedModelChoiceField( + queryset=PolicyBindingModel.objects.all().select_subclasses(), + to_field_name="pbm_uuid", + ) + policy = GroupedModelChoiceField( + queryset=Policy.objects.all().select_subclasses(), + ) + + class Meta: + + model = PolicyBinding + fields = ["enabled", "policy", "target", "order", "timeout"] diff --git a/passbook/policies/group_membership/__init__.py b/authentik/policies/group_membership/__init__.py similarity index 100% rename from passbook/policies/group_membership/__init__.py rename to authentik/policies/group_membership/__init__.py diff --git a/authentik/policies/group_membership/api.py b/authentik/policies/group_membership/api.py new file mode 100644 index 00000000..6aa5e298 --- /dev/null +++ b/authentik/policies/group_membership/api.py @@ -0,0 +1,23 @@ +"""Group Membership Policy API""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS +from authentik.policies.group_membership.models import GroupMembershipPolicy + + +class GroupMembershipPolicySerializer(ModelSerializer): + """Group Membership Policy Serializer""" + + class Meta: + model = GroupMembershipPolicy + fields = GENERAL_SERIALIZER_FIELDS + [ + "group", + ] + + +class GroupMembershipPolicyViewSet(ModelViewSet): + """Group Membership Policy Viewset""" + + queryset = GroupMembershipPolicy.objects.all() + serializer_class = GroupMembershipPolicySerializer diff --git a/authentik/policies/group_membership/apps.py b/authentik/policies/group_membership/apps.py new file mode 100644 index 00000000..fa3acbff --- /dev/null +++ b/authentik/policies/group_membership/apps.py @@ -0,0 +1,11 @@ +"""authentik Group Membership policy app config""" + +from django.apps import AppConfig + + +class AuthentikPoliciesGroupMembershipConfig(AppConfig): + """authentik Group Membership policy app config""" + + name = "authentik.policies.group_membership" + label = "authentik_policies_group_membership" + verbose_name = "authentik Policies.Group Membership" diff --git a/authentik/policies/group_membership/forms.py b/authentik/policies/group_membership/forms.py new file mode 100644 index 00000000..250f74ca --- /dev/null +++ b/authentik/policies/group_membership/forms.py @@ -0,0 +1,20 @@ +"""authentik Group Membership Policy forms""" + +from django import forms + +from authentik.policies.forms import GENERAL_FIELDS +from authentik.policies.group_membership.models import GroupMembershipPolicy + + +class GroupMembershipPolicyForm(forms.ModelForm): + """GroupMembershipPolicy Form""" + + class Meta: + + model = GroupMembershipPolicy + fields = GENERAL_FIELDS + [ + "group", + ] + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/policies/group_membership/migrations/0001_initial.py b/authentik/policies/group_membership/migrations/0001_initial.py new file mode 100644 index 00000000..1b48a500 --- /dev/null +++ b/authentik/policies/group_membership/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 3.0.7 on 2020-07-01 19:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0002_auto_20200528_1647"), + ("authentik_core", "0003_default_user"), + ] + + operations = [ + migrations.CreateModel( + name="GroupMembershipPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.Policy", + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_core.Group", + ), + ), + ], + options={ + "verbose_name": "Group Membership Policy", + "verbose_name_plural": "Group Membership Policies", + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/passbook/policies/group_membership/migrations/__init__.py b/authentik/policies/group_membership/migrations/__init__.py similarity index 100% rename from passbook/policies/group_membership/migrations/__init__.py rename to authentik/policies/group_membership/migrations/__init__.py diff --git a/authentik/policies/group_membership/models.py b/authentik/policies/group_membership/models.py new file mode 100644 index 00000000..c53b3550 --- /dev/null +++ b/authentik/policies/group_membership/models.py @@ -0,0 +1,39 @@ +"""user field matcher models""" +from typing import Type + +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from rest_framework.serializers import BaseSerializer + +from authentik.core.models import Group +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult + + +class GroupMembershipPolicy(Policy): + """Check that the user is member of the selected group.""" + + group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL) + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.group_membership.api import ( + GroupMembershipPolicySerializer, + ) + + return GroupMembershipPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.group_membership.forms import GroupMembershipPolicyForm + + return GroupMembershipPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + return PolicyResult(self.group.users.filter(pk=request.user.pk).exists()) + + class Meta: + + verbose_name = _("Group Membership Policy") + verbose_name_plural = _("Group Membership Policies") diff --git a/authentik/policies/group_membership/tests.py b/authentik/policies/group_membership/tests.py new file mode 100644 index 00000000..5c53b395 --- /dev/null +++ b/authentik/policies/group_membership/tests.py @@ -0,0 +1,32 @@ +"""evaluator tests""" +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.core.models import Group +from authentik.policies.group_membership.models import GroupMembershipPolicy +from authentik.policies.types import PolicyRequest + + +class TestGroupMembershipPolicy(TestCase): + """GroupMembershipPolicy tests""" + + def setUp(self): + self.request = PolicyRequest(user=get_anonymous_user()) + + def test_invalid(self): + """user not in group""" + group = Group.objects.create(name="test") + policy: GroupMembershipPolicy = GroupMembershipPolicy.objects.create( + group=group + ) + self.assertFalse(policy.passes(self.request).passing) + + def test_valid(self): + """user in group""" + group = Group.objects.create(name="test") + group.users.add(get_anonymous_user()) + group.save() + policy: GroupMembershipPolicy = GroupMembershipPolicy.objects.create( + group=group + ) + self.assertTrue(policy.passes(self.request).passing) diff --git a/passbook/policies/hibp/__init__.py b/authentik/policies/hibp/__init__.py similarity index 100% rename from passbook/policies/hibp/__init__.py rename to authentik/policies/hibp/__init__.py diff --git a/authentik/policies/hibp/api.py b/authentik/policies/hibp/api.py new file mode 100644 index 00000000..ab0ff1ff --- /dev/null +++ b/authentik/policies/hibp/api.py @@ -0,0 +1,21 @@ +"""Source API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS +from authentik.policies.hibp.models import HaveIBeenPwendPolicy + + +class HaveIBeenPwendPolicySerializer(ModelSerializer): + """Have I Been Pwned Policy Serializer""" + + class Meta: + model = HaveIBeenPwendPolicy + fields = GENERAL_SERIALIZER_FIELDS + ["password_field", "allowed_count"] + + +class HaveIBeenPwendPolicyViewSet(ModelViewSet): + """Source Viewset""" + + queryset = HaveIBeenPwendPolicy.objects.all() + serializer_class = HaveIBeenPwendPolicySerializer diff --git a/authentik/policies/hibp/apps.py b/authentik/policies/hibp/apps.py new file mode 100644 index 00000000..75fe9a27 --- /dev/null +++ b/authentik/policies/hibp/apps.py @@ -0,0 +1,11 @@ +"""Authentik hibp app config""" + +from django.apps import AppConfig + + +class AuthentikPolicyHIBPConfig(AppConfig): + """Authentik hibp app config""" + + name = "authentik.policies.hibp" + label = "authentik_policies_hibp" + verbose_name = "authentik Policies.HaveIBeenPwned" diff --git a/authentik/policies/hibp/forms.py b/authentik/policies/hibp/forms.py new file mode 100644 index 00000000..e389847f --- /dev/null +++ b/authentik/policies/hibp/forms.py @@ -0,0 +1,19 @@ +"""authentik HaveIBeenPwned Policy forms""" + +from django import forms + +from authentik.policies.forms import GENERAL_FIELDS +from authentik.policies.hibp.models import HaveIBeenPwendPolicy + + +class HaveIBeenPwnedPolicyForm(forms.ModelForm): + """Edit HaveIBeenPwendPolicy instances""" + + class Meta: + + model = HaveIBeenPwendPolicy + fields = GENERAL_FIELDS + ["password_field", "allowed_count"] + widgets = { + "name": forms.TextInput(), + "password_field": forms.TextInput(), + } diff --git a/authentik/policies/hibp/migrations/0001_initial.py b/authentik/policies/hibp/migrations/0001_initial.py new file mode 100644 index 00000000..3ffaa410 --- /dev/null +++ b/authentik/policies/hibp/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="HaveIBeenPwendPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.Policy", + ), + ), + ("allowed_count", models.IntegerField(default=0)), + ], + options={ + "verbose_name": "Have I Been Pwned Policy", + "verbose_name_plural": "Have I Been Pwned Policies", + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/authentik/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py b/authentik/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py new file mode 100644 index 00000000..a0505421 --- /dev/null +++ b/authentik/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.8 on 2020-07-10 18:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_hibp", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="haveibeenpwendpolicy", + name="password_field", + field=models.TextField( + default="password", + help_text="Field key to check, field keys defined in Prompt stages are available.", + ), + ), + ] diff --git a/passbook/policies/hibp/migrations/__init__.py b/authentik/policies/hibp/migrations/__init__.py similarity index 100% rename from passbook/policies/hibp/migrations/__init__.py rename to authentik/policies/hibp/migrations/__init__.py diff --git a/authentik/policies/hibp/models.py b/authentik/policies/hibp/models.py new file mode 100644 index 00000000..91625262 --- /dev/null +++ b/authentik/policies/hibp/models.py @@ -0,0 +1,74 @@ +"""authentik HIBP Models""" +from hashlib import sha1 +from typing import Type + +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from requests import get +from rest_framework.serializers import BaseSerializer +from structlog import get_logger + +from authentik.policies.models import Policy, PolicyResult +from authentik.policies.types import PolicyRequest + +LOGGER = get_logger() + + +class HaveIBeenPwendPolicy(Policy): + """Check if password is on HaveIBeenPwned's list by uploading the first + 5 characters of the SHA1 Hash.""" + + password_field = models.TextField( + default="password", + help_text=_( + "Field key to check, field keys defined in Prompt stages are available." + ), + ) + + allowed_count = models.IntegerField(default=0) + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.hibp.api import HaveIBeenPwendPolicySerializer + + return HaveIBeenPwendPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.hibp.forms import HaveIBeenPwnedPolicyForm + + return HaveIBeenPwnedPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + """Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5 + characters of Password in request and checks if full hash is in response. Returns 0 + if Password is not in result otherwise the count of how many times it was used.""" + if self.password_field not in request.context: + LOGGER.warning( + "Password field not set in Policy Request", + field=self.password_field, + fields=request.context.keys(), + ) + password = request.context[self.password_field] + + pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec + url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}" + result = get(url).text + final_count = 0 + for line in result.split("\r\n"): + full_hash, count = line.split(":") + if pw_hash[5:] == full_hash.lower(): + final_count = int(count) + LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) + if final_count > self.allowed_count: + message = _( + "Password exists on %(count)d online lists." % {"count": final_count} + ) + return PolicyResult(False, message) + return PolicyResult(True) + + class Meta: + + verbose_name = _("Have I Been Pwned Policy") + verbose_name_plural = _("Have I Been Pwned Policies") diff --git a/authentik/policies/hibp/tests.py b/authentik/policies/hibp/tests.py new file mode 100644 index 00000000..f7499471 --- /dev/null +++ b/authentik/policies/hibp/tests.py @@ -0,0 +1,33 @@ +"""HIBP Policy tests""" +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.policies.hibp.models import HaveIBeenPwendPolicy +from authentik.policies.types import PolicyRequest, PolicyResult +from authentik.providers.oauth2.generators import generate_client_secret + + +class TestHIBPPolicy(TestCase): + """Test HIBP Policy""" + + def test_false(self): + """Failing password case""" + policy = HaveIBeenPwendPolicy.objects.create( + name="test_false", + ) + request = PolicyRequest(get_anonymous_user()) + request.context["password"] = "password" + result: PolicyResult = policy.passes(request) + self.assertFalse(result.passing) + self.assertTrue(result.messages[0].startswith("Password exists on ")) + + def test_true(self): + """Positive password case""" + policy = HaveIBeenPwendPolicy.objects.create( + name="test_true", + ) + request = PolicyRequest(get_anonymous_user()) + request.context["password"] = generate_client_secret() + result: PolicyResult = policy.passes(request) + self.assertTrue(result.passing) + self.assertEqual(result.messages, tuple()) diff --git a/authentik/policies/http.py b/authentik/policies/http.py new file mode 100644 index 00000000..72a0b5f8 --- /dev/null +++ b/authentik/policies/http.py @@ -0,0 +1,43 @@ +"""policy http response""" +from typing import Any, Dict, Optional + +from django.http.request import HttpRequest +from django.template.response import TemplateResponse +from django.utils.translation import gettext as _ + +from authentik.core.models import USER_ATTRIBUTE_DEBUG +from authentik.policies.types import PolicyResult + + +class AccessDeniedResponse(TemplateResponse): + """Response used for access denied messages. Can optionally show an error message, + and if the user is a superuser or has user_debug enabled, shows a policy result.""" + + title: str + + error_message: Optional[str] = None + policy_result: Optional[PolicyResult] = None + + # pyright: reportGeneralTypeIssues=false + def __init__(self, request: HttpRequest, template="policies/denied.html") -> None: + super().__init__(request, template) + self.title = _("Access denied") + + def resolve_context( + self, context: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + if not context: + context = {} + context["title"] = self.title + if self.error_message: + context["error"] = self.error_message + # Only show policy result if user is authenticated and + # either superuser or has USER_ATTRIBUTE_DEBUG set + if self.policy_result: + if self._request.user and self._request.user.is_authenticated: + if ( + self._request.user.is_superuser + or self._request.user.attributes.get(USER_ATTRIBUTE_DEBUG, False) + ): + context["policy_result"] = self.policy_result + return context diff --git a/authentik/policies/migrations/0001_initial.py b/authentik/policies/migrations/0001_initial.py new file mode 100644 index 00000000..5b2d0ff4 --- /dev/null +++ b/authentik/policies/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:07 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Policy", + fields=[ + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "policy_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField(blank=True, null=True)), + ("negate", models.BooleanField(default=False)), + ("order", models.IntegerField(default=0)), + ("timeout", models.IntegerField(default=30)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="PolicyBinding", + fields=[ + ( + "policy_binding_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("enabled", models.BooleanField(default=True)), + ("order", models.IntegerField(default=0)), + ( + "policy", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="authentik_policies.Policy", + ), + ), + ], + options={ + "verbose_name": "Policy Binding", + "verbose_name_plural": "Policy Bindings", + }, + ), + migrations.CreateModel( + name="PolicyBindingModel", + fields=[ + ( + "pbm_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "policies", + models.ManyToManyField( + blank=True, + related_name="bindings", + through="authentik_policies.PolicyBinding", + to="authentik_policies.Policy", + ), + ), + ], + options={ + "verbose_name": "Policy Binding Model", + "verbose_name_plural": "Policy Binding Models", + }, + ), + migrations.AddField( + model_name="policybinding", + name="target", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="authentik_policies.PolicyBindingModel", + ), + ), + ] diff --git a/authentik/policies/migrations/0002_auto_20200528_1647.py b/authentik/policies/migrations/0002_auto_20200528_1647.py new file mode 100644 index 00000000..3c0d636a --- /dev/null +++ b/authentik/policies/migrations/0002_auto_20200528_1647.py @@ -0,0 +1,70 @@ +# Generated by Django 3.0.6 on 2020-05-28 16:47 + +import django.db.models.deletion +from django.db import migrations, models + +import authentik.lib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="policy", + options={ + "base_manager_name": "objects", + "verbose_name": "Policy", + "verbose_name_plural": "Policies", + }, + ), + migrations.RemoveField( + model_name="policy", + name="negate", + ), + migrations.RemoveField( + model_name="policy", + name="order", + ), + migrations.RemoveField( + model_name="policy", + name="timeout", + ), + migrations.AddField( + model_name="policybinding", + name="negate", + field=models.BooleanField( + default=False, + help_text="Negates the outcome of the policy. Messages are unaffected.", + ), + ), + migrations.AddField( + model_name="policybinding", + name="timeout", + field=models.IntegerField( + default=30, + help_text="Timeout after which Policy execution is terminated.", + ), + ), + migrations.AlterField( + model_name="policybinding", + name="order", + field=models.IntegerField(), + ), + migrations.AlterField( + model_name="policybinding", + name="policy", + field=authentik.lib.models.InheritanceForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="authentik_policies.Policy", + ), + ), + migrations.AlterUniqueTogether( + name="policybinding", + unique_together={("policy", "target", "order")}, + ), + ] diff --git a/authentik/policies/migrations/0003_auto_20200908_1542.py b/authentik/policies/migrations/0003_auto_20200908_1542.py new file mode 100644 index 00000000..ed808ed7 --- /dev/null +++ b/authentik/policies/migrations/0003_auto_20200908_1542.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.1 on 2020-09-08 15:42 + +import django.db.models.deletion +from django.db import migrations + +import authentik.lib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies", "0002_auto_20200528_1647"), + ] + + operations = [ + migrations.AlterField( + model_name="policybinding", + name="target", + field=authentik.lib.models.InheritanceForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="authentik_policies.policybindingmodel", + ), + ), + ] diff --git a/passbook/policies/migrations/__init__.py b/authentik/policies/migrations/__init__.py similarity index 100% rename from passbook/policies/migrations/__init__.py rename to authentik/policies/migrations/__init__.py diff --git a/authentik/policies/models.py b/authentik/policies/models.py new file mode 100644 index 00000000..3cd8a2bb --- /dev/null +++ b/authentik/policies/models.py @@ -0,0 +1,102 @@ +"""Policy base models""" +from typing import Type +from uuid import uuid4 + +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ +from model_utils.managers import InheritanceManager +from rest_framework.serializers import BaseSerializer + +from authentik.lib.models import ( + CreatedUpdatedModel, + InheritanceAutoManager, + InheritanceForeignKey, + SerializerModel, +) +from authentik.policies.exceptions import PolicyException +from authentik.policies.types import PolicyRequest, PolicyResult + + +class PolicyBindingModel(models.Model): + """Base Model for objects that have policies applied to them.""" + + pbm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + policies = models.ManyToManyField( + "Policy", through="PolicyBinding", related_name="bindings", blank=True + ) + + objects = InheritanceManager() + + class Meta: + verbose_name = _("Policy Binding Model") + verbose_name_plural = _("Policy Binding Models") + + +class PolicyBinding(SerializerModel): + """Relationship between a Policy and a PolicyBindingModel.""" + + policy_binding_uuid = models.UUIDField( + primary_key=True, editable=False, default=uuid4 + ) + + enabled = models.BooleanField(default=True) + + policy = InheritanceForeignKey("Policy", on_delete=models.CASCADE, related_name="+") + target = InheritanceForeignKey( + PolicyBindingModel, on_delete=models.CASCADE, related_name="+" + ) + negate = models.BooleanField( + default=False, + help_text=_("Negates the outcome of the policy. Messages are unaffected."), + ) + timeout = models.IntegerField( + default=30, help_text=_("Timeout after which Policy execution is terminated.") + ) + + order = models.IntegerField() + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.api import PolicyBindingSerializer + + return PolicyBindingSerializer + + def __str__(self) -> str: + return f"Policy Binding {self.target} #{self.order} {self.policy}" + + class Meta: + + verbose_name = _("Policy Binding") + verbose_name_plural = _("Policy Bindings") + unique_together = ("policy", "target", "order") + + +class Policy(SerializerModel, CreatedUpdatedModel): + """Policies which specify if a user is authorized to use an Application. Can be overridden by + other types to add other fields, more logic, etc.""" + + policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + name = models.TextField(blank=True, null=True) + + objects = InheritanceAutoManager() + + @property + def form(self) -> Type[ModelForm]: + """Return Form class used to edit this object""" + raise NotImplementedError + + def __str__(self): + return f"{self.__class__.__name__} {self.name}" + + def passes(self, request: PolicyRequest) -> PolicyResult: # pragma: no cover + """Check if user instance passes this policy""" + raise PolicyException() + + class Meta: + base_manager_name = "objects" + + verbose_name = _("Policy") + verbose_name_plural = _("Policies") diff --git a/passbook/policies/password/__init__.py b/authentik/policies/password/__init__.py similarity index 100% rename from passbook/policies/password/__init__.py rename to authentik/policies/password/__init__.py diff --git a/authentik/policies/password/api.py b/authentik/policies/password/api.py new file mode 100644 index 00000000..5dae9b47 --- /dev/null +++ b/authentik/policies/password/api.py @@ -0,0 +1,29 @@ +"""Password Policy API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS +from authentik.policies.password.models import PasswordPolicy + + +class PasswordPolicySerializer(ModelSerializer): + """Password Policy Serializer""" + + class Meta: + model = PasswordPolicy + fields = GENERAL_SERIALIZER_FIELDS + [ + "password_field", + "amount_uppercase", + "amount_lowercase", + "amount_symbols", + "length_min", + "symbol_charset", + "error_message", + ] + + +class PasswordPolicyViewSet(ModelViewSet): + """Password Policy Viewset""" + + queryset = PasswordPolicy.objects.all() + serializer_class = PasswordPolicySerializer diff --git a/authentik/policies/password/apps.py b/authentik/policies/password/apps.py new file mode 100644 index 00000000..7125647d --- /dev/null +++ b/authentik/policies/password/apps.py @@ -0,0 +1,11 @@ +"""authentik Password policy app config""" + +from django.apps import AppConfig + + +class AuthentikPoliciesPasswordConfig(AppConfig): + """authentik Password policy app config""" + + name = "authentik.policies.password" + label = "authentik_policies_password" + verbose_name = "authentik Policies.Password" diff --git a/authentik/policies/password/forms.py b/authentik/policies/password/forms.py new file mode 100644 index 00000000..2707da69 --- /dev/null +++ b/authentik/policies/password/forms.py @@ -0,0 +1,36 @@ +"""authentik Policy forms""" + +from django import forms +from django.utils.translation import gettext as _ + +from authentik.policies.forms import GENERAL_FIELDS +from authentik.policies.password.models import PasswordPolicy + + +class PasswordPolicyForm(forms.ModelForm): + """PasswordPolicy Form""" + + class Meta: + + model = PasswordPolicy + fields = GENERAL_FIELDS + [ + "password_field", + "amount_uppercase", + "amount_lowercase", + "amount_symbols", + "length_min", + "symbol_charset", + "error_message", + ] + widgets = { + "name": forms.TextInput(), + "password_field": forms.TextInput(), + "symbol_charset": forms.TextInput(), + "error_message": forms.TextInput(), + } + labels = { + "amount_uppercase": _("Minimum amount of Uppercase Characters"), + "amount_lowercase": _("Minimum amount of Lowercase Characters"), + "amount_symbols": _("Minimum amount of Symbols Characters"), + "length_min": _("Minimum Length"), + } diff --git a/authentik/policies/password/migrations/0001_initial.py b/authentik/policies/password/migrations/0001_initial.py new file mode 100644 index 00000000..4352a661 --- /dev/null +++ b/authentik/policies/password/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PasswordPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.Policy", + ), + ), + ("amount_uppercase", models.IntegerField(default=0)), + ("amount_lowercase", models.IntegerField(default=0)), + ("amount_symbols", models.IntegerField(default=0)), + ("length_min", models.IntegerField(default=0)), + ( + "symbol_charset", + models.TextField(default="!\\\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ "), + ), + ("error_message", models.TextField()), + ], + options={ + "verbose_name": "Password Policy", + "verbose_name_plural": "Password Policies", + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/authentik/policies/password/migrations/0002_passwordpolicy_password_field.py b/authentik/policies/password/migrations/0002_passwordpolicy_password_field.py new file mode 100644 index 00000000..b0f16010 --- /dev/null +++ b/authentik/policies/password/migrations/0002_passwordpolicy_password_field.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.8 on 2020-07-10 18:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_password", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="passwordpolicy", + name="password_field", + field=models.TextField( + default="password", + help_text="Field key to check, field keys defined in Prompt stages are available.", + ), + ), + ] diff --git a/passbook/policies/password/migrations/__init__.py b/authentik/policies/password/migrations/__init__.py similarity index 100% rename from passbook/policies/password/migrations/__init__.py rename to authentik/policies/password/migrations/__init__.py diff --git a/authentik/policies/password/models.py b/authentik/policies/password/models.py new file mode 100644 index 00000000..30cac3c9 --- /dev/null +++ b/authentik/policies/password/models.py @@ -0,0 +1,77 @@ +"""user field matcher models""" +import re +from typing import Type + +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from rest_framework.serializers import BaseSerializer +from structlog import get_logger + +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult + +LOGGER = get_logger() + + +class PasswordPolicy(Policy): + """Policy to make sure passwords have certain properties""" + + password_field = models.TextField( + default="password", + help_text=_( + "Field key to check, field keys defined in Prompt stages are available." + ), + ) + + amount_uppercase = models.IntegerField(default=0) + amount_lowercase = models.IntegerField(default=0) + amount_symbols = models.IntegerField(default=0) + length_min = models.IntegerField(default=0) + symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ") + error_message = models.TextField() + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.password.api import PasswordPolicySerializer + + return PasswordPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.password.forms import PasswordPolicyForm + + return PasswordPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + if self.password_field not in request.context: + LOGGER.warning( + "Password field not set in Policy Request", + field=self.password_field, + fields=request.context.keys(), + ) + password = request.context[self.password_field] + + filter_regex = [] + if self.amount_lowercase > 0: + filter_regex.append(r"[a-z]{%d,}" % self.amount_lowercase) + if self.amount_uppercase > 0: + filter_regex.append(r"[A-Z]{%d,}" % self.amount_uppercase) + if self.amount_symbols > 0: + filter_regex.append( + r"[%s]{%d,}" % (self.symbol_charset, self.amount_symbols) + ) + full_regex = "|".join(filter_regex) + LOGGER.debug("Built regex", regexp=full_regex) + result = bool(re.compile(full_regex).match(password)) + + result = result and len(password) >= self.length_min + + if not result: + return PolicyResult(result, self.error_message) + return PolicyResult(result) + + class Meta: + + verbose_name = _("Password Policy") + verbose_name_plural = _("Password Policies") diff --git a/authentik/policies/password/tests.py b/authentik/policies/password/tests.py new file mode 100644 index 00000000..c1617528 --- /dev/null +++ b/authentik/policies/password/tests.py @@ -0,0 +1,42 @@ +"""Password Policy tests""" +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.policies.password.models import PasswordPolicy +from authentik.policies.types import PolicyRequest, PolicyResult + + +class TestPasswordPolicy(TestCase): + """Test Password Policy""" + + def test_false(self): + """Failing password case""" + policy = PasswordPolicy.objects.create( + name="test_false", + amount_uppercase=1, + amount_lowercase=2, + amount_symbols=3, + length_min=24, + error_message="test message", + ) + request = PolicyRequest(get_anonymous_user()) + request.context["password"] = "test" + result: PolicyResult = policy.passes(request) + self.assertFalse(result.passing) + self.assertEqual(result.messages, ("test message",)) + + def test_true(self): + """Positive password case""" + policy = PasswordPolicy.objects.create( + name="test_true", + amount_uppercase=1, + amount_lowercase=2, + amount_symbols=3, + length_min=3, + error_message="test message", + ) + request = PolicyRequest(get_anonymous_user()) + request.context["password"] = "Test()!" + result: PolicyResult = policy.passes(request) + self.assertTrue(result.passing) + self.assertEqual(result.messages, tuple()) diff --git a/authentik/policies/process.py b/authentik/policies/process.py new file mode 100644 index 00000000..0737ef43 --- /dev/null +++ b/authentik/policies/process.py @@ -0,0 +1,87 @@ +"""authentik policy task""" +from multiprocessing import Process +from multiprocessing.connection import Connection +from typing import Optional + +from django.core.cache import cache +from sentry_sdk.hub import Hub +from sentry_sdk.tracing import Span +from structlog import get_logger + +from authentik.policies.exceptions import PolicyException +from authentik.policies.models import PolicyBinding +from authentik.policies.types import PolicyRequest, PolicyResult + +LOGGER = get_logger() + + +def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: + """Generate Cache key for policy""" + prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}" + if request.http_request: + prefix += f"_{request.http_request.session.session_key}" + if request.user: + prefix += f"#{request.user.pk}" + return prefix + + +class PolicyProcess(Process): + """Evaluate a single policy within a seprate process""" + + connection: Connection + binding: PolicyBinding + request: PolicyRequest + + def __init__( + self, + binding: PolicyBinding, + request: PolicyRequest, + connection: Optional[Connection], + ): + super().__init__() + self.binding = binding + self.request = request + if not isinstance(self.request, PolicyRequest): + raise ValueError(f"{self.request} is not a Policy Request.") + if connection: + self.connection = connection + + def execute(self) -> PolicyResult: + """Run actual policy, returns result""" + with Hub.current.start_span( + op="policy.process.execute", + ) as span: + span: Span + span.set_data("policy", self.binding.policy) + span.set_data("request", self.request) + LOGGER.debug( + "P_ENG(proc): Running policy", + policy=self.binding.policy, + user=self.request.user, + process="PolicyProcess", + ) + try: + policy_result = self.binding.policy.passes(self.request) + except PolicyException as exc: + LOGGER.debug("P_ENG(proc): error", exc=exc) + policy_result = PolicyResult(False, str(exc)) + policy_result.source_policy = self.binding.policy + # Invert result if policy.negate is set + if self.binding.negate: + policy_result.passing = not policy_result.passing + LOGGER.debug( + "P_ENG(proc): Finished", + policy=self.binding.policy, + result=policy_result, + process="PolicyProcess", + passing=policy_result.passing, + user=self.request.user, + ) + key = cache_key(self.binding, self.request) + cache.set(key, policy_result) + LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key) + return policy_result + + def run(self): + """Task wrapper to run policy checking""" + self.connection.send(self.execute()) diff --git a/passbook/policies/reputation/__init__.py b/authentik/policies/reputation/__init__.py similarity index 100% rename from passbook/policies/reputation/__init__.py rename to authentik/policies/reputation/__init__.py diff --git a/authentik/policies/reputation/api.py b/authentik/policies/reputation/api.py new file mode 100644 index 00000000..2a5fd7c7 --- /dev/null +++ b/authentik/policies/reputation/api.py @@ -0,0 +1,21 @@ +"""Source API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS +from authentik.policies.reputation.models import ReputationPolicy + + +class ReputationPolicySerializer(ModelSerializer): + """Reputation Policy Serializer""" + + class Meta: + model = ReputationPolicy + fields = GENERAL_SERIALIZER_FIELDS + ["check_ip", "check_username", "threshold"] + + +class ReputationPolicyViewSet(ModelViewSet): + """Source Viewset""" + + queryset = ReputationPolicy.objects.all() + serializer_class = ReputationPolicySerializer diff --git a/authentik/policies/reputation/apps.py b/authentik/policies/reputation/apps.py new file mode 100644 index 00000000..594b471f --- /dev/null +++ b/authentik/policies/reputation/apps.py @@ -0,0 +1,15 @@ +"""Authentik reputation_policy app config""" +from importlib import import_module + +from django.apps import AppConfig + + +class AuthentikPolicyReputationConfig(AppConfig): + """Authentik reputation app config""" + + name = "authentik.policies.reputation" + label = "authentik_policies_reputation" + verbose_name = "authentik Policies.Reputation" + + def ready(self): + import_module("authentik.policies.reputation.signals") diff --git a/authentik/policies/reputation/forms.py b/authentik/policies/reputation/forms.py new file mode 100644 index 00000000..ce3a23ed --- /dev/null +++ b/authentik/policies/reputation/forms.py @@ -0,0 +1,22 @@ +"""authentik reputation request forms""" +from django import forms +from django.utils.translation import gettext_lazy as _ + +from authentik.policies.forms import GENERAL_FIELDS +from authentik.policies.reputation.models import ReputationPolicy + + +class ReputationPolicyForm(forms.ModelForm): + """Form to edit ReputationPolicy""" + + class Meta: + + model = ReputationPolicy + fields = GENERAL_FIELDS + ["check_ip", "check_username", "threshold"] + widgets = { + "name": forms.TextInput(), + "value": forms.TextInput(), + } + labels = { + "check_ip": _("Check IP"), + } diff --git a/authentik/policies/reputation/migrations/0001_initial.py b/authentik/policies/reputation/migrations/0001_initial.py new file mode 100644 index 00000000..fe7eaf9c --- /dev/null +++ b/authentik/policies/reputation/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +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_policies", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="IPReputation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("ip", models.GenericIPAddressField(unique=True)), + ("score", models.IntegerField(default=0)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="ReputationPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.Policy", + ), + ), + ("check_ip", models.BooleanField(default=True)), + ("check_username", models.BooleanField(default=True)), + ("threshold", models.IntegerField(default=-5)), + ], + options={ + "verbose_name": "Reputation Policy", + "verbose_name_plural": "Reputation Policies", + }, + bases=("authentik_policies.policy",), + ), + migrations.CreateModel( + name="UserReputation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("score", models.IntegerField(default=0)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/passbook/policies/reputation/migrations/__init__.py b/authentik/policies/reputation/migrations/__init__.py similarity index 100% rename from passbook/policies/reputation/migrations/__init__.py rename to authentik/policies/reputation/migrations/__init__.py diff --git a/authentik/policies/reputation/models.py b/authentik/policies/reputation/models.py new file mode 100644 index 00000000..2dfaa834 --- /dev/null +++ b/authentik/policies/reputation/models.py @@ -0,0 +1,74 @@ +"""authentik reputation request policy""" +from typing import Type + +from django.core.cache import cache +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from rest_framework.serializers import BaseSerializer + +from authentik.core.models import User +from authentik.lib.utils.http import get_client_ip +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult + +CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_" +CACHE_KEY_USER_PREFIX = "authentik_reputation_user_" + + +class ReputationPolicy(Policy): + """Return true if request IP/target username's score is below a certain threshold""" + + check_ip = models.BooleanField(default=True) + check_username = models.BooleanField(default=True) + threshold = models.IntegerField(default=-5) + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.reputation.api import ReputationPolicySerializer + + return ReputationPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.reputation.forms import ReputationPolicyForm + + return ReputationPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + remote_ip = get_client_ip(request.http_request) or "255.255.255.255" + passing = True + if self.check_ip: + score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) + passing = passing and score <= self.threshold + if self.check_username: + score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) + passing = passing and score <= self.threshold + return PolicyResult(passing) + + class Meta: + + verbose_name = _("Reputation Policy") + verbose_name_plural = _("Reputation Policies") + + +class IPReputation(models.Model): + """Store score coming from the same IP""" + + ip = models.GenericIPAddressField(unique=True) + score = models.IntegerField(default=0) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"IPReputation for {self.ip} @ {self.score}" + + +class UserReputation(models.Model): + """Store score attempting to log in as the same username""" + + user = models.OneToOneField(User, on_delete=models.CASCADE) + score = models.IntegerField(default=0) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"UserReputation for {self.user} @ {self.score}" diff --git a/authentik/policies/reputation/settings.py b/authentik/policies/reputation/settings.py new file mode 100644 index 00000000..401ba548 --- /dev/null +++ b/authentik/policies/reputation/settings.py @@ -0,0 +1,15 @@ +"""Reputation Settings""" +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + "policies_reputation_ip_save": { + "task": "authentik.policies.reputation.tasks.save_ip_reputation", + "schedule": crontab(minute="*/5"), + "options": {"queue": "authentik_scheduled"}, + }, + "policies_reputation_user_save": { + "task": "authentik.policies.reputation.tasks.save_user_reputation", + "schedule": crontab(minute="*/5"), + "options": {"queue": "authentik_scheduled"}, + }, +} diff --git a/authentik/policies/reputation/signals.py b/authentik/policies/reputation/signals.py new file mode 100644 index 00000000..fce16d70 --- /dev/null +++ b/authentik/policies/reputation/signals.py @@ -0,0 +1,43 @@ +"""authentik reputation request signals""" +from django.contrib.auth.signals import user_logged_in, user_login_failed +from django.core.cache import cache +from django.dispatch import receiver +from django.http import HttpRequest +from structlog import get_logger + +from authentik.lib.utils.http import get_client_ip +from authentik.policies.reputation.models import ( + CACHE_KEY_IP_PREFIX, + CACHE_KEY_USER_PREFIX, +) + +LOGGER = get_logger() + + +def update_score(request: HttpRequest, username: str, amount: int): + """Update score for IP and User""" + remote_ip = get_client_ip(request) or "255.255.255.255" + + # We only update the cache here, as its faster than writing to the DB + cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) + cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) + + cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0) + cache.incr(CACHE_KEY_USER_PREFIX + username, amount) + + LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) + + +@receiver(user_login_failed) +# pylint: disable=unused-argument +def handle_failed_login(sender, request, credentials, **_): + """Lower Score for failed loging attempts""" + if "username" in credentials: + update_score(request, credentials.get("username"), -1) + + +@receiver(user_logged_in) +# pylint: disable=unused-argument +def handle_successful_login(sender, request, user, **_): + """Raise score for successful attempts""" + update_score(request, user.username, 1) diff --git a/authentik/policies/reputation/tasks.py b/authentik/policies/reputation/tasks.py new file mode 100644 index 00000000..78fafee5 --- /dev/null +++ b/authentik/policies/reputation/tasks.py @@ -0,0 +1,50 @@ +"""Reputation tasks""" +from django.core.cache import cache +from structlog import get_logger + +from authentik.core.models import User +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.policies.reputation.models import IPReputation, UserReputation +from authentik.policies.reputation.signals import ( + CACHE_KEY_IP_PREFIX, + CACHE_KEY_USER_PREFIX, +) +from authentik.root.celery import CELERY_APP + +LOGGER = get_logger() + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def save_ip_reputation(self: MonitoredTask): + """Save currently cached reputation to database""" + objects_to_update = [] + for key, score in cache.get_many(cache.keys(CACHE_KEY_IP_PREFIX + "*")).items(): + remote_ip = key.replace(CACHE_KEY_IP_PREFIX, "") + rep, _ = IPReputation.objects.get_or_create(ip=remote_ip) + rep.score = score + objects_to_update.append(rep) + IPReputation.objects.bulk_update(objects_to_update, ["score"]) + self.set_status( + TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated IP Reputation"]) + ) + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def save_user_reputation(self: MonitoredTask): + """Save currently cached reputation to database""" + objects_to_update = [] + for key, score in cache.get_many(cache.keys(CACHE_KEY_USER_PREFIX + "*")).items(): + username = key.replace(CACHE_KEY_USER_PREFIX, "") + users = User.objects.filter(username=username) + if not users.exists(): + LOGGER.info("User in cache does not exist, ignoring", username=username) + continue + rep, _ = UserReputation.objects.get_or_create(user=users.first()) + rep.score = score + objects_to_update.append(rep) + UserReputation.objects.bulk_update(objects_to_update, ["score"]) + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, ["Successfully updated User Reputation"] + ) + ) diff --git a/authentik/policies/reputation/tests.py b/authentik/policies/reputation/tests.py new file mode 100644 index 00000000..00c5e689 --- /dev/null +++ b/authentik/policies/reputation/tests.py @@ -0,0 +1,55 @@ +"""test reputation signals and policy""" +from django.contrib.auth import authenticate +from django.core.cache import cache +from django.test import TestCase + +from authentik.core.models import User +from authentik.policies.reputation.models import ( + CACHE_KEY_IP_PREFIX, + CACHE_KEY_USER_PREFIX, + IPReputation, + ReputationPolicy, + UserReputation, +) +from authentik.policies.reputation.tasks import save_ip_reputation, save_user_reputation +from authentik.policies.types import PolicyRequest + + +class TestReputationPolicy(TestCase): + """test reputation signals and policy""" + + def setUp(self): + self.test_ip = "255.255.255.255" + self.test_username = "test" + cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip) + cache.delete(CACHE_KEY_USER_PREFIX + self.test_username) + # We need a user for the one-to-one in userreputation + self.user = User.objects.create(username=self.test_username) + + def test_ip_reputation(self): + """test IP reputation""" + # Trigger negative reputation + authenticate(None, username=self.test_username, password=self.test_username) + # Test value in cache + self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1) + # Save cache and check db values + save_ip_reputation.delay().get() + self.assertEqual(IPReputation.objects.get(ip=self.test_ip).score, -1) + + def test_user_reputation(self): + """test User reputation""" + # Trigger negative reputation + authenticate(None, username=self.test_username, password=self.test_username) + # Test value in cache + self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1) + # Save cache and check db values + save_user_reputation.delay().get() + self.assertEqual(UserReputation.objects.get(user=self.user).score, -1) + + def test_policy(self): + """Test Policy""" + request = PolicyRequest(user=self.user) + policy: ReputationPolicy = ReputationPolicy.objects.create( + name="reputation-test", threshold=0 + ) + self.assertTrue(policy.passes(request).passing) diff --git a/authentik/policies/signals.py b/authentik/policies/signals.py new file mode 100644 index 00000000..1c559524 --- /dev/null +++ b/authentik/policies/signals.py @@ -0,0 +1,25 @@ +"""authentik policy signals""" +from django.core.cache import cache +from django.db.models.signals import post_save +from django.dispatch import receiver +from structlog import get_logger + +LOGGER = get_logger() + + +@receiver(post_save) +# pylint: disable=unused-argument +def invalidate_policy_cache(sender, instance, **_): + """Invalidate Policy cache when policy is updated""" + from authentik.policies.models import Policy, PolicyBinding + + if isinstance(instance, Policy): + total = 0 + for binding in PolicyBinding.objects.filter(policy=instance): + prefix = ( + f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*" + ) + keys = cache.keys(prefix) + total += len(keys) + cache.delete_many(keys) + LOGGER.debug("Invalidating policy cache", policy=instance, keys=total) diff --git a/authentik/policies/templates/policies/denied.html b/authentik/policies/templates/policies/denied.html new file mode 100644 index 00000000..d84d860b --- /dev/null +++ b/authentik/policies/templates/policies/denied.html @@ -0,0 +1,57 @@ +{% extends 'login/base_full.html' %} + +{% load static %} +{% load i18n %} +{% load authentik_utils %} + +{% block card_title %} +{% trans 'Permission denied' %} +{% endblock %} + +{% block title %} +{% trans 'Permission denied' %} +{% endblock %} + +{% block card %} +
+ {% csrf_token %} + {% include 'partials/form.html' %} +
+

+ + {% trans 'Request has been denied.' %} +

+ {% if error %} +
+

+ {{ error }} +

+ {% endif %} + {% if policy_result %} +
+ + {% trans 'Explanation:' %} + +
    + {% for source_result in policy_result.source_results %} +
  • + {% blocktrans with name=source_result.source_policy.name result=source_result.passing %} + Policy '{{ name }}' returned result '{{ result }}' + {% endblocktrans %} + {% if source_result.messages %} +
      + {% for message in source_result.messages %} +
    • {{ message }}
    • + {% endfor %} +
    + {% endif %} +
  • + {% endfor %} +
+ {% endif %} +
+ {% if 'back' in request.GET %} + {% trans 'Back' %} + {% endif %} +
+{% endblock %} diff --git a/passbook/policies/tests/__init__.py b/authentik/policies/tests/__init__.py similarity index 100% rename from passbook/policies/tests/__init__.py rename to authentik/policies/tests/__init__.py diff --git a/authentik/policies/tests/test_engine.py b/authentik/policies/tests/test_engine.py new file mode 100644 index 00000000..fe2c808b --- /dev/null +++ b/authentik/policies/tests/test_engine.py @@ -0,0 +1,84 @@ +"""policy engine tests""" +from django.core.cache import cache +from django.test import TestCase + +from authentik.core.models import User +from authentik.policies.dummy.models import DummyPolicy +from authentik.policies.engine import PolicyEngine +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel + + +class TestPolicyEngine(TestCase): + """PolicyEngine tests""" + + def setUp(self): + cache.clear() + self.user = User.objects.create_user(username="policyuser") + self.policy_false = DummyPolicy.objects.create( + result=False, wait_min=0, wait_max=1 + ) + self.policy_true = DummyPolicy.objects.create( + result=True, wait_min=0, wait_max=1 + ) + self.policy_wrong_type = Policy.objects.create(name="wrong_type") + self.policy_raises = ExpressionPolicy.objects.create( + name="raises", expression="{{ 0/0 }}" + ) + + def test_engine_empty(self): + """Ensure empty policy list passes""" + pbm = PolicyBindingModel.objects.create() + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, True) + self.assertEqual(result.messages, ()) + + def test_engine(self): + """Ensure all policies passes (Mix of false and true -> false)""" + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) + PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=1) + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("dummy",)) + + def test_engine_negate(self): + """Test negate flag""" + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create( + target=pbm, policy=self.policy_true, negate=True, order=0 + ) + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("dummy",)) + + def test_engine_policy_error(self): + """Test policy raising an error flag""" + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_raises, order=0) + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("division by zero",)) + + def test_engine_policy_type(self): + """Test invalid policy type""" + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_wrong_type, order=0) + with self.assertRaises(TypeError): + engine = PolicyEngine(pbm, self.user) + engine.build() + + def test_engine_cache(self): + """Ensure empty policy list passes""" + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) + engine = PolicyEngine(pbm, self.user) + self.assertEqual(len(cache.keys("policy_*")), 0) + self.assertEqual(engine.build().passing, False) + self.assertEqual(len(cache.keys("policy_*")), 1) + self.assertEqual(engine.build().passing, False) + self.assertEqual(len(cache.keys("policy_*")), 1) diff --git a/authentik/policies/tests/test_models.py b/authentik/policies/tests/test_models.py new file mode 100644 index 00000000..3e13b852 --- /dev/null +++ b/authentik/policies/tests/test_models.py @@ -0,0 +1,30 @@ +"""flow model tests""" +from typing import Callable, Type + +from django.forms import ModelForm +from django.test import TestCase + +from authentik.lib.utils.reflection import all_subclasses +from authentik.policies.models import Policy + + +class TestPolicyProperties(TestCase): + """Generic model properties tests""" + + +def policy_tester_factory(model: Type[Policy]) -> Callable: + """Test a form""" + + def tester(self: TestPolicyProperties): + model_inst = model() + self.assertTrue(issubclass(model_inst.form, ModelForm)) + + return tester + + +for policy_type in all_subclasses(Policy): + setattr( + TestPolicyProperties, + f"test_policy_{policy_type.__name__}", + policy_tester_factory(policy_type), + ) diff --git a/authentik/policies/types.py b/authentik/policies/types.py new file mode 100644 index 00000000..2abaf444 --- /dev/null +++ b/authentik/policies/types.py @@ -0,0 +1,53 @@ +"""policy structures""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +from django.db.models import Model +from django.http import HttpRequest + +if TYPE_CHECKING: + from authentik.core.models import User + from authentik.policies.models import Policy + + +class PolicyRequest: + """Data-class to hold policy request data""" + + user: User + http_request: Optional[HttpRequest] + obj: Optional[Model] + context: Dict[str, str] + + def __init__(self, user: User): + self.user = user + self.http_request = None + self.obj = None + self.context = {} + + def __str__(self): + return f"" + + +class PolicyResult: + """Small data-class to hold policy results""" + + passing: bool + messages: Tuple[str, ...] + + source_policy: Optional[Policy] + source_results: Optional[List["PolicyResult"]] + + def __init__(self, passing: bool, *messages: str): + self.passing = passing + self.messages = messages + self.source_policy = None + self.source_results = [] + + def __repr__(self): + return self.__str__() + + def __str__(self): + if self.messages: + return f"PolicyResult passing={self.passing} messages={self.messages}" + return f"PolicyResult passing={self.passing}" diff --git a/passbook/policies/utils.py b/authentik/policies/utils.py similarity index 100% rename from passbook/policies/utils.py rename to authentik/policies/utils.py diff --git a/authentik/policies/views.py b/authentik/policies/views.py new file mode 100644 index 00000000..844a939e --- /dev/null +++ b/authentik/policies/views.py @@ -0,0 +1,93 @@ +"""authentik access helper classes""" +from typing import Any, Optional + +from django.contrib import messages +from django.contrib.auth.mixins import AccessMixin +from django.contrib.auth.views import redirect_to_login +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from django.views.generic.base import View +from structlog import get_logger + +from authentik.core.models import Application, Provider, User +from authentik.flows.views import SESSION_KEY_APPLICATION_PRE +from authentik.policies.engine import PolicyEngine +from authentik.policies.http import AccessDeniedResponse +from authentik.policies.types import PolicyResult + +LOGGER = get_logger() + + +class BaseMixin: + """Base Mixin class, used to annotate View Member variables""" + + request: HttpRequest + + +class PolicyAccessView(AccessMixin, View): + """Mixin class for usage in Authorization views. + Provider functions to check application access, etc""" + + provider: Provider + application: Application + + def resolve_provider_application(self): + """Resolve self.provider and self.application. *.DoesNotExist Exceptions cause a normal + AccessDenied view to be shown. An Http404 exception + is not caught, and will return directly""" + raise NotImplementedError + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + try: + self.resolve_provider_application() + except (Application.DoesNotExist, Provider.DoesNotExist): + return self.handle_no_permission_authenticated() + # Check if user is unauthenticated, so we pass the application + # for the identification stage + if not request.user.is_authenticated: + return self.handle_no_permission() + # Check permissions + result = self.user_has_access() + if not result.passing: + return self.handle_no_permission_authenticated(result) + return super().dispatch(request, *args, **kwargs) + + def handle_no_permission(self) -> HttpResponse: + """User has no access and is not authenticated, so we remember the application + they try to access and redirect to the login URL. The application is saved to show + a hint on the Identification Stage what the user should login for.""" + if self.application: + self.request.session[SESSION_KEY_APPLICATION_PRE] = self.application + return redirect_to_login( + self.request.get_full_path(), + self.get_login_url(), + self.get_redirect_field_name(), + ) + + def handle_no_permission_authenticated( + self, result: Optional[PolicyResult] = None + ) -> HttpResponse: + """Function called when user has no permissions but is authenticated""" + response = AccessDeniedResponse(self.request) + if result: + response.policy_result = result + return response + + def user_has_access(self, user: Optional[User] = None) -> PolicyResult: + """Check if user has access to application.""" + user = user or self.request.user + policy_engine = PolicyEngine( + self.application, user or self.request.user, self.request + ) + policy_engine.build() + result = policy_engine.result + LOGGER.debug( + "AccessMixin user_has_access", + user=user, + app=self.application, + result=result, + ) + if not result.passing: + for message in result.messages: + messages.error(self.request, _(message)) + return result diff --git a/passbook/providers/__init__.py b/authentik/providers/__init__.py similarity index 100% rename from passbook/providers/__init__.py rename to authentik/providers/__init__.py diff --git a/passbook/providers/oauth2/__init__.py b/authentik/providers/oauth2/__init__.py similarity index 100% rename from passbook/providers/oauth2/__init__.py rename to authentik/providers/oauth2/__init__.py diff --git a/authentik/providers/oauth2/api.py b/authentik/providers/oauth2/api.py new file mode 100644 index 00000000..91ae6711 --- /dev/null +++ b/authentik/providers/oauth2/api.py @@ -0,0 +1,51 @@ +"""OAuth2Provider API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping + + +class OAuth2ProviderSerializer(ModelSerializer): + """OAuth2Provider Serializer""" + + class Meta: + + model = OAuth2Provider + fields = [ + "pk", + "name", + "authorization_flow", + "client_type", + "client_id", + "client_secret", + "token_validity", + "response_type", + "jwt_alg", + "rsa_key", + "redirect_uris", + "sub_mode", + "property_mappings", + ] + + +class OAuth2ProviderViewSet(ModelViewSet): + """OAuth2Provider Viewset""" + + queryset = OAuth2Provider.objects.all() + serializer_class = OAuth2ProviderSerializer + + +class ScopeMappingSerializer(ModelSerializer): + """ScopeMapping Serializer""" + + class Meta: + + model = ScopeMapping + fields = ["pk", "name", "scope_name", "description", "expression"] + + +class ScopeMappingViewSet(ModelViewSet): + """ScopeMapping Viewset""" + + queryset = ScopeMapping.objects.all() + serializer_class = ScopeMappingSerializer diff --git a/authentik/providers/oauth2/apps.py b/authentik/providers/oauth2/apps.py new file mode 100644 index 00000000..68ccbb76 --- /dev/null +++ b/authentik/providers/oauth2/apps.py @@ -0,0 +1,14 @@ +"""authentik auth oauth provider app config""" +from django.apps import AppConfig + + +class AuthentikProviderOAuth2Config(AppConfig): + """authentik auth oauth provider app config""" + + name = "authentik.providers.oauth2" + label = "authentik_providers_oauth2" + verbose_name = "authentik Providers.OAuth2" + mountpoints = { + "authentik.providers.oauth2.urls": "application/o/", + "authentik.providers.oauth2.urls_github": "", + } diff --git a/passbook/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py similarity index 100% rename from passbook/providers/oauth2/constants.py rename to authentik/providers/oauth2/constants.py diff --git a/passbook/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py similarity index 100% rename from passbook/providers/oauth2/errors.py rename to authentik/providers/oauth2/errors.py diff --git a/authentik/providers/oauth2/forms.py b/authentik/providers/oauth2/forms.py new file mode 100644 index 00000000..071b6434 --- /dev/null +++ b/authentik/providers/oauth2/forms.py @@ -0,0 +1,100 @@ +"""authentik OAuth2 Provider Forms""" + +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +from authentik.admin.fields import CodeMirrorWidget +from authentik.core.expression import PropertyMappingEvaluator +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow, FlowDesignation +from authentik.providers.oauth2.generators import ( + generate_client_id, + generate_client_secret, +) +from authentik.providers.oauth2.models import ( + JWTAlgorithms, + OAuth2Provider, + ScopeMapping, +) + + +class OAuth2ProviderForm(forms.ModelForm): + """OAuth2 Provider form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["authorization_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.AUTHORIZATION + ) + self.fields["client_id"].initial = generate_client_id() + self.fields["client_secret"].initial = generate_client_secret() + self.fields["rsa_key"].queryset = CertificateKeyPair.objects.exclude( + key_data__exact="" + ) + self.fields["property_mappings"].queryset = ScopeMapping.objects.all() + + def clean_jwt_alg(self): + """Ensure that when RS256 is selected, a certificate-key-pair is selected""" + if ( + self.data["rsa_key"] == "" + and self.cleaned_data["jwt_alg"] == JWTAlgorithms.RS256 + ): + raise ValidationError( + _("RS256 requires a Certificate-Key-Pair to be selected.") + ) + return self.cleaned_data["jwt_alg"] + + class Meta: + model = OAuth2Provider + fields = [ + "name", + "authorization_flow", + "client_type", + "client_id", + "client_secret", + "response_type", + "token_validity", + "jwt_alg", + "rsa_key", + "redirect_uris", + "sub_mode", + "property_mappings", + ] + widgets = { + "name": forms.TextInput(), + "token_validity": forms.TextInput(), + } + labels = {"property_mappings": _("Scopes")} + help_texts = { + "property_mappings": _( + ( + "Select which scopes can be used by the client. " + "The client stil has to specify the scope to access the data." + ) + ) + } + + +class ScopeMappingForm(forms.ModelForm): + """Form to edit ScopeMappings""" + + template_name = "providers/oauth2/property_mapping_form.html" + + def clean_expression(self): + """Test Syntax""" + expression = self.cleaned_data.get("expression") + evaluator = PropertyMappingEvaluator() + evaluator.validate(expression) + return expression + + class Meta: + + model = ScopeMapping + fields = ["name", "scope_name", "description", "expression"] + widgets = { + "name": forms.TextInput(), + "scope_name": forms.TextInput(), + "description": forms.TextInput(), + "expression": CodeMirrorWidget(mode="python"), + } diff --git a/passbook/providers/oauth2/generators.py b/authentik/providers/oauth2/generators.py similarity index 100% rename from passbook/providers/oauth2/generators.py rename to authentik/providers/oauth2/generators.py diff --git a/authentik/providers/oauth2/migrations/0001_initial.py b/authentik/providers/oauth2/migrations/0001_initial.py new file mode 100644 index 00000000..0a234d64 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0001_initial.py @@ -0,0 +1,362 @@ +# Generated by Django 3.1 on 2020-08-18 15:59 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.conf import settings +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import authentik.core.models +import authentik.lib.utils.time +import authentik.providers.oauth2.generators + +SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself. +return {} +""" +SCOPE_EMAIL_EXPRESSION = """return { + "email": user.email, + "email_verified": True +} +""" +SCOPE_PROFILE_EXPRESSION = """return { + "name": user.name, + "given_name": user.name, + "family_name": "", + "preferred_username": user.username, + "nickname": user.username, +} +""" + + +def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping") + ScopeMapping.objects.update_or_create( + scope_name="openid", + defaults={ + "name": "Autogenerated OAuth2 Mapping: OpenID 'openid'", + "scope_name": "openid", + "description": "", + "expression": SCOPE_OPENID_EXPRESSION, + }, + ) + ScopeMapping.objects.update_or_create( + scope_name="email", + defaults={ + "name": "Autogenerated OAuth2 Mapping: OpenID 'email'", + "scope_name": "email", + "description": "Email address", + "expression": SCOPE_EMAIL_EXPRESSION, + }, + ) + ScopeMapping.objects.update_or_create( + scope_name="profile", + defaults={ + "name": "Autogenerated OAuth2 Mapping: OpenID 'profile'", + "scope_name": "profile", + "description": "General Profile Information", + "expression": SCOPE_PROFILE_EXPRESSION, + }, + ) + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_core", "0007_auto_20200815_1841"), + ("authentik_crypto", "0002_create_self_signed_kp"), + ] + + operations = [ + migrations.RunSQL( + "DROP TABLE IF EXISTS authentik_providers_oauth_oauth2provider CASCADE;" + ), + migrations.RunSQL( + "DROP TABLE IF EXISTS authentik_providers_oidc_openidprovider CASCADE;" + ), + migrations.CreateModel( + name="OAuth2Provider", + fields=[ + ( + "provider_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.provider", + ), + ), + ("name", models.TextField()), + ( + "client_type", + models.CharField( + choices=[ + ("confidential", "Confidential"), + ("public", "Public"), + ], + default="confidential", + help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.", + max_length=30, + verbose_name="Client Type", + ), + ), + ( + "client_id", + models.CharField( + default=authentik.providers.oauth2.generators.generate_client_id, + max_length=255, + unique=True, + verbose_name="Client ID", + ), + ), + ( + "client_secret", + models.CharField( + blank=True, + default=authentik.providers.oauth2.generators.generate_client_secret, + max_length=255, + verbose_name="Client Secret", + ), + ), + ( + "response_type", + models.TextField( + choices=[ + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ( + "code id_token token", + "code id_token token (Hybrid Flow)", + ), + ], + default="code", + help_text="Response Type required by the client.", + ), + ), + ( + "jwt_alg", + models.CharField( + choices=[ + ("HS256", "HS256 (Symmetric Encryption)"), + ("RS256", "RS256 (Asymmetric Encryption)"), + ], + default="RS256", + help_text="Algorithm used to sign the JWT Token", + max_length=10, + verbose_name="JWT Algorithm", + ), + ), + ( + "redirect_uris", + models.TextField( + default="", + help_text="Enter each URI on a new line.", + verbose_name="Redirect URIs", + ), + ), + ( + "post_logout_redirect_uris", + models.TextField( + blank=True, + default="", + help_text="Enter each URI on a new line.", + verbose_name="Post Logout Redirect URIs", + ), + ), + ( + "include_claims_in_id_token", + models.BooleanField( + default=True, + help_text="Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.", + verbose_name="Include claims in id_token", + ), + ), + ( + "token_validity", + models.TextField( + default="minutes=10", + help_text="Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", + validators=[ + authentik.lib.utils.time.timedelta_string_validator + ], + ), + ), + ( + "rsa_key", + models.ForeignKey( + help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.", + on_delete=django.db.models.deletion.CASCADE, + to="authentik_crypto.certificatekeypair", + verbose_name="RSA Key", + blank=True, + null=True, + ), + ), + ], + options={ + "verbose_name": "OAuth2/OpenID Provider", + "verbose_name_plural": "OAuth2/OpenID Providers", + }, + bases=("authentik_core.provider",), + ), + migrations.CreateModel( + name="ScopeMapping", + fields=[ + ( + "propertymapping_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.propertymapping", + ), + ), + ("scope_name", models.TextField(help_text="Scope used by the client")), + ( + "description", + models.TextField( + blank=True, + help_text="Description shown to the user when consenting. If left empty, the user won't be informed.", + ), + ), + ], + options={ + "verbose_name": "Scope Mapping", + "verbose_name_plural": "Scope Mappings", + }, + bases=("authentik_core.propertymapping",), + ), + migrations.RunPython(create_default_scopes), + migrations.CreateModel( + name="RefreshToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "expires", + models.DateTimeField( + default=authentik.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ("_scope", models.TextField(default="", verbose_name="Scopes")), + ( + "access_token", + models.CharField( + max_length=255, unique=True, verbose_name="Access Token" + ), + ), + ( + "refresh_token", + models.CharField( + max_length=255, unique=True, verbose_name="Refresh Token" + ), + ), + ("_id_token", models.TextField(verbose_name="ID Token")), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_oauth2.oauth2provider", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Token", + "verbose_name_plural": "Tokens", + }, + ), + migrations.CreateModel( + name="AuthorizationCode", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "expires", + models.DateTimeField( + default=authentik.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ("_scope", models.TextField(default="", verbose_name="Scopes")), + ( + "code", + models.CharField(max_length=255, unique=True, verbose_name="Code"), + ), + ( + "nonce", + models.CharField( + blank=True, default="", max_length=255, verbose_name="Nonce" + ), + ), + ( + "is_open_id", + models.BooleanField( + default=False, verbose_name="Is Authentication?" + ), + ), + ( + "code_challenge", + models.CharField( + max_length=255, null=True, verbose_name="Code Challenge" + ), + ), + ( + "code_challenge_method", + models.CharField( + max_length=255, null=True, verbose_name="Code Challenge Method" + ), + ), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_oauth2.oauth2provider", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Authorization Code", + "verbose_name_plural": "Authorization Codes", + }, + ), + ] diff --git a/authentik/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py b/authentik/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py new file mode 100644 index 00000000..895d6fa0 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.1 on 2020-09-15 18:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_oauth2", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="oauth2provider", + name="sub_mode", + field=models.TextField( + choices=[ + ("hashed_user_id", "Based on the Hashed User ID"), + ("user_username", "Based on the username"), + ( + "user_email", + "Based on the User's Email. This is recommended over the UPN method.", + ), + ( + "user_upn", + "Based on the User's UPN, only works if user has a 'upn' attribute set. Use this method only if you have different UPN and Mail domains.", + ), + ], + default="hashed_user_id", + help_text="Configure what data should be used as unique User Identifier. For most cases, the default should be fine.", + ), + ), + ] diff --git a/authentik/providers/oauth2/migrations/0003_auto_20200916_2129.py b/authentik/providers/oauth2/migrations/0003_auto_20200916_2129.py new file mode 100644 index 00000000..bc14353c --- /dev/null +++ b/authentik/providers/oauth2/migrations/0003_auto_20200916_2129.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.1 on 2020-09-16 21:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_oauth2", "0002_oauth2provider_sub_mode"), + ] + + operations = [ + migrations.AlterField( + model_name="oauth2provider", + name="client_type", + field=models.CharField( + choices=[("confidential", "Confidential"), ("public", "Public")], + default="confidential", + help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.", + max_length=30, + verbose_name="Client Type", + ), + ), + migrations.AlterField( + model_name="oauth2provider", + name="response_type", + field=models.TextField( + choices=[ + ("code", "code (Authorization Code Flow)"), + ( + "code_adfs", + "code (ADFS Compatibility Mode, sends id_token as access_token)", + ), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), + ], + default="code", + help_text="Response Type required by the client.", + ), + ), + ] diff --git a/authentik/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py b/authentik/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py new file mode 100644 index 00000000..a5776fee --- /dev/null +++ b/authentik/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.1 on 2020-09-18 21:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_oauth2", "0003_auto_20200916_2129"), + ] + + operations = [ + migrations.RemoveField( + model_name="oauth2provider", + name="post_logout_redirect_uris", + ), + ] diff --git a/authentik/providers/oauth2/migrations/0005_auto_20200920_1240.py b/authentik/providers/oauth2/migrations/0005_auto_20200920_1240.py new file mode 100644 index 00000000..eb50bb39 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0005_auto_20200920_1240.py @@ -0,0 +1,36 @@ +# Generated by Django 3.1.1 on 2020-09-20 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_providers_oauth2", + "0004_remove_oauth2provider_post_logout_redirect_uris", + ), + ] + + operations = [ + migrations.AlterField( + model_name="oauth2provider", + name="response_type", + field=models.TextField( + choices=[ + ("code", "code (Authorization Code Flow)"), + ( + "code#adfs", + "code (ADFS Compatibility Mode, sends id_token as access_token)", + ), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), + ], + default="code", + help_text="Response Type required by the client.", + ), + ), + ] diff --git a/authentik/providers/oauth2/migrations/0006_remove_oauth2provider_name.py b/authentik/providers/oauth2/migrations/0006_remove_oauth2provider_name.py new file mode 100644 index 00000000..cead8957 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0006_remove_oauth2provider_name.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.2 on 2020-10-03 17:37 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def update_name_temp(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + OAuth2Provider = apps.get_model("authentik_providers_oauth2", "OAuth2Provider") + db_alias = schema_editor.connection.alias + + for provider in OAuth2Provider.objects.using(db_alias).all(): + provider.name_temp = provider.name + provider.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0011_provider_name_temp"), + ("authentik_providers_oauth2", "0005_auto_20200920_1240"), + ] + + operations = [ + migrations.RunPython(update_name_temp), + migrations.RemoveField( + model_name="oauth2provider", + name="name", + ), + ] diff --git a/authentik/providers/oauth2/migrations/0007_auto_20201016_1107.py b/authentik/providers/oauth2/migrations/0007_auto_20201016_1107.py new file mode 100644 index 00000000..1f5cab06 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0007_auto_20201016_1107.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.2 on 2020-10-16 11:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_oauth2", "0006_remove_oauth2provider_name"), + ] + + operations = [ + migrations.AlterModelOptions( + name="refreshtoken", + options={ + "verbose_name": "OAuth2 Token", + "verbose_name_plural": "OAuth2 Tokens", + }, + ), + ] diff --git a/passbook/providers/oauth2/migrations/__init__.py b/authentik/providers/oauth2/migrations/__init__.py similarity index 100% rename from passbook/providers/oauth2/migrations/__init__.py rename to authentik/providers/oauth2/migrations/__init__.py diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py new file mode 100644 index 00000000..ebaa96dc --- /dev/null +++ b/authentik/providers/oauth2/models.py @@ -0,0 +1,499 @@ +"""OAuth Provider Models""" +import base64 +import binascii +import json +import time +from dataclasses import asdict, dataclass, field +from hashlib import sha256 +from typing import Any, Dict, List, Optional, Type +from urllib.parse import urlparse +from uuid import uuid4 + +from django.conf import settings +from django.db import models +from django.forms import ModelForm +from django.http import HttpRequest +from django.shortcuts import reverse +from django.utils import dateformat, timezone +from django.utils.translation import gettext_lazy as _ +from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key +from jwkest.jws import JWS + +from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User +from authentik.crypto.models import CertificateKeyPair +from authentik.lib.utils.template import render_to_string +from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator +from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config +from authentik.providers.oauth2.generators import ( + generate_client_id, + generate_client_secret, +) + + +class ClientTypes(models.TextChoices): + """Confidential clients are capable of maintaining the confidentiality + of their credentials. Public clients are incapable.""" + + CONFIDENTIAL = "confidential", _("Confidential") + PUBLIC = "public", _("Public") + + +class GrantTypes(models.TextChoices): + """OAuth2 Grant types we support""" + + AUTHORIZATION_CODE = "authorization_code" + IMPLICIT = "implicit" + HYBRID = "hybrid" + + +class SubModes(models.TextChoices): + """Mode after which 'sub' attribute is generateed, for compatibility reasons""" + + HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID") + USER_USERNAME = "user_username", _("Based on the username") + USER_EMAIL = ( + "user_email", + _("Based on the User's Email. This is recommended over the UPN method."), + ) + USER_UPN = ( + "user_upn", + _( + ( + "Based on the User's UPN, only works if user has a 'upn' attribute set. " + "Use this method only if you have different UPN and Mail domains." + ) + ), + ) + + +class ResponseTypes(models.TextChoices): + """Response Type required by the client.""" + + CODE = "code", _("code (Authorization Code Flow)") + CODE_ADFS = ( + "code#adfs", + _("code (ADFS Compatibility Mode, sends id_token as access_token)"), + ) + ID_TOKEN = "id_token", _("id_token (Implicit Flow)") + ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)") + CODE_TOKEN = "code token", _("code token (Hybrid Flow)") + CODE_ID_TOKEN = "code id_token", _("code id_token (Hybrid Flow)") + CODE_ID_TOKEN_TOKEN = "code id_token token", _("code id_token token (Hybrid Flow)") + + +class JWTAlgorithms(models.TextChoices): + """Algorithm used to sign the JWT Token""" + + HS256 = "HS256", _("HS256 (Symmetric Encryption)") + RS256 = "RS256", _("RS256 (Asymmetric Encryption)") + + +class ScopeMapping(PropertyMapping): + """Map an OAuth Scope to users properties""" + + scope_name = models.TextField(help_text=_("Scope used by the client")) + description = models.TextField( + blank=True, + help_text=_( + ( + "Description shown to the user when consenting. " + "If left empty, the user won't be informed." + ) + ), + ) + + @property + def form(self) -> Type[ModelForm]: + from authentik.providers.oauth2.forms import ScopeMappingForm + + return ScopeMappingForm + + def __str__(self): + return f"Scope Mapping {self.name} ({self.scope_name})" + + class Meta: + + verbose_name = _("Scope Mapping") + verbose_name_plural = _("Scope Mappings") + + +class OAuth2Provider(Provider): + """OAuth2 Provider for generic OAuth and OpenID Connect Applications.""" + + client_type = models.CharField( + max_length=30, + choices=ClientTypes.choices, + default=ClientTypes.CONFIDENTIAL, + verbose_name=_("Client Type"), + help_text=_(ClientTypes.__doc__), + ) + client_id = models.CharField( + max_length=255, + unique=True, + verbose_name=_("Client ID"), + default=generate_client_id, + ) + client_secret = models.CharField( + max_length=255, + blank=True, + verbose_name=_("Client Secret"), + default=generate_client_secret, + ) + response_type = models.TextField( + choices=ResponseTypes.choices, + default=ResponseTypes.CODE, + help_text=_(ResponseTypes.__doc__), + ) + jwt_alg = models.CharField( + max_length=10, + choices=JWTAlgorithms.choices, + default=JWTAlgorithms.RS256, + verbose_name=_("JWT Algorithm"), + help_text=_(JWTAlgorithms.__doc__), + ) + redirect_uris = models.TextField( + default="", + verbose_name=_("Redirect URIs"), + help_text=_("Enter each URI on a new line."), + ) + + include_claims_in_id_token = models.BooleanField( + default=True, + verbose_name=_("Include claims in id_token"), + help_text=_( + ( + "Include User claims from scopes in the id_token, for applications " + "that don't access the userinfo endpoint." + ) + ), + ) + + token_validity = models.TextField( + default="minutes=10", + validators=[timedelta_string_validator], + help_text=_( + ( + "Tokens not valid on or after current time + this value " + "(Format: hours=1;minutes=2;seconds=3)." + ) + ), + ) + + sub_mode = models.TextField( + choices=SubModes.choices, + default=SubModes.HASHED_USER_ID, + help_text=_( + ( + "Configure what data should be used as unique User Identifier. For most cases, " + "the default should be fine." + ) + ), + ) + + rsa_key = models.ForeignKey( + CertificateKeyPair, + verbose_name=_("RSA Key"), + on_delete=models.CASCADE, + blank=True, + null=True, + help_text=_( + "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." + ), + ) + + def create_refresh_token( + self, user: User, scope: List[str], id_token: Optional["IDToken"] = None + ) -> "RefreshToken": + """Create and populate a RefreshToken object.""" + token = RefreshToken( + user=user, + provider=self, + access_token=uuid4().hex, + refresh_token=uuid4().hex, + expires=timezone.now() + timedelta_from_string(self.token_validity), + scope=scope, + ) + if id_token: + token.id_token = id_token + return token + + def get_jwt_keys(self) -> List[Key]: + """ + Takes a provider and returns the set of keys associated with it. + Returns a list of keys. + """ + if self.jwt_alg == JWTAlgorithms.RS256: + # if the user selected RS256 but didn't select a + # CertificateKeyPair, we fall back to HS256 + if not self.rsa_key: + self.jwt_alg = JWTAlgorithms.HS256 + self.save() + else: + # Because the JWT Library uses python cryptodome, + # we can't directly pass the RSAPublicKey + # object, but have to load it ourselves + key = import_rsa_key(self.rsa_key.key_data) + keys = [RSAKey(key=key, kid=self.rsa_key.kid)] + if not keys: + raise Exception("You must add at least one RSA Key.") + return keys + + if self.jwt_alg == JWTAlgorithms.HS256: + return [SYMKey(key=self.client_secret, alg=self.jwt_alg)] + + raise Exception("Unsupported key algorithm.") + + def get_issuer(self, request: HttpRequest) -> Optional[str]: + """Get issuer, based on request""" + try: + mountpoint = AuthentikProviderOAuth2Config.mountpoints[ + "authentik.providers.oauth2.urls" + ] + # pylint: disable=no-member + return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/") + except Provider.application.RelatedObjectDoesNotExist: + return None + + @property + def launch_url(self) -> Optional[str]: + """Guess launch_url based on first redirect_uri""" + if self.redirect_uris == "": + return None + main_url = self.redirect_uris.split("\n")[0] + launch_url = urlparse(main_url) + return main_url.replace(launch_url.path, "") + + @property + def form(self) -> Type[ModelForm]: + from authentik.providers.oauth2.forms import OAuth2ProviderForm + + return OAuth2ProviderForm + + def __str__(self): + return f"OAuth2 Provider {self.name}" + + def encode(self, payload: Dict[str, Any]) -> str: + """Represent the ID Token as a JSON Web Token (JWT).""" + keys = self.get_jwt_keys() + # If the provider does not have an RSA Key assigned, it was switched to Symmetric + self.refresh_from_db() + jws = JWS(payload, alg=self.jwt_alg) + return jws.sign_compact(keys) + + def html_setup_urls(self, request: HttpRequest) -> Optional[str]: + """return template and context modal with URLs for authorize, token, openid-config, etc""" + try: + # pylint: disable=no-member + return render_to_string( + "providers/oauth2/setup_url_modal.html", + { + "provider": self, + "issuer": self.get_issuer(request), + "authorize": request.build_absolute_uri( + reverse( + "authentik_providers_oauth2:authorize", + ) + ), + "token": request.build_absolute_uri( + reverse( + "authentik_providers_oauth2:token", + ) + ), + "userinfo": request.build_absolute_uri( + reverse( + "authentik_providers_oauth2:userinfo", + ) + ), + "provider_info": request.build_absolute_uri( + reverse( + "authentik_providers_oauth2:provider-info", + kwargs={"application_slug": self.application.slug}, + ) + ), + }, + ) + except Provider.application.RelatedObjectDoesNotExist: + return None + + class Meta: + + verbose_name = _("OAuth2/OpenID Provider") + verbose_name_plural = _("OAuth2/OpenID Providers") + + +class BaseGrantModel(models.Model): + """Base Model for all grants""" + + provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE) + user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) + _scope = models.TextField(default="", verbose_name=_("Scopes")) + + @property + def scope(self) -> List[str]: + """Return scopes as list of strings""" + return self._scope.split() + + @scope.setter + def scope(self, value): + self._scope = " ".join(value) + + class Meta: + abstract = True + + +class AuthorizationCode(ExpiringModel, BaseGrantModel): + """OAuth2 Authorization Code""" + + code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) + nonce = models.CharField( + max_length=255, blank=True, default="", verbose_name=_("Nonce") + ) + is_open_id = models.BooleanField( + default=False, verbose_name=_("Is Authentication?") + ) + code_challenge = models.CharField( + max_length=255, null=True, verbose_name=_("Code Challenge") + ) + code_challenge_method = models.CharField( + max_length=255, null=True, verbose_name=_("Code Challenge Method") + ) + + class Meta: + verbose_name = _("Authorization Code") + verbose_name_plural = _("Authorization Codes") + + def __str__(self): + return "{0} - {1}".format(self.provider, self.code) + + +@dataclass +class IDToken: + """The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be + Authenticated is the ID Token data structure. The ID Token is a security token that contains + Claims about the Authentication of an End-User by an Authorization Server when using a Client, + and potentially other requested Claims. The ID Token is represented as a + JSON Web Token (JWT) [JWT]. + + https://openid.net/specs/openid-connect-core-1_0.html#IDToken""" + + # All these fields need to optional so we can save an empty IDToken for non-OpenID flows. + iss: Optional[str] = None + sub: Optional[str] = None + aud: Optional[str] = None + exp: Optional[int] = None + iat: Optional[int] = None + auth_time: Optional[int] = None + + nonce: Optional[str] = None + at_hash: Optional[str] = None + + claims: Dict[str, Any] = field(default_factory=dict) + + @staticmethod + def from_dict(data: Dict[str, Any]) -> "IDToken": + """Reconstruct ID Token from json dictionary""" + token = IDToken() + for key, value in data.items(): + setattr(token, key, value) + return token + + def to_dict(self) -> Dict[str, Any]: + """Convert dataclass to dict, and update with keys from `claims`""" + dic = asdict(self) + dic.pop("claims") + dic.update(self.claims) + return dic + + +class RefreshToken(ExpiringModel, BaseGrantModel): + """OAuth2 Refresh Token""" + + access_token = models.CharField( + max_length=255, unique=True, verbose_name=_("Access Token") + ) + refresh_token = models.CharField( + max_length=255, unique=True, verbose_name=_("Refresh Token") + ) + _id_token = models.TextField(verbose_name=_("ID Token")) + + class Meta: + verbose_name = _("OAuth2 Token") + verbose_name_plural = _("OAuth2 Tokens") + + @property + def id_token(self) -> IDToken: + """Load ID Token from json""" + if self._id_token: + raw_token = json.loads(self._id_token) + return IDToken.from_dict(raw_token) + return IDToken() + + @id_token.setter + def id_token(self, value: IDToken): + self._id_token = json.dumps(asdict(value)) + + def __str__(self): + return f"{self.provider} - {self.access_token}" + + @property + def at_hash(self): + """Get hashed access_token""" + hashed_access_token = ( + sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii") + ) + return ( + base64.urlsafe_b64encode( + binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2]) + ) + .rstrip(b"=") + .decode("ascii") + ) + + def create_id_token(self, user: User, request: HttpRequest) -> IDToken: + """Creates the id_token. + See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken""" + sub = "" + if self.provider.sub_mode == SubModes.HASHED_USER_ID: + sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() + elif self.provider.sub_mode == SubModes.USER_EMAIL: + sub = user.email + elif self.provider.sub_mode == SubModes.USER_USERNAME: + sub = user.username + elif self.provider.sub_mode == SubModes.USER_UPN: + sub = user.attributes["upn"] + else: + raise ValueError( + ( + f"Provider {self.provider} has invalid sub_mode " + f"selected: {self.provider.sub_mode}" + ) + ) + + # Convert datetimes into timestamps. + now = int(time.time()) + iat_time = now + exp_time = int( + now + timedelta_from_string(self.provider.token_validity).seconds + ) + user_auth_time = user.last_login or user.date_joined + auth_time = int(dateformat.format(user_auth_time, "U")) + + token = IDToken( + iss=self.provider.get_issuer(request), + sub=sub, + aud=self.provider.client_id, + exp=exp_time, + iat=iat_time, + auth_time=auth_time, + ) + + # Include (or not) user standard claims in the id_token. + if self.provider.include_claims_in_id_token: + from authentik.providers.oauth2.views.userinfo import UserInfoView + + user_info = UserInfoView() + user_info.request = request + claims = user_info.get_claims(self) + token.claims = claims + + return token diff --git a/passbook/providers/oauth2/templates/providers/oauth2/consent.html b/authentik/providers/oauth2/templates/providers/oauth2/consent.html similarity index 100% rename from passbook/providers/oauth2/templates/providers/oauth2/consent.html rename to authentik/providers/oauth2/templates/providers/oauth2/consent.html diff --git a/authentik/providers/oauth2/templates/providers/oauth2/end_session.html b/authentik/providers/oauth2/templates/providers/oauth2/end_session.html new file mode 100644 index 00000000..c87b3634 --- /dev/null +++ b/authentik/providers/oauth2/templates/providers/oauth2/end_session.html @@ -0,0 +1,38 @@ +{% extends 'login/base_full.html' %} + +{% load static %} +{% load i18n %} +{% load authentik_utils %} + +{% block title %} +{% trans 'End session' %} +{% endblock %} + +{% block card_title %} +{% blocktrans with application=application.name %} +You've logged out of {{ application }}. +{% endblocktrans %} +{% endblock %} + +{% block card %} +
+

+ {% blocktrans with application=application.name %} + You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your authentik account. + {% endblocktrans %} +

+ + {% trans 'Go back to overview' %} + + {% trans 'Log out of authentik' %} + + {% if application.get_launch_url %} + + {% blocktrans with application=application.name %} + Log back into {{ application }} + {% endblocktrans %} + + {% endif %} + +
+{% endblock %} diff --git a/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html b/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html new file mode 100644 index 00000000..c202b5b2 --- /dev/null +++ b/authentik/providers/oauth2/templates/providers/oauth2/property_mapping_form.html @@ -0,0 +1,14 @@ +{% extends "generic/form.html" %} + +{% load i18n %} + +{% block beneath_form %} +
+ +
+

+ Expression using Python. See here for a list of all variables. +

+
+
+{% endblock %} diff --git a/authentik/providers/oauth2/templates/providers/oauth2/setup_url_modal.html b/authentik/providers/oauth2/templates/providers/oauth2/setup_url_modal.html new file mode 100644 index 00000000..f17b8781 --- /dev/null +++ b/authentik/providers/oauth2/templates/providers/oauth2/setup_url_modal.html @@ -0,0 +1,50 @@ +{% load i18n %} + + + +
+
+

{% trans 'Setup URLs' %}

+
+ + +
+
diff --git a/authentik/providers/oauth2/urls.py b/authentik/providers/oauth2/urls.py new file mode 100644 index 00000000..2f15b34f --- /dev/null +++ b/authentik/providers/oauth2/urls.py @@ -0,0 +1,43 @@ +"""OAuth provider URLs""" +from django.urls import path +from django.views.decorators.csrf import csrf_exempt + +from authentik.providers.oauth2.constants import SCOPE_OPENID +from authentik.providers.oauth2.utils import protected_resource_view +from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView +from authentik.providers.oauth2.views.introspection import TokenIntrospectionView +from authentik.providers.oauth2.views.jwks import JWKSView +from authentik.providers.oauth2.views.provider import ProviderInfoView +from authentik.providers.oauth2.views.session import EndSessionView +from authentik.providers.oauth2.views.token import TokenView +from authentik.providers.oauth2.views.userinfo import UserInfoView + +urlpatterns = [ + path( + "authorize/", + AuthorizationFlowInitView.as_view(), + name="authorize", + ), + path("token/", csrf_exempt(TokenView.as_view()), name="token"), + path( + "userinfo/", + csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())), + name="userinfo", + ), + path( + "introspect/", + csrf_exempt(TokenIntrospectionView.as_view()), + name="token-introspection", + ), + path( + "/end-session/", + EndSessionView.as_view(), + name="end-session", + ), + path("/jwks/", JWKSView.as_view(), name="jwks"), + path( + "/.well-known/openid-configuration", + ProviderInfoView.as_view(), + name="provider-info", + ), +] diff --git a/authentik/providers/oauth2/urls_github.py b/authentik/providers/oauth2/urls_github.py new file mode 100644 index 00000000..77dba082 --- /dev/null +++ b/authentik/providers/oauth2/urls_github.py @@ -0,0 +1,45 @@ +"""authentik oauth_provider urls""" +from django.urls import include, path +from django.views.decorators.csrf import csrf_exempt + +from authentik.providers.oauth2.constants import ( + SCOPE_GITHUB_ORG_READ, + SCOPE_GITHUB_USER_EMAIL, +) +from authentik.providers.oauth2.utils import protected_resource_view +from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView +from authentik.providers.oauth2.views.github import GitHubUserTeamsView, GitHubUserView +from authentik.providers.oauth2.views.token import TokenView + +github_urlpatterns = [ + path( + "login/oauth/authorize", + AuthorizationFlowInitView.as_view(), + name="github-authorize", + ), + path( + "login/oauth/access_token", + csrf_exempt(TokenView.as_view()), + name="github-access-token", + ), + path( + "user", + csrf_exempt( + protected_resource_view([SCOPE_GITHUB_USER_EMAIL])(GitHubUserView.as_view()) + ), + name="github-user", + ), + path( + "user/teams", + csrf_exempt( + protected_resource_view([SCOPE_GITHUB_ORG_READ])( + GitHubUserTeamsView.as_view() + ) + ), + name="github-user-teams", + ), +] + +urlpatterns = [ + path("", include(github_urlpatterns)), +] diff --git a/authentik/providers/oauth2/utils.py b/authentik/providers/oauth2/utils.py new file mode 100644 index 00000000..23a6a9a2 --- /dev/null +++ b/authentik/providers/oauth2/utils.py @@ -0,0 +1,156 @@ +"""OAuth2/OpenID Utils""" +import re +from base64 import b64decode +from binascii import Error +from typing import List, Optional, Tuple + +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.utils.cache import patch_vary_headers +from jwkest.jwt import JWT +from structlog import get_logger + +from authentik.providers.oauth2.errors import BearerTokenError +from authentik.providers.oauth2.models import RefreshToken + +LOGGER = get_logger() + + +class TokenResponse(JsonResponse): + """JSON Response with headers that it should never be cached + + https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self["Cache-Control"] = "no-store" + self["Pragma"] = "no-cache" + + +def cors_allow_any(request, response): + """ + Add headers to permit CORS requests from any origin, with or without credentials, + with any headers. + """ + origin = request.META.get("HTTP_ORIGIN") + if not origin: + return response + + # From the CORS spec: The string "*" cannot be used for a resource that supports credentials. + response["Access-Control-Allow-Origin"] = origin + patch_vary_headers(response, ["Origin"]) + response["Access-Control-Allow-Credentials"] = "true" + + if request.method == "OPTIONS": + if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META: + response["Access-Control-Allow-Headers"] = request.META[ + "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" + ] + response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + + return response + + +def extract_access_token(request: HttpRequest) -> Optional[str]: + """ + Get the access token using Authorization Request Header Field method. + Or try getting via GET. + See: http://tools.ietf.org/html/rfc6750#section-2.1 + + Return a string. + """ + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + + if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header): + return auth_header.split()[1] + if "access_token" in request.POST: + return request.POST.get("access_token") + if "access_token" in request.GET: + return request.GET.get("access_token") + return None + + +def extract_client_auth(request: HttpRequest) -> Tuple[str, str]: + """ + Get client credentials using HTTP Basic Authentication method. + Or try getting parameters via POST. + See: http://tools.ietf.org/html/rfc6750#section-2.1 + + Return a tuple `(client_id, client_secret)`. + """ + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + + if re.compile(r"^Basic\s{1}.+$").match(auth_header): + b64_user_pass = auth_header.split()[1] + try: + user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") + client_id, client_secret = user_pass + except (ValueError, Error): + client_id = client_secret = "" + else: + client_id = request.POST.get("client_id", "") + client_secret = request.POST.get("client_secret", "") + + return (client_id, client_secret) + + +def protected_resource_view(scopes: List[str]): + """View decorator. The client accesses protected resources by presenting the + access token to the resource server. + + https://tools.ietf.org/html/rfc6749#section-7 + + This decorator also injects the token into `kwargs`""" + + def wrapper(view): + def view_wrapper(request, *args, **kwargs): + try: + access_token = extract_access_token(request) + if not access_token: + LOGGER.debug("No token passed") + raise BearerTokenError("invalid_token") + + try: + kwargs["token"] = RefreshToken.objects.get( + access_token=access_token + ) + except RefreshToken.DoesNotExist: + LOGGER.debug("Token does not exist", access_token=access_token) + raise BearerTokenError("invalid_token") + + if kwargs["token"].is_expired: + LOGGER.debug("Token has expired", access_token=access_token) + raise BearerTokenError("invalid_token") + + if not set(scopes).issubset(set(kwargs["token"].scope)): + LOGGER.warning( + "Scope missmatch.", + required=set(scopes), + token_has=set(kwargs["token"].scope), + ) + raise BearerTokenError("insufficient_scope") + except BearerTokenError as error: + response = HttpResponse(status=error.status) + response[ + "WWW-Authenticate" + ] = f'error="{error.code}", error_description="{error.description}"' + return response + + return view(request, *args, **kwargs) + + return view_wrapper + + return wrapper + + +def client_id_from_id_token(id_token): + """ + Extracts the client id from a JSON Web Token (JWT). + Returns a string or None. + """ + payload = JWT().unpack(id_token).payload() + aud = payload.get("aud", None) + if aud is None: + return None + if isinstance(aud, list): + return aud[0] + return aud diff --git a/passbook/providers/oauth2/views/__init__.py b/authentik/providers/oauth2/views/__init__.py similarity index 100% rename from passbook/providers/oauth2/views/__init__.py rename to authentik/providers/oauth2/views/__init__.py diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py new file mode 100644 index 00000000..f13bd8c5 --- /dev/null +++ b/authentik/providers/oauth2/views/authorize.py @@ -0,0 +1,382 @@ +"""authentik OAuth2 Authorization views""" +from dataclasses import dataclass, field +from typing import List, Optional, Set +from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit +from uuid import uuid4 + +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone +from structlog import get_logger + +from authentik.audit.models import Event, EventAction +from authentik.core.models import Application +from authentik.flows.models import in_memory_stage +from authentik.flows.planner import ( + PLAN_CONTEXT_APPLICATION, + PLAN_CONTEXT_SSO, + FlowPlan, + FlowPlanner, +) +from authentik.flows.stage import StageView +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.lib.utils.time import timedelta_from_string +from authentik.lib.utils.urls import redirect_with_qs +from authentik.lib.views import bad_request_message +from authentik.policies.views import PolicyAccessView +from authentik.providers.oauth2.constants import ( + PROMPT_CONSNET, + PROMPT_NONE, + SCOPE_OPENID, +) +from authentik.providers.oauth2.errors import ( + AuthorizeError, + ClientIdError, + OAuth2Error, + RedirectUriError, +) +from authentik.providers.oauth2.models import ( + AuthorizationCode, + GrantTypes, + OAuth2Provider, + ResponseTypes, +) +from authentik.providers.oauth2.views.userinfo import UserInfoView +from authentik.stages.consent.models import ConsentMode, ConsentStage +from authentik.stages.consent.stage import ( + PLAN_CONTEXT_CONSENT_TEMPLATE, + ConsentStageView, +) + +LOGGER = get_logger() + +PLAN_CONTEXT_PARAMS = "params" +PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions" + +ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET} + + +@dataclass +class OAuthAuthorizationParams: + """Parameteres required to authorize an OAuth Client""" + + client_id: str + redirect_uri: str + response_type: str + scope: List[str] + state: str + nonce: str + prompt: Set[str] + grant_type: str + + provider: OAuth2Provider = field(default_factory=OAuth2Provider) + + code_challenge: Optional[str] = None + code_challenge_method: Optional[str] = None + + @staticmethod + def from_request(request: HttpRequest) -> "OAuthAuthorizationParams": + """ + Get all the params used by the Authorization Code Flow + (and also for the Implicit and Hybrid). + + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + # Because in this endpoint we handle both GET + # and POST request. + query_dict = request.POST if request.method == "POST" else request.GET + + response_type = query_dict.get("response_type", "") + grant_type = None + # Determine which flow to use. + if response_type in [ResponseTypes.CODE, ResponseTypes.CODE_ADFS]: + grant_type = GrantTypes.AUTHORIZATION_CODE + elif response_type in [ + ResponseTypes.ID_TOKEN, + ResponseTypes.ID_TOKEN_TOKEN, + ResponseTypes.CODE_TOKEN, + ]: + grant_type = GrantTypes.IMPLICIT + elif response_type in [ + ResponseTypes.CODE_TOKEN, + ResponseTypes.CODE_ID_TOKEN, + ResponseTypes.CODE_ID_TOKEN_TOKEN, + ]: + grant_type = GrantTypes.HYBRID + + # Grant type validation. + if not grant_type: + LOGGER.warning("Invalid response type", type=response_type) + raise AuthorizeError( + query_dict.get("redirect_uri", ""), + "unsupported_response_type", + grant_type, + ) + + return OAuthAuthorizationParams( + client_id=query_dict.get("client_id", ""), + redirect_uri=query_dict.get("redirect_uri", ""), + response_type=response_type, + grant_type=grant_type, + scope=query_dict.get("scope", "").split(), + state=query_dict.get("state", ""), + nonce=query_dict.get("nonce", ""), + prompt=ALLOWED_PROMPT_PARAMS.intersection( + set(query_dict.get("prompt", "").split()) + ), + code_challenge=query_dict.get("code_challenge"), + code_challenge_method=query_dict.get("code_challenge_method"), + ) + + def __post_init__(self): + try: + self.provider: OAuth2Provider = OAuth2Provider.objects.get( + client_id=self.client_id + ) + except OAuth2Provider.DoesNotExist: + LOGGER.warning("Invalid client identifier", client_id=self.client_id) + raise ClientIdError() + is_open_id = SCOPE_OPENID in self.scope + + # Redirect URI validation. + if is_open_id and not self.redirect_uri: + LOGGER.warning("Missing redirect uri.") + raise RedirectUriError() + if self.redirect_uri.lower() not in [ + x.lower() for x in self.provider.redirect_uris.split() + ]: + LOGGER.warning( + "Invalid redirect uri", + redirect_uri=self.redirect_uri, + excepted=self.provider.redirect_uris.split(), + ) + raise RedirectUriError() + + if not is_open_id and ( + self.grant_type == GrantTypes.HYBRID + or self.response_type + in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN] + ): + LOGGER.warning("Missing 'openid' scope.") + raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type) + + # Nonce parameter validation. + if is_open_id and self.grant_type == GrantTypes.IMPLICIT and not self.nonce: + raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type) + + # Response type parameter validation. + if is_open_id: + actual_response_type = self.provider.response_type + if "#" in self.provider.response_type: + hash_index = actual_response_type.index("#") + actual_response_type = actual_response_type[:hash_index] + if self.response_type != actual_response_type: + raise AuthorizeError( + self.redirect_uri, "invalid_request", self.grant_type + ) + + # PKCE validation of the transformation method. + if self.code_challenge: + if not (self.code_challenge_method in ["plain", "S256"]): + raise AuthorizeError( + self.redirect_uri, "invalid_request", self.grant_type + ) + + def create_code(self, request: HttpRequest) -> AuthorizationCode: + """Create an AuthorizationCode object for the request""" + code = AuthorizationCode() + code.user = request.user + code.provider = self.provider + + code.code = uuid4().hex + + if self.code_challenge and self.code_challenge_method: + code.code_challenge = self.code_challenge + code.code_challenge_method = self.code_challenge_method + + code.expires_at = timezone.now() + timedelta_from_string( + self.provider.token_validity + ) + code.scope = self.scope + code.nonce = self.nonce + code.is_open_id = SCOPE_OPENID in self.scope + + return code + + +class OAuthFulfillmentStage(StageView): + """Final stage, restores params from Flow.""" + + params: OAuthAuthorizationParams + provider: OAuth2Provider + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.params: OAuthAuthorizationParams = self.executor.plan.context.pop( + PLAN_CONTEXT_PARAMS + ) + application: Application = self.executor.plan.context.pop( + PLAN_CONTEXT_APPLICATION + ) + self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id) + try: + # At this point we don't need to check permissions anymore + if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt): + raise AuthorizeError( + self.params.redirect_uri, + "consent_required", + self.params.grant_type, + ) + Event.new( + EventAction.AUTHORIZE_APPLICATION, + authorized_application=application, + flow=self.executor.plan.flow_pk, + ).from_http(self.request) + return redirect(self.create_response_uri()) + except (ClientIdError, RedirectUriError) as error: + self.executor.stage_invalid() + # pylint: disable=no-member + return bad_request_message(request, error.description, title=error.error) + except AuthorizeError as error: + self.executor.stage_invalid() + uri = error.create_uri(self.params.redirect_uri, self.params.state) + return redirect(uri) + + def create_response_uri(self) -> str: + """Create a final Response URI the user is redirected to.""" + uri = urlsplit(self.params.redirect_uri) + query_params = parse_qs(uri.query) + query_fragment = {} + + try: + code = None + + if self.params.grant_type in [ + GrantTypes.AUTHORIZATION_CODE, + GrantTypes.HYBRID, + ]: + code = self.params.create_code(self.request) + code.save() + + if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE: + query_params["code"] = code.code + query_params["state"] = [ + str(self.params.state) if self.params.state else "" + ] + elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]: + token = self.provider.create_refresh_token( + user=self.request.user, + scope=self.params.scope, + ) + + # Check if response_type must include access_token in the response. + if self.params.response_type in [ + ResponseTypes.ID_TOKEN_TOKEN, + ResponseTypes.CODE_ID_TOKEN_TOKEN, + ResponseTypes.ID_TOKEN, + ResponseTypes.CODE_TOKEN, + ]: + query_fragment["access_token"] = token.access_token + + # We don't need id_token if it's an OAuth2 request. + if SCOPE_OPENID in self.params.scope: + id_token = token.create_id_token( + user=self.request.user, + request=self.request, + ) + id_token.nonce = self.params.nonce + + # Include at_hash when access_token is being returned. + if "access_token" in query_fragment: + id_token.at_hash = token.at_hash + + # Check if response_type must include id_token in the response. + if self.params.response_type in [ + ResponseTypes.ID_TOKEN, + ResponseTypes.ID_TOKEN_TOKEN, + ResponseTypes.CODE_ID_TOKEN, + ResponseTypes.CODE_ID_TOKEN_TOKEN, + ]: + query_fragment["id_token"] = self.provider.encode( + id_token.to_dict() + ) + token.id_token = id_token + + # Store the token. + token.save() + + # Code parameter must be present if it's Hybrid Flow. + if self.params.grant_type == GrantTypes.HYBRID: + query_fragment["code"] = code.code + + query_fragment["token_type"] = "bearer" + query_fragment["expires_in"] = timedelta_from_string( + self.provider.token_validity + ).seconds + query_fragment["state"] = self.params.state if self.params.state else "" + + except OAuth2Error as error: + LOGGER.exception("Error when trying to create response uri", error=error) + raise AuthorizeError( + self.params.redirect_uri, "server_error", self.params.grant_type + ) + + uri = uri._replace( + query=urlencode(query_params, doseq=True), + fragment=uri.fragment + urlencode(query_fragment, doseq=True), + ) + + return urlunsplit(uri) + + +class AuthorizationFlowInitView(PolicyAccessView): + """OAuth2 Flow initializer, checks access to application and starts flow""" + + def resolve_provider_application(self): + client_id = self.request.GET.get("client_id") + self.provider = get_object_or_404(OAuth2Provider, client_id=client_id) + self.application = self.provider.application + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Check access to application, start FlowPLanner, return to flow executor shell""" + # Extract params so we can save them in the plan context + try: + params = OAuthAuthorizationParams.from_request(request) + except (ClientIdError, RedirectUriError) as error: + # pylint: disable=no-member + return bad_request_message(request, error.description, title=error.error) + # Regardless, we start the planner and return to it + planner = FlowPlanner(self.provider.authorization_flow) + # planner.use_cache = False + planner.allow_empty_flows = True + plan: FlowPlan = planner.plan( + self.request, + { + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_APPLICATION: self.application, + # OAuth2 related params + PLAN_CONTEXT_PARAMS: params, + PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions( + params.scope + ), + # Consent related params + PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html", + }, + ) + # OpenID clients can specify a `prompt` parameter, and if its set to consent we + # need to inject a consent stage + if PROMPT_CONSNET in params.prompt: + if not any([isinstance(x, ConsentStageView) for x in plan.stages]): + # Plan does not have any consent stage, so we add an in-memory one + stage = ConsentStage( + name="OAuth2 Provider In-memory consent stage", + mode=ConsentMode.ALWAYS_REQUIRE, + ) + plan.append(stage) + plan.append(in_memory_stage(OAuthFulfillmentStage)) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_flows:flow-executor-shell", + self.request.GET, + flow_slug=self.provider.authorization_flow.slug, + ) diff --git a/authentik/providers/oauth2/views/github.py b/authentik/providers/oauth2/views/github.py new file mode 100644 index 00000000..d9a001c2 --- /dev/null +++ b/authentik/providers/oauth2/views/github.py @@ -0,0 +1,69 @@ +"""authentik pretend GitHub Views""" +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.views import View + +from authentik.providers.oauth2.models import RefreshToken + + +class GitHubUserView(View): + """Emulate GitHub's /user API Endpoint""" + + def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse: + """Emulate GitHub's /user API Endpoint""" + user = token.user + return JsonResponse( + { + "login": user.username, + "id": user.pk, + "node_id": "", + "avatar_url": "", + "gravatar_id": "", + "url": "", + "html_url": "", + "followers_url": "", + "following_url": "", + "gists_url": "", + "starred_url": "", + "subscriptions_url": "", + "organizations_url": "", + "repos_url": "", + "events_url": "", + "received_events_url": "", + "type": "User", + "site_admin": False, + "name": user.name, + "company": "", + "blog": "", + "location": "", + "email": user.email, + "hireable": False, + "bio": "", + "public_repos": 0, + "public_gists": 0, + "followers": 0, + "following": 0, + "created_at": user.date_joined, + "updated_at": user.date_joined, + "private_gists": 0, + "total_private_repos": 0, + "owned_private_repos": 0, + "disk_usage": 0, + "collaborators": 0, + "two_factor_authentication": True, + "plan": { + "name": "None", + "space": 0, + "private_repos": 0, + "collaborators": 0, + }, + } + ) + + +class GitHubUserTeamsView(View): + """Emulate GitHub's /user/teams API Endpoint""" + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse: + """Emulate GitHub's /user/teams API Endpoint""" + return JsonResponse([], safe=False) diff --git a/authentik/providers/oauth2/views/introspection.py b/authentik/providers/oauth2/views/introspection.py new file mode 100644 index 00000000..cb38e6dc --- /dev/null +++ b/authentik/providers/oauth2/views/introspection.py @@ -0,0 +1,124 @@ +"""authentik OAuth2 Token Introspection Views""" +from dataclasses import dataclass, field + +from django.http import HttpRequest, HttpResponse +from django.views import View +from structlog import get_logger + +from authentik.providers.oauth2.errors import TokenIntrospectionError +from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken +from authentik.providers.oauth2.utils import ( + TokenResponse, + extract_access_token, + extract_client_auth, +) + +LOGGER = get_logger() + + +@dataclass +class TokenIntrospectionParams: + """Parameters for Token Introspection""" + + token: RefreshToken + + provider: OAuth2Provider = field(init=False) + id_token: IDToken = field(init=False) + + def __post_init__(self): + if self.token.is_expired: + LOGGER.debug("Token is not valid") + raise TokenIntrospectionError() + + self.provider = self.token.provider + self.id_token = self.token.id_token + + if not self.token.id_token: + LOGGER.debug( + "token not an authentication token", + token=self.token, + ) + raise TokenIntrospectionError() + + def authenticate_basic(self, request: HttpRequest) -> bool: + """Attempt to authenticate via Basic auth of client_id:client_secret""" + client_id, client_secret = extract_client_auth(request) + if client_id == client_secret == "": + return False + if ( + client_id != self.provider.client_id + or client_secret != self.provider.client_secret + ): + LOGGER.debug("(basic) Provider for basic auth does not exist") + raise TokenIntrospectionError() + return True + + def authenticate_bearer(self, request: HttpRequest) -> bool: + """Attempt to authenticate via token sent as bearer header""" + body_token = extract_access_token(request) + if not body_token: + return False + tokens = RefreshToken.objects.filter(access_token=body_token).select_related( + "provider" + ) + if not tokens.exists(): + LOGGER.debug("(bearer) Token does not exist") + raise TokenIntrospectionError() + if tokens.first().provider != self.provider: + LOGGER.debug("(bearer) Token providers don't match") + raise TokenIntrospectionError() + return True + + @staticmethod + def from_request(request: HttpRequest) -> "TokenIntrospectionParams": + """Extract required Parameters from HTTP Request""" + raw_token = request.POST.get("token") + token_type_hint = request.POST.get("token_type_hint", "access_token") + token_filter = {token_type_hint: raw_token} + + if token_type_hint not in ["access_token", "refresh_token"]: + LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) + raise TokenIntrospectionError() + + try: + token: RefreshToken = RefreshToken.objects.select_related("provider").get( + **token_filter + ) + except RefreshToken.DoesNotExist: + LOGGER.debug("Token does not exist", token=raw_token) + raise TokenIntrospectionError() + + params = TokenIntrospectionParams(token=token) + if not any( + [params.authenticate_basic(request), params.authenticate_bearer(request)] + ): + LOGGER.debug("Not authenticated") + raise TokenIntrospectionError() + return params + + +class TokenIntrospectionView(View): + """Token Introspection + https://tools.ietf.org/html/rfc7662""" + + token: RefreshToken + params: TokenIntrospectionParams + provider: OAuth2Provider + id_token: IDToken + + def post(self, request: HttpRequest) -> HttpResponse: + """Introspection handler""" + try: + self.params = TokenIntrospectionParams.from_request(request) + + response_dic = {} + if self.params.id_token: + token_dict = self.params.id_token.to_dict() + for k in ("aud", "sub", "exp", "iat", "iss"): + response_dic[k] = token_dict[k] + response_dic["active"] = True + response_dic["client_id"] = self.params.token.provider.client_id + + return TokenResponse(response_dic) + except TokenIntrospectionError: + return TokenResponse({"active": False}) diff --git a/authentik/providers/oauth2/views/jwks.py b/authentik/providers/oauth2/views/jwks.py new file mode 100644 index 00000000..b87a3322 --- /dev/null +++ b/authentik/providers/oauth2/views/jwks.py @@ -0,0 +1,40 @@ +"""authentik OAuth2 JWKS Views""" +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.views import View +from jwkest import long_to_base64 +from jwkest.jwk import import_rsa_key + +from authentik.core.models import Application +from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider + + +class JWKSView(View): + """Show RSA Key data for Provider""" + + def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: + """Show RSA Key data for Provider""" + application = get_object_or_404(Application, slug=application_slug) + provider: OAuth2Provider = get_object_or_404( + OAuth2Provider, pk=application.provider_id + ) + + response_data = {} + + if provider.jwt_alg == JWTAlgorithms.RS256: + public_key = import_rsa_key(provider.rsa_key.key_data).publickey() + response_data["keys"] = [ + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": provider.rsa_key.kid, + "n": long_to_base64(public_key.n), + "e": long_to_base64(public_key.e), + } + ] + + response = JsonResponse(response_data) + response["Access-Control-Allow-Origin"] = "*" + + return response diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py new file mode 100644 index 00000000..704f85ed --- /dev/null +++ b/authentik/providers/oauth2/views/provider.py @@ -0,0 +1,74 @@ +"""authentik OAuth2 OpenID well-known views""" +from typing import Any, Dict + +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, reverse +from django.views import View +from structlog import get_logger + +from authentik.core.models import Application +from authentik.providers.oauth2.models import OAuth2Provider + +LOGGER = get_logger() + +PLAN_CONTEXT_PARAMS = "params" +PLAN_CONTEXT_SCOPES = "scopes" + + +class ProviderInfoView(View): + """OpenID-compliant Provider Info""" + + def get_info(self, provider: OAuth2Provider) -> Dict[str, Any]: + """Get dictionary for OpenID Connect information""" + return { + "issuer": provider.get_issuer(self.request), + "authorization_endpoint": self.request.build_absolute_uri( + reverse("authentik_providers_oauth2:authorize") + ), + "token_endpoint": self.request.build_absolute_uri( + reverse("authentik_providers_oauth2:token") + ), + "userinfo_endpoint": self.request.build_absolute_uri( + reverse("authentik_providers_oauth2:userinfo") + ), + "end_session_endpoint": self.request.build_absolute_uri( + reverse( + "authentik_providers_oauth2:end-session", + kwargs={"application_slug": provider.application.slug}, + ) + ), + "introspection_endpoint": self.request.build_absolute_uri( + reverse("authentik_providers_oauth2:token-introspection") + ), + "response_types_supported": [provider.response_type], + "jwks_uri": self.request.build_absolute_uri( + reverse( + "authentik_providers_oauth2:jwks", + kwargs={"application_slug": provider.application.slug}, + ) + ), + "id_token_signing_alg_values_supported": [provider.jwt_alg], + # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes + "subject_types_supported": ["public"], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + ], + } + + # pylint: disable=unused-argument + def get( + self, request: HttpRequest, application_slug: str, *args, **kwargs + ) -> HttpResponse: + """OpenID-compliant Provider Info""" + + application = get_object_or_404(Application, slug=application_slug) + provider: OAuth2Provider = get_object_or_404( + OAuth2Provider, pk=application.provider_id + ) + response = JsonResponse( + self.get_info(provider), json_dumps_params={"indent": 2} + ) + response["Access-Control-Allow-Origin"] = "*" + + return response diff --git a/authentik/providers/oauth2/views/session.py b/authentik/providers/oauth2/views/session.py new file mode 100644 index 00000000..332a9b87 --- /dev/null +++ b/authentik/providers/oauth2/views/session.py @@ -0,0 +1,22 @@ +"""authentik OAuth2 Session Views""" +from typing import Any, Dict + +from django.shortcuts import get_object_or_404 +from django.views.generic.base import TemplateView + +from authentik.core.models import Application + + +class EndSessionView(TemplateView): + """Allow the client to end the Session""" + + template_name = "providers/oauth2/end_session.html" + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + + context["application"] = get_object_or_404( + Application, slug=self.kwargs["application_slug"] + ) + + return context diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py new file mode 100644 index 00000000..a1b9740f --- /dev/null +++ b/authentik/providers/oauth2/views/token.py @@ -0,0 +1,256 @@ +"""authentik OAuth2 Token views""" +from base64 import urlsafe_b64encode +from dataclasses import InitVar, dataclass +from hashlib import sha256 +from typing import Any, Dict, List, Optional + +from django.http import HttpRequest, HttpResponse +from django.views import View +from structlog import get_logger + +from authentik.lib.utils.time import timedelta_from_string +from authentik.providers.oauth2.constants import ( + GRANT_TYPE_AUTHORIZATION_CODE, + GRANT_TYPE_REFRESH_TOKEN, +) +from authentik.providers.oauth2.errors import TokenError, UserAuthError +from authentik.providers.oauth2.models import ( + AuthorizationCode, + OAuth2Provider, + RefreshToken, + ResponseTypes, +) +from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth + +LOGGER = get_logger() + + +@dataclass +class TokenParams: + """Token params""" + + client_id: str + client_secret: str + redirect_uri: str + grant_type: str + state: str + scope: List[str] + + authorization_code: Optional[AuthorizationCode] = None + refresh_token: Optional[RefreshToken] = None + + code_verifier: Optional[str] = None + + raw_code: InitVar[str] = "" + raw_token: InitVar[str] = "" + + @staticmethod + def from_request(request: HttpRequest) -> "TokenParams": + """Extract Token Parameters from http request""" + client_id, client_secret = extract_client_auth(request) + + return TokenParams( + client_id=client_id, + client_secret=client_secret, + redirect_uri=request.POST.get("redirect_uri", ""), + grant_type=request.POST.get("grant_type", ""), + raw_code=request.POST.get("code", ""), + raw_token=request.POST.get("refresh_token", ""), + state=request.POST.get("state", ""), + scope=request.POST.get("scope", "").split(), + # PKCE parameter. + code_verifier=request.POST.get("code_verifier"), + ) + + def __post_init__(self, raw_code, raw_token): + try: + provider: OAuth2Provider = OAuth2Provider.objects.get( + client_id=self.client_id + ) + self.provider = provider + except OAuth2Provider.DoesNotExist: + LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id) + raise TokenError("invalid_client") + + if self.provider.client_type == "confidential": + if self.provider.client_secret != self.client_secret: + LOGGER.warning( + "Invalid client secret: client does not have secret", + client_id=self.provider.client_id, + secret=self.provider.client_secret, + ) + raise TokenError("invalid_client") + + if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: + self.__post_init_code(raw_code) + + elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN: + if not raw_token: + LOGGER.warning("Missing refresh token") + raise TokenError("invalid_grant") + + try: + self.refresh_token = RefreshToken.objects.get( + refresh_token=raw_token, provider=self.provider + ) + + except RefreshToken.DoesNotExist: + LOGGER.warning( + "Refresh token does not exist", + token=raw_token, + ) + raise TokenError("invalid_grant") + + else: + LOGGER.warning("Invalid grant type", grant_type=self.grant_type) + raise TokenError("unsupported_grant_type") + + def __post_init_code(self, raw_code): + if not raw_code: + LOGGER.warning("Missing authorization code") + raise TokenError("invalid_grant") + + if self.redirect_uri not in self.provider.redirect_uris.split(): + LOGGER.warning( + "Invalid redirect uri", + uri=self.redirect_uri, + expected=self.provider.redirect_uris.split(), + ) + raise TokenError("invalid_client") + + try: + self.authorization_code = AuthorizationCode.objects.get(code=raw_code) + except AuthorizationCode.DoesNotExist: + LOGGER.warning("Code does not exist", code=raw_code) + raise TokenError("invalid_grant") + + if ( + self.authorization_code.provider != self.provider + or self.authorization_code.is_expired + ): + LOGGER.warning("Invalid code: invalid client or code has expired") + raise TokenError("invalid_grant") + + # Validate PKCE parameters. + if self.code_verifier: + if self.authorization_code.code_challenge_method == "S256": + new_code_challenge = ( + urlsafe_b64encode( + sha256(self.code_verifier.encode("ascii")).digest() + ) + .decode("utf-8") + .replace("=", "") + ) + else: + new_code_challenge = self.code_verifier + + if new_code_challenge != self.authorization_code.code_challenge: + LOGGER.warning("Code challenge not matching") + raise TokenError("invalid_grant") + + +class TokenView(View): + """Generate tokens for clients""" + + params: TokenParams + + def post(self, request: HttpRequest) -> HttpResponse: + """Generate tokens for clients""" + try: + self.params = TokenParams.from_request(request) + + if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: + return TokenResponse(self.create_code_response_dic()) + if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: + return TokenResponse(self.create_refresh_response_dic()) + raise ValueError(f"Invalid grant_type: {self.params.grant_type}") + except TokenError as error: + return TokenResponse(error.create_dict(), status=400) + except UserAuthError as error: + return TokenResponse(error.create_dict(), status=403) + + def create_code_response_dic(self) -> Dict[str, Any]: + """See https://tools.ietf.org/html/rfc6749#section-4.1""" + + refresh_token = self.params.authorization_code.provider.create_refresh_token( + user=self.params.authorization_code.user, + scope=self.params.authorization_code.scope, + ) + + if self.params.authorization_code.is_open_id: + id_token = refresh_token.create_id_token( + user=self.params.authorization_code.user, + request=self.request, + ) + id_token.nonce = self.params.authorization_code.nonce + id_token.at_hash = refresh_token.at_hash + refresh_token.id_token = id_token + + # Store the token. + refresh_token.save() + + # We don't need to store the code anymore. + self.params.authorization_code.delete() + + response_dict = { + "access_token": refresh_token.access_token, + "refresh_token": refresh_token.refresh_token, + "token_type": "Bearer", + "expires_in": timedelta_from_string( + self.params.provider.token_validity + ).seconds, + "id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()), + } + + if self.params.provider.response_type == ResponseTypes.CODE_ADFS: + # This seems to be expected by some OIDC Clients + # namely VMware vCenter. This is not documented in any OpenID or OAuth2 Standard. + # Maybe this should be a setting + # in the future? + response_dict["access_token"] = response_dict["id_token"] + + return response_dict + + def create_refresh_response_dic(self) -> Dict[str, Any]: + """See https://tools.ietf.org/html/rfc6749#section-6""" + + unauthorized_scopes = set(self.params.scope) - set( + self.params.refresh_token.scope + ) + if unauthorized_scopes: + raise TokenError("invalid_scope") + + provider: OAuth2Provider = self.params.refresh_token.provider + + refresh_token: RefreshToken = provider.create_refresh_token( + user=self.params.refresh_token.user, + scope=self.params.scope, + ) + + # If the Token has an id_token it's an Authentication request. + if self.params.refresh_token.id_token: + refresh_token.id_token = refresh_token.create_id_token( + user=self.params.refresh_token.user, + request=self.request, + ) + refresh_token.id_token.at_hash = refresh_token.at_hash + + # Store the refresh_token. + refresh_token.save() + + # Forget the old token. + self.params.refresh_token.delete() + + dic = { + "access_token": refresh_token.access_token, + "refresh_token": refresh_token.refresh_token, + "token_type": "bearer", + "expires_in": timedelta_from_string( + refresh_token.provider.token_validity + ).seconds, + "id_token": self.params.provider.encode( + self.params.refresh_token.id_token.to_dict() + ), + } + + return dic diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py new file mode 100644 index 00000000..d0f0f542 --- /dev/null +++ b/authentik/providers/oauth2/views/userinfo.py @@ -0,0 +1,92 @@ +"""authentik OAuth2 OpenID Userinfo views""" +from typing import Any, Dict, List + +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext_lazy as _ +from django.views import View +from structlog import get_logger + +from authentik.providers.oauth2.constants import ( + SCOPE_GITHUB_ORG_READ, + SCOPE_GITHUB_USER, + SCOPE_GITHUB_USER_EMAIL, + SCOPE_GITHUB_USER_READ, +) +from authentik.providers.oauth2.models import RefreshToken, ScopeMapping +from authentik.providers.oauth2.utils import TokenResponse, cors_allow_any + +LOGGER = get_logger() + + +class UserInfoView(View): + """Create a dictionary with all the requested claims about the End-User. + See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse""" + + def get_scope_descriptions(self, scopes: List[str]) -> Dict[str, str]: + """Get a list of all Scopes's descriptions""" + scope_descriptions = {} + for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by( + "scope_name" + ): + if scope.description != "": + scope_descriptions[scope.scope_name] = scope.description + # GitHub Compatibility Scopes are handeled differently, since they required custom paths + # Hence they don't exist as Scope objects + github_scope_map = { + SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"), + SCOPE_GITHUB_USER_READ: _( + "GitHub Compatibility: Access your User Information" + ), + SCOPE_GITHUB_USER_EMAIL: _( + "GitHub Compatibility: Access you Email addresses" + ), + SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"), + } + for scope in scopes: + if scope in github_scope_map: + scope_descriptions[scope] = github_scope_map[scope] + return scope_descriptions + + def get_claims(self, token: RefreshToken) -> Dict[str, Any]: + """Get a dictionary of claims from scopes that the token + requires and are assigned to the provider.""" + + scopes_from_client = token.scope + final_claims = {} + for scope in ScopeMapping.objects.filter( + provider=token.provider, scope_name__in=scopes_from_client + ).order_by("scope_name"): + value = scope.evaluate( + user=token.user, + request=self.request, + provider=token.provider, + token=token, + ) + if value is None: + continue + if not isinstance(value, dict): + LOGGER.warning( + "Scope returned a non-dict value, ignoring", + scope=scope, + value=value, + ) + continue + LOGGER.debug("updated scope", scope=scope) + final_claims.update(value) + return final_claims + + def options(self, request: HttpRequest) -> HttpResponse: + return cors_allow_any(self.request, TokenResponse({})) + + def get(self, request: HttpRequest, **kwargs) -> HttpResponse: + """Handle GET Requests for UserInfo""" + token: RefreshToken = kwargs["token"] + claims = self.get_claims(token) + claims["sub"] = token.id_token.sub + response = TokenResponse(claims) + cors_allow_any(self.request, response) + return response + + def post(self, request: HttpRequest, **kwargs) -> HttpResponse: + """POST Requests behave the same as GET Requests, so the get handler is called here""" + return self.get(request, **kwargs) diff --git a/passbook/providers/proxy/__init__.py b/authentik/providers/proxy/__init__.py similarity index 100% rename from passbook/providers/proxy/__init__.py rename to authentik/providers/proxy/__init__.py diff --git a/authentik/providers/proxy/api.py b/authentik/providers/proxy/api.py new file mode 100644 index 00000000..51377c0a --- /dev/null +++ b/authentik/providers/proxy/api.py @@ -0,0 +1,118 @@ +"""ProxyProvider API Views""" +from drf_yasg2.utils import swagger_serializer_method +from rest_framework.fields import CharField, ListField, SerializerMethodField +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer, Serializer +from rest_framework.viewsets import ModelViewSet + +from authentik.providers.oauth2.views.provider import ProviderInfoView +from authentik.providers.proxy.models import ProxyProvider + + +class OpenIDConnectConfigurationSerializer(Serializer): + """rest_framework Serializer for OIDC Configuration""" + + issuer = CharField() + authorization_endpoint = CharField() + token_endpoint = CharField() + userinfo_endpoint = CharField() + end_session_endpoint = CharField() + introspection_endpoint = CharField() + jwks_uri = CharField() + + response_types_supported = ListField(child=CharField()) + id_token_signing_alg_values_supported = ListField(child=CharField()) + subject_types_supported = ListField(child=CharField()) + token_endpoint_auth_methods_supported = ListField(child=CharField()) + + def create(self, request: Request) -> Response: + raise NotImplementedError + + def update(self, request: Request) -> Response: + raise NotImplementedError + + +class ProxyProviderSerializer(ModelSerializer): + """ProxyProvider Serializer""" + + def create(self, validated_data): + instance: ProxyProvider = super().create(validated_data) + instance.set_oauth_defaults() + instance.save() + return instance + + def update(self, instance: ProxyProvider, validated_data): + instance.set_oauth_defaults() + return super().update(instance, validated_data) + + class Meta: + + model = ProxyProvider + fields = [ + "pk", + "name", + "internal_host", + "external_host", + "internal_host_ssl_validation", + "certificate", + "skip_path_regex", + "basic_auth_enabled", + "basic_auth_password_attribute", + "basic_auth_user_attribute", + ] + + +class ProxyProviderViewSet(ModelViewSet): + """ProxyProvider Viewset""" + + queryset = ProxyProvider.objects.all() + serializer_class = ProxyProviderSerializer + + +class ProxyOutpostConfigSerializer(ModelSerializer): + """ProxyProvider Serializer""" + + oidc_configuration = SerializerMethodField() + + def create(self, validated_data): + instance: ProxyProvider = super().create(validated_data) + instance.set_oauth_defaults() + instance.save() + return instance + + def update(self, instance: ProxyProvider, validated_data): + instance.set_oauth_defaults() + return super().update(instance, validated_data) + + class Meta: + + model = ProxyProvider + fields = [ + "pk", + "name", + "internal_host", + "external_host", + "internal_host_ssl_validation", + "client_id", + "client_secret", + "oidc_configuration", + "cookie_secret", + "certificate", + "skip_path_regex", + "basic_auth_enabled", + "basic_auth_password_attribute", + "basic_auth_user_attribute", + ] + + @swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer) + def get_oidc_configuration(self, obj: ProxyProvider): + """Embed OpenID Connect provider information""" + return ProviderInfoView(request=self.context["request"]._request).get_info(obj) + + +class ProxyOutpostConfigViewSet(ModelViewSet): + """ProxyProvider Viewset""" + + queryset = ProxyProvider.objects.filter(application__isnull=False) + serializer_class = ProxyOutpostConfigSerializer diff --git a/authentik/providers/proxy/apps.py b/authentik/providers/proxy/apps.py new file mode 100644 index 00000000..ef7d2dd6 --- /dev/null +++ b/authentik/providers/proxy/apps.py @@ -0,0 +1,10 @@ +"""authentik Proxy app""" +from django.apps import AppConfig + + +class AuthentikProviderProxyConfig(AppConfig): + """authentik proxy app""" + + name = "authentik.providers.proxy" + label = "authentik_providers_proxy" + verbose_name = "authentik Providers.Proxy" diff --git a/passbook/providers/proxy/controllers/__init__.py b/authentik/providers/proxy/controllers/__init__.py similarity index 100% rename from passbook/providers/proxy/controllers/__init__.py rename to authentik/providers/proxy/controllers/__init__.py diff --git a/authentik/providers/proxy/controllers/docker.py b/authentik/providers/proxy/controllers/docker.py new file mode 100644 index 00000000..920c76b2 --- /dev/null +++ b/authentik/providers/proxy/controllers/docker.py @@ -0,0 +1,34 @@ +"""Proxy Provider Docker Contoller""" +from typing import Dict +from urllib.parse import urlparse + +from authentik.outposts.controllers.docker import DockerController +from authentik.outposts.models import DockerServiceConnection, Outpost +from authentik.providers.proxy.models import ProxyProvider + + +class ProxyDockerController(DockerController): + """Proxy Provider Docker Contoller""" + + def __init__(self, outpost: Outpost, connection: DockerServiceConnection): + super().__init__(outpost, connection) + self.deployment_ports = { + "http": 4180, + "https": 4443, + } + + def _get_labels(self) -> Dict[str, str]: + hosts = [] + for proxy_provider in ProxyProvider.objects.filter(outpost__in=[self.outpost]): + proxy_provider: ProxyProvider + external_host_name = urlparse(proxy_provider.external_host) + hosts.append(f"`{external_host_name}`") + traefik_name = f"ak-outpost-{self.outpost.pk.hex}" + return { + "traefik.enable": "true", + f"traefik.http.routers.{traefik_name}-router.rule": f"Host({','.join(hosts)})", + f"traefik.http.routers.{traefik_name}-router.tls": "true", + f"traefik.http.routers.{traefik_name}-router.service": f"{traefik_name}-service", + f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path": "/", + f"traefik.http.services.{traefik_name}-service.loadbalancer.server.port": "4180", + } diff --git a/passbook/providers/proxy/controllers/k8s/__init__.py b/authentik/providers/proxy/controllers/k8s/__init__.py similarity index 100% rename from passbook/providers/proxy/controllers/k8s/__init__.py rename to authentik/providers/proxy/controllers/k8s/__init__.py diff --git a/authentik/providers/proxy/controllers/k8s/ingress.py b/authentik/providers/proxy/controllers/k8s/ingress.py new file mode 100644 index 00000000..ea4de5ac --- /dev/null +++ b/authentik/providers/proxy/controllers/k8s/ingress.py @@ -0,0 +1,140 @@ +"""Kubernetes Ingress Reconciler""" +from typing import TYPE_CHECKING, Dict +from urllib.parse import urlparse + +from kubernetes.client import ( + NetworkingV1beta1Api, + NetworkingV1beta1HTTPIngressPath, + NetworkingV1beta1HTTPIngressRuleValue, + NetworkingV1beta1Ingress, + NetworkingV1beta1IngressBackend, + NetworkingV1beta1IngressSpec, + NetworkingV1beta1IngressTLS, +) +from kubernetes.client.models.networking_v1beta1_ingress_rule import ( + NetworkingV1beta1IngressRule, +) + +from authentik.outposts.controllers.k8s.base import ( + KubernetesObjectReconciler, + NeedsUpdate, +) +from authentik.providers.proxy.models import ProxyProvider + +if TYPE_CHECKING: + from authentik.outposts.controllers.kubernetes import KubernetesController + + +class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): + """Kubernetes Ingress Reconciler""" + + def __init__(self, controller: "KubernetesController") -> None: + super().__init__(controller) + self.api = NetworkingV1beta1Api(controller.client) + + @property + def name(self) -> str: + return f"authentik-outpost-{self.controller.outpost.uuid.hex}" + + def reconcile( + self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress + ): + # Create a list of all expected host and tls hosts + expected_hosts = [] + expected_hosts_tls = [] + for proxy_provider in ProxyProvider.objects.filter( + outpost__in=[self.controller.outpost] + ): + proxy_provider: ProxyProvider + external_host_name = urlparse(proxy_provider.external_host) + expected_hosts.append(external_host_name.hostname) + if external_host_name.scheme == "https": + expected_hosts_tls.append(external_host_name.hostname) + expected_hosts.sort() + expected_hosts_tls.sort() + + have_hosts = [rule.host for rule in reference.spec.rules] + have_hosts.sort() + + have_hosts_tls = [] + for tls_config in reference.spec.tls: + if tls_config: + have_hosts_tls += tls_config.hosts + have_hosts_tls.sort() + + if have_hosts != expected_hosts: + raise NeedsUpdate() + if have_hosts_tls != expected_hosts_tls: + raise NeedsUpdate() + + def get_ingress_annotations(self) -> Dict[str, str]: + """Get ingress annotations""" + annotations = { + # Ensure that with multiple proxy replicas deployed, the same CSRF request + # goes to the same pod + "nginx.ingress.kubernetes.io/affinity": "cookie", + "traefik.ingress.kubernetes.io/affinity": "true", + } + annotations.update( + self.controller.outpost.config.kubernetes_ingress_annotations + ) + return dict() + + def get_reference_object(self) -> NetworkingV1beta1Ingress: + """Get deployment object for outpost""" + meta = self.get_object_meta( + name=self.name, + annotations=self.get_ingress_annotations(), + ) + rules = [] + tls_hosts = [] + for proxy_provider in ProxyProvider.objects.filter( + outpost__in=[self.controller.outpost] + ): + proxy_provider: ProxyProvider + external_host_name = urlparse(proxy_provider.external_host) + if external_host_name.scheme == "https": + tls_hosts.append(external_host_name.hostname) + rule = NetworkingV1beta1IngressRule( + host=external_host_name.hostname, + http=NetworkingV1beta1HTTPIngressRuleValue( + paths=[ + NetworkingV1beta1HTTPIngressPath( + backend=NetworkingV1beta1IngressBackend( + service_name=self.name, + service_port=self.controller.deployment_ports["http"], + ), + path="/", + ) + ] + ), + ) + rules.append(rule) + tls_config = None + if tls_hosts: + tls_config = NetworkingV1beta1IngressTLS( + hosts=tls_hosts, + secret_name=self.controller.outpost.config.kubernetes_ingress_secret_name, + ) + return NetworkingV1beta1Ingress( + metadata=meta, + spec=NetworkingV1beta1IngressSpec(rules=rules, tls=[tls_config]), + ) + + def create(self, reference: NetworkingV1beta1Ingress): + return self.api.create_namespaced_ingress(self.namespace, reference) + + def delete(self, reference: NetworkingV1beta1Ingress): + return self.api.delete_namespaced_ingress( + reference.metadata.name, self.namespace + ) + + def retrieve(self) -> NetworkingV1beta1Ingress: + return self.api.read_namespaced_ingress(self.name, self.namespace) + + def update( + self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress + ): + return self.api.patch_namespaced_ingress( + current.metadata.name, self.namespace, reference + ) diff --git a/authentik/providers/proxy/controllers/kubernetes.py b/authentik/providers/proxy/controllers/kubernetes.py new file mode 100644 index 00000000..9cee34ae --- /dev/null +++ b/authentik/providers/proxy/controllers/kubernetes.py @@ -0,0 +1,17 @@ +"""Proxy Provider Kubernetes Contoller""" +from authentik.outposts.controllers.kubernetes import KubernetesController +from authentik.outposts.models import KubernetesServiceConnection, Outpost +from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler + + +class ProxyKubernetesController(KubernetesController): + """Proxy Provider Kubernetes Contoller""" + + def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): + super().__init__(outpost, connection) + self.deployment_ports = { + "http": 4180, + "https": 4443, + } + self.reconcilers["ingress"] = IngressReconciler + self.reconcile_order.append("ingress") diff --git a/authentik/providers/proxy/forms.py b/authentik/providers/proxy/forms.py new file mode 100644 index 00000000..a8371510 --- /dev/null +++ b/authentik/providers/proxy/forms.py @@ -0,0 +1,50 @@ +"""authentik Proxy Provider Forms""" +from django import forms + +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow, FlowDesignation +from authentik.providers.proxy.models import ProxyProvider + + +class ProxyProviderForm(forms.ModelForm): + """Security Gateway Provider form""" + + instance: ProxyProvider + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["authorization_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.AUTHORIZATION + ) + self.fields["certificate"].queryset = CertificateKeyPair.objects.filter( + key_data__isnull=False + ) + + def save(self, *args, **kwargs): + actual_save = super().save(*args, **kwargs) + self.instance.set_oauth_defaults() + self.instance.save() + return actual_save + + class Meta: + + model = ProxyProvider + fields = [ + "name", + "authorization_flow", + "internal_host", + "internal_host_ssl_validation", + "external_host", + "certificate", + "skip_path_regex", + "basic_auth_enabled", + "basic_auth_user_attribute", + "basic_auth_password_attribute", + ] + widgets = { + "name": forms.TextInput(), + "internal_host": forms.TextInput(), + "external_host": forms.TextInput(), + "basic_auth_user_attribute": forms.TextInput(), + "basic_auth_password_attribute": forms.TextInput(), + } diff --git a/authentik/providers/proxy/migrations/0001_initial.py b/authentik/providers/proxy/migrations/0001_initial.py new file mode 100644 index 00000000..873690d2 --- /dev/null +++ b/authentik/providers/proxy/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 3.1 on 2020-08-18 18:16 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_providers_oauth2", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="ProxyProvider", + fields=[ + ( + "oauth2provider_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_providers_oauth2.oauth2provider", + ), + ), + ( + "internal_host", + models.TextField( + validators=[ + django.core.validators.URLValidator( + schemes=("http", "https") + ) + ] + ), + ), + ( + "external_host", + models.TextField( + validators=[ + django.core.validators.URLValidator( + schemes=("http", "https") + ) + ] + ), + ), + ], + options={ + "verbose_name": "Proxy Provider", + "verbose_name_plural": "Proxy Providers", + }, + bases=("authentik_providers_oauth2.oauth2provider",), + ), + ] diff --git a/authentik/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py b/authentik/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py new file mode 100644 index 00000000..bcf25c25 --- /dev/null +++ b/authentik/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1 on 2020-08-19 14:50 + +from django.db import migrations, models + +import authentik.providers.proxy.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_proxy", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="proxyprovider", + name="cookie_secret", + field=models.TextField( + default=authentik.providers.proxy.models.get_cookie_secret + ), + ), + ] diff --git a/authentik/providers/proxy/migrations/0003_proxyprovider_certificate.py b/authentik/providers/proxy/migrations/0003_proxyprovider_certificate.py new file mode 100644 index 00000000..cbdbb862 --- /dev/null +++ b/authentik/providers/proxy/migrations/0003_proxyprovider_certificate.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2020-08-23 22:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0002_create_self_signed_kp"), + ("authentik_providers_proxy", "0002_proxyprovider_cookie_secret"), + ] + + operations = [ + migrations.AddField( + model_name="proxyprovider", + name="certificate", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_crypto.certificatekeypair", + ), + ), + ] diff --git a/authentik/providers/proxy/migrations/0004_auto_20200913_1947.py b/authentik/providers/proxy/migrations/0004_auto_20200913_1947.py new file mode 100644 index 00000000..34426eaf --- /dev/null +++ b/authentik/providers/proxy/migrations/0004_auto_20200913_1947.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.1 on 2020-09-13 19:47 + +from django.db import migrations, models + +import authentik.lib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_proxy", "0003_proxyprovider_certificate"), + ] + + operations = [ + migrations.AlterField( + model_name="proxyprovider", + name="external_host", + field=models.TextField( + validators=[ + authentik.lib.models.DomainlessURLValidator( + schemes=("http", "https") + ) + ] + ), + ), + migrations.AlterField( + model_name="proxyprovider", + name="internal_host", + field=models.TextField( + validators=[ + authentik.lib.models.DomainlessURLValidator( + schemes=("http", "https") + ) + ] + ), + ), + ] diff --git a/authentik/providers/proxy/migrations/0005_auto_20200914_1536.py b/authentik/providers/proxy/migrations/0005_auto_20200914_1536.py new file mode 100644 index 00000000..0e8ec244 --- /dev/null +++ b/authentik/providers/proxy/migrations/0005_auto_20200914_1536.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.1 on 2020-09-14 15:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0002_create_self_signed_kp"), + ("authentik_providers_proxy", "0004_auto_20200913_1947"), + ] + + operations = [ + migrations.AlterField( + model_name="proxyprovider", + name="certificate", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_crypto.certificatekeypair", + ), + ), + ] diff --git a/authentik/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py b/authentik/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py new file mode 100644 index 00000000..de7bd7d5 --- /dev/null +++ b/authentik/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.1 on 2020-09-19 09:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_proxy", "0005_auto_20200914_1536"), + ] + + operations = [ + migrations.AddField( + model_name="proxyprovider", + name="skip_path_regex", + field=models.TextField( + blank=True, + default="", + help_text="Regular expression for which authentication is not required. Each new line is interpreted as a new Regular Expression.", + ), + ), + ] diff --git a/authentik/providers/proxy/migrations/0007_auto_20200923_1017.py b/authentik/providers/proxy/migrations/0007_auto_20200923_1017.py new file mode 100644 index 00000000..722bb87e --- /dev/null +++ b/authentik/providers/proxy/migrations/0007_auto_20200923_1017.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.1 on 2020-09-23 10:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_proxy", "0006_proxyprovider_skip_path_regex"), + ] + + operations = [ + migrations.AddField( + model_name="proxyprovider", + name="internal_host_ssl_validation", + field=models.BooleanField( + default=True, help_text="Validate SSL Certificates of upstream servers" + ), + ), + migrations.AlterField( + model_name="proxyprovider", + name="skip_path_regex", + field=models.TextField( + blank=True, + default="", + help_text="Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression.", + ), + ), + ] diff --git a/authentik/providers/proxy/migrations/0008_auto_20200930_0810.py b/authentik/providers/proxy/migrations/0008_auto_20200930_0810.py new file mode 100644 index 00000000..444ba2c7 --- /dev/null +++ b/authentik/providers/proxy/migrations/0008_auto_20200930_0810.py @@ -0,0 +1,78 @@ +# Generated by Django 3.1.1 on 2020-09-30 08:10 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +SCOPE_AK_PROXY_EXPRESSION = """return { + "ak_proxy": { + "user_attributes": user.group_attributes() + } +}""" + + +def create_proxy_scope(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + from authentik.providers.proxy.models import SCOPE_AK_PROXY, ProxyProvider + + ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping") + + ScopeMapping.objects.update_or_create( + scope_name=SCOPE_AK_PROXY, + defaults={ + "name": "Autogenerated OAuth2 Mapping: authentik Proxy", + "scope_name": SCOPE_AK_PROXY, + "description": "", + "expression": SCOPE_AK_PROXY_EXPRESSION, + }, + ) + + for provider in ProxyProvider.objects.all(): + provider.set_oauth_defaults() + provider.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_proxy", "0007_auto_20200923_1017"), + ] + + operations = [ + migrations.AlterField( + model_name="proxyprovider", + name="internal_host_ssl_validation", + field=models.BooleanField( + default=True, + help_text="Validate SSL Certificates of upstream servers", + verbose_name="Internal host SSL Validation", + ), + ), + migrations.AddField( + model_name="proxyprovider", + name="basic_auth_enabled", + field=models.BooleanField( + default=False, + help_text="Set a custom HTTP-Basic Authentication header based on values from authentik.", + verbose_name="Set HTTP-Basic Authentication", + ), + ), + migrations.AddField( + model_name="proxyprovider", + name="basic_auth_password_attribute", + field=models.TextField( + blank=True, + help_text="User Attribute used for the password part of the HTTP-Basic Header.", + verbose_name="HTTP-Basic Password", + ), + ), + migrations.AddField( + model_name="proxyprovider", + name="basic_auth_user_attribute", + field=models.TextField( + blank=True, + help_text="User Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.", + verbose_name="HTTP-Basic Username", + ), + ), + migrations.RunPython(create_proxy_scope), + ] diff --git a/authentik/providers/proxy/migrations/0009_auto_20201007_1721.py b/authentik/providers/proxy/migrations/0009_auto_20201007_1721.py new file mode 100644 index 00000000..94c6e2a3 --- /dev/null +++ b/authentik/providers/proxy/migrations/0009_auto_20201007_1721.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.2 on 2020-10-07 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_proxy", "0008_auto_20200930_0810"), + ] + + operations = [ + migrations.AlterField( + model_name="proxyprovider", + name="basic_auth_password_attribute", + field=models.TextField( + blank=True, + help_text="User/Group Attribute used for the password part of the HTTP-Basic Header.", + verbose_name="HTTP-Basic Password Key", + ), + ), + migrations.AlterField( + model_name="proxyprovider", + name="basic_auth_user_attribute", + field=models.TextField( + blank=True, + help_text="User/Group Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.", + verbose_name="HTTP-Basic Username Key", + ), + ), + ] diff --git a/passbook/providers/proxy/migrations/__init__.py b/authentik/providers/proxy/migrations/__init__.py similarity index 100% rename from passbook/providers/proxy/migrations/__init__.py rename to authentik/providers/proxy/migrations/__init__.py diff --git a/authentik/providers/proxy/models.py b/authentik/providers/proxy/models.py new file mode 100644 index 00000000..badd6226 --- /dev/null +++ b/authentik/providers/proxy/models.py @@ -0,0 +1,154 @@ +"""authentik proxy models""" +import string +from random import SystemRandom +from typing import Iterable, Optional, Type +from urllib.parse import urljoin + +from django.db import models +from django.forms import ModelForm +from django.http import HttpRequest +from django.utils.translation import gettext as _ + +from authentik.crypto.models import CertificateKeyPair +from authentik.lib.models import DomainlessURLValidator +from authentik.outposts.models import OutpostModel +from authentik.providers.oauth2.constants import ( + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, +) +from authentik.providers.oauth2.models import ( + ClientTypes, + JWTAlgorithms, + OAuth2Provider, + ResponseTypes, + ScopeMapping, +) + +SCOPE_AK_PROXY = "ak_proxy" + + +def get_cookie_secret(): + """Generate random 32-character string for cookie-secret""" + return "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32) + ) + + +def _get_callback_url(uri: str) -> str: + return urljoin(uri, "/akprox/callback") + + +class ProxyProvider(OutpostModel, OAuth2Provider): + """Protect applications that don't support any of the other + Protocols by using a Reverse-Proxy.""" + + internal_host = models.TextField( + validators=[DomainlessURLValidator(schemes=("http", "https"))] + ) + external_host = models.TextField( + validators=[DomainlessURLValidator(schemes=("http", "https"))] + ) + internal_host_ssl_validation = models.BooleanField( + default=True, + help_text=_("Validate SSL Certificates of upstream servers"), + verbose_name=_("Internal host SSL Validation"), + ) + + skip_path_regex = models.TextField( + default="", + blank=True, + help_text=_( + ( + "Regular expressions for which authentication is not required. " + "Each new line is interpreted as a new Regular Expression." + ) + ), + ) + + basic_auth_enabled = models.BooleanField( + default=False, + verbose_name=_("Set HTTP-Basic Authentication"), + help_text=_( + "Set a custom HTTP-Basic Authentication header based on values from authentik." + ), + ) + basic_auth_user_attribute = models.TextField( + blank=True, + verbose_name=_("HTTP-Basic Username Key"), + help_text=_( + ( + "User/Group Attribute used for the user part of the HTTP-Basic Header. " + "If not set, the user's Email address is used." + ) + ), + ) + basic_auth_password_attribute = models.TextField( + blank=True, + verbose_name=_("HTTP-Basic Password Key"), + help_text=_( + ( + "User/Group Attribute used for the password part of the HTTP-Basic Header." + ) + ), + ) + + certificate = models.ForeignKey( + CertificateKeyPair, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + cookie_secret = models.TextField(default=get_cookie_secret) + + @property + def form(self) -> Type[ModelForm]: + from authentik.providers.proxy.forms import ProxyProviderForm + + return ProxyProviderForm + + @property + def launch_url(self) -> Optional[str]: + """Use external_host as launch URL""" + return self.external_host + + def html_setup_urls(self, request: HttpRequest) -> Optional[str]: + """Overwrite Setup URLs as they are not needed for proxy""" + return None + + def set_oauth_defaults(self): + """Ensure all OAuth2-related settings are correct""" + self.client_type = ClientTypes.CONFIDENTIAL + self.response_type = ResponseTypes.CODE + self.jwt_alg = JWTAlgorithms.RS256 + self.rsa_key = CertificateKeyPair.objects.first() + scopes = ScopeMapping.objects.filter( + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_PROFILE, + SCOPE_OPENID_EMAIL, + SCOPE_AK_PROXY, + ] + ) + self.property_mappings.set(scopes) + self.redirect_uris = "\n".join( + [ + _get_callback_url(self.external_host), + _get_callback_url(self.internal_host), + ] + ) + + def __str__(self): + return f"Proxy Provider {self.name}" + + def get_required_objects(self) -> Iterable[models.Model]: + required_models = [self] + if self.certificate is not None: + required_models.append(self.certificate) + return required_models + + class Meta: + + verbose_name = _("Proxy Provider") + verbose_name_plural = _("Proxy Providers") diff --git a/passbook/providers/proxy/provider/__init__.py b/authentik/providers/proxy/provider/__init__.py similarity index 100% rename from passbook/providers/proxy/provider/__init__.py rename to authentik/providers/proxy/provider/__init__.py diff --git a/passbook/providers/proxy/provider/kubernetes/__init__.py b/authentik/providers/proxy/provider/kubernetes/__init__.py similarity index 100% rename from passbook/providers/proxy/provider/kubernetes/__init__.py rename to authentik/providers/proxy/provider/kubernetes/__init__.py diff --git a/passbook/providers/saml/__init__.py b/authentik/providers/saml/__init__.py similarity index 100% rename from passbook/providers/saml/__init__.py rename to authentik/providers/saml/__init__.py diff --git a/authentik/providers/saml/api.py b/authentik/providers/saml/api.py new file mode 100644 index 00000000..bf0f5501 --- /dev/null +++ b/authentik/providers/saml/api.py @@ -0,0 +1,51 @@ +"""SAMLProvider API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider + + +class SAMLProviderSerializer(ModelSerializer): + """SAMLProvider Serializer""" + + class Meta: + + model = SAMLProvider + fields = [ + "pk", + "name", + "acs_url", + "audience", + "issuer", + "assertion_valid_not_before", + "assertion_valid_not_on_or_after", + "session_valid_not_on_or_after", + "property_mappings", + "digest_algorithm", + "signature_algorithm", + "signing_kp", + "verification_kp", + ] + + +class SAMLProviderViewSet(ModelViewSet): + """SAMLProvider Viewset""" + + queryset = SAMLProvider.objects.all() + serializer_class = SAMLProviderSerializer + + +class SAMLPropertyMappingSerializer(ModelSerializer): + """SAMLPropertyMapping Serializer""" + + class Meta: + + model = SAMLPropertyMapping + fields = ["pk", "name", "saml_name", "friendly_name", "expression"] + + +class SAMLPropertyMappingViewSet(ModelViewSet): + """SAMLPropertyMapping Viewset""" + + queryset = SAMLPropertyMapping.objects.all() + serializer_class = SAMLPropertyMappingSerializer diff --git a/authentik/providers/saml/apps.py b/authentik/providers/saml/apps.py new file mode 100644 index 00000000..1d6d9c5e --- /dev/null +++ b/authentik/providers/saml/apps.py @@ -0,0 +1,12 @@ +"""authentik SAML IdP app config""" + +from django.apps import AppConfig + + +class AuthentikProviderSAMLConfig(AppConfig): + """authentik SAML IdP app config""" + + name = "authentik.providers.saml" + label = "authentik_providers_saml" + verbose_name = "authentik Providers.SAML" + mountpoint = "application/saml/" diff --git a/authentik/providers/saml/exceptions.py b/authentik/providers/saml/exceptions.py new file mode 100644 index 00000000..b32453c6 --- /dev/null +++ b/authentik/providers/saml/exceptions.py @@ -0,0 +1,6 @@ +"""authentik SAML IDP Exceptions""" +from authentik.lib.sentry import SentryIgnoredException + + +class CannotHandleAssertion(SentryIgnoredException): + """This processor does not handle this assertion.""" diff --git a/authentik/providers/saml/forms.py b/authentik/providers/saml/forms.py new file mode 100644 index 00000000..c676355d --- /dev/null +++ b/authentik/providers/saml/forms.py @@ -0,0 +1,85 @@ +"""authentik SAML IDP Forms""" + +from django import forms +from django.utils.html import mark_safe +from django.utils.translation import gettext as _ + +from authentik.admin.fields import CodeMirrorWidget +from authentik.core.expression import PropertyMappingEvaluator +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow, FlowDesignation +from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider + + +class SAMLProviderForm(forms.ModelForm): + """SAML Provider form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["authorization_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.AUTHORIZATION + ) + self.fields["property_mappings"].queryset = SAMLPropertyMapping.objects.all() + self.fields["signing_kp"].queryset = CertificateKeyPair.objects.exclude( + key_data__iexact="" + ) + + class Meta: + + model = SAMLProvider + fields = [ + "name", + "authorization_flow", + "acs_url", + "audience", + "issuer", + "sp_binding", + "assertion_valid_not_before", + "assertion_valid_not_on_or_after", + "session_valid_not_on_or_after", + "digest_algorithm", + "signature_algorithm", + "signing_kp", + "verification_kp", + "property_mappings", + ] + widgets = { + "name": forms.TextInput(), + "audience": forms.TextInput(), + "issuer": forms.TextInput(), + "assertion_valid_not_before": forms.TextInput(), + "assertion_valid_not_on_or_after": forms.TextInput(), + "session_valid_not_on_or_after": forms.TextInput(), + } + + +class SAMLPropertyMappingForm(forms.ModelForm): + """SAML Property Mapping form""" + + template_name = "providers/saml/property_mapping_form.html" + + def clean_expression(self): + """Test Syntax""" + expression = self.cleaned_data.get("expression") + evaluator = PropertyMappingEvaluator() + evaluator.validate(expression) + return expression + + class Meta: + + model = SAMLPropertyMapping + fields = ["name", "saml_name", "friendly_name", "expression"] + widgets = { + "name": forms.TextInput(), + "saml_name": forms.TextInput(), + "friendly_name": forms.TextInput(), + "expression": CodeMirrorWidget(mode="python"), + } + help_texts = { + "saml_name": mark_safe( + _( + "URN OID used by SAML. This is optional. " + 'Reference' + ) + ), + } diff --git a/authentik/providers/saml/migrations/0001_initial.py b/authentik/providers/saml/migrations/0001_initial.py new file mode 100644 index 00000000..c877030f --- /dev/null +++ b/authentik/providers/saml/migrations/0001_initial.py @@ -0,0 +1,140 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + +import authentik.lib.utils.time + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_crypto", "0001_initial"), + ("authentik_core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SAMLPropertyMapping", + fields=[ + ( + "propertymapping_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.PropertyMapping", + ), + ), + ("saml_name", models.TextField(verbose_name="SAML Name")), + ( + "friendly_name", + models.TextField(blank=True, default=None, null=True), + ), + ], + options={ + "verbose_name": "SAML Property Mapping", + "verbose_name_plural": "SAML Property Mappings", + }, + bases=("authentik_core.propertymapping",), + ), + migrations.CreateModel( + name="SAMLProvider", + fields=[ + ( + "provider_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.Provider", + ), + ), + ("name", models.TextField()), + ("processor_path", models.CharField(choices=[], max_length=255)), + ("acs_url", models.URLField(verbose_name="ACS URL")), + ("audience", models.TextField(default="")), + ("issuer", models.TextField(help_text="Also known as EntityID")), + ( + "assertion_valid_not_before", + models.TextField( + default="minutes=-5", + help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).", + validators=[ + authentik.lib.utils.time.timedelta_string_validator + ], + ), + ), + ( + "assertion_valid_not_on_or_after", + models.TextField( + default="minutes=5", + help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", + validators=[ + authentik.lib.utils.time.timedelta_string_validator + ], + ), + ), + ( + "session_valid_not_on_or_after", + models.TextField( + default="minutes=86400", + help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", + validators=[ + authentik.lib.utils.time.timedelta_string_validator + ], + ), + ), + ( + "digest_algorithm", + models.CharField( + choices=[("sha1", "SHA1"), ("sha256", "SHA256")], + default="sha256", + max_length=50, + ), + ), + ( + "signature_algorithm", + models.CharField( + choices=[ + ("rsa-sha1", "RSA-SHA1"), + ("rsa-sha256", "RSA-SHA256"), + ("ecdsa-sha256", "ECDSA-SHA256"), + ("dsa-sha1", "DSA-SHA1"), + ], + default="rsa-sha256", + max_length=50, + ), + ), + ( + "require_signing", + models.BooleanField( + default=False, + help_text="Require Requests to be signed by an X509 Certificate. Must match the Certificate selected in `Singing Keypair`.", + ), + ), + ( + "signing_kp", + models.ForeignKey( + default=None, + help_text="Singing is enabled upon selection of a Key Pair.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_crypto.CertificateKeyPair", + verbose_name="Signing Keypair", + ), + ), + ], + options={ + "verbose_name": "SAML Provider", + "verbose_name_plural": "SAML Providers", + }, + bases=("authentik_core.provider",), + ), + ] diff --git a/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py b/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py new file mode 100644 index 00000000..0caf8d1c --- /dev/null +++ b/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py @@ -0,0 +1,63 @@ +# Generated by Django 3.0.6 on 2020-05-23 19:32 + +from django.db import migrations + + +def create_default_property_mappings(apps, schema_editor): + """Create default SAML Property Mappings""" + SAMLPropertyMapping = apps.get_model( + "authentik_providers_saml", "SAMLPropertyMapping" + ) + db_alias = schema_editor.connection.alias + defaults = [ + { + "FriendlyName": "eduPersonPrincipalName", + "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", + "Expression": "return user.email", + }, + { + "FriendlyName": "cn", + "Name": "urn:oid:2.5.4.3", + "Expression": "return user.name", + }, + { + "FriendlyName": "mail", + "Name": "urn:oid:0.9.2342.19200300.100.1.3", + "Expression": "return user.email", + }, + { + "FriendlyName": "displayName", + "Name": "urn:oid:2.16.840.1.113730.3.1.241", + "Expression": "return user.username", + }, + { + "FriendlyName": "uid", + "Name": "urn:oid:0.9.2342.19200300.100.1.1", + "Expression": "return user.pk", + }, + { + "FriendlyName": "member-of", + "Name": "member-of", + "Expression": "for group in user.groups.all():\n yield group.name", + }, + ] + for default in defaults: + SAMLPropertyMapping.objects.using(db_alias).get_or_create( + saml_name=default["Name"], + friendly_name=default["FriendlyName"], + expression=default["Expression"], + defaults={ + "name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}" + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_saml", "0001_initial"), + ] + + operations = [ + migrations.RunPython(create_default_property_mappings), + ] diff --git a/authentik/providers/saml/migrations/0003_samlprovider_sp_binding.py b/authentik/providers/saml/migrations/0003_samlprovider_sp_binding.py new file mode 100644 index 00000000..9119f1cc --- /dev/null +++ b/authentik/providers/saml/migrations/0003_samlprovider_sp_binding.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.6 on 2020-06-06 13:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_saml", "0002_default_saml_property_mappings"), + ] + + operations = [ + migrations.AddField( + model_name="samlprovider", + name="sp_binding", + field=models.TextField( + choices=[("redirect", "Redirect"), ("post", "Post")], default="redirect" + ), + ), + ] diff --git a/authentik/providers/saml/migrations/0004_auto_20200620_1950.py b/authentik/providers/saml/migrations/0004_auto_20200620_1950.py new file mode 100644 index 00000000..723d16cb --- /dev/null +++ b/authentik/providers/saml/migrations/0004_auto_20200620_1950.py @@ -0,0 +1,22 @@ +# Generated by Django 3.0.7 on 2020-06-20 19:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_saml", "0003_samlprovider_sp_binding"), + ] + + operations = [ + migrations.AlterField( + model_name="samlprovider", + name="sp_binding", + field=models.TextField( + choices=[("redirect", "Redirect"), ("post", "Post")], + default="redirect", + verbose_name="Service Prodier Binding", + ), + ), + ] diff --git a/authentik/providers/saml/migrations/0005_remove_samlprovider_processor_path.py b/authentik/providers/saml/migrations/0005_remove_samlprovider_processor_path.py new file mode 100644 index 00000000..db5a834a --- /dev/null +++ b/authentik/providers/saml/migrations/0005_remove_samlprovider_processor_path.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.8 on 2020-07-11 00:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_saml", "0004_auto_20200620_1950"), + ] + + operations = [ + migrations.RemoveField( + model_name="samlprovider", + name="processor_path", + ), + ] diff --git a/authentik/providers/saml/migrations/0006_remove_samlprovider_name.py b/authentik/providers/saml/migrations/0006_remove_samlprovider_name.py new file mode 100644 index 00000000..d68e1b05 --- /dev/null +++ b/authentik/providers/saml/migrations/0006_remove_samlprovider_name.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.2 on 2020-10-03 17:37 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def update_name_temp(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + SAMLProvider = apps.get_model("authentik_providers_saml", "SAMLProvider") + db_alias = schema_editor.connection.alias + + for provider in SAMLProvider.objects.using(db_alias).all(): + provider.name_temp = provider.name + provider.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0011_provider_name_temp"), + ("authentik_providers_saml", "0005_remove_samlprovider_processor_path"), + ] + + operations = [ + migrations.RunPython(update_name_temp), + migrations.RemoveField( + model_name="samlprovider", + name="name", + ), + ] diff --git a/authentik/providers/saml/migrations/0007_samlprovider_verification_kp.py b/authentik/providers/saml/migrations/0007_samlprovider_verification_kp.py new file mode 100644 index 00000000..016b4bc0 --- /dev/null +++ b/authentik/providers/saml/migrations/0007_samlprovider_verification_kp.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.3 on 2020-11-08 21:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0002_create_self_signed_kp"), + ("authentik_providers_saml", "0006_remove_samlprovider_name"), + ] + + operations = [ + migrations.AddField( + model_name="samlprovider", + name="verification_kp", + field=models.ForeignKey( + default=None, + help_text="If selected, incoming assertion's Signatures will be validated.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="authentik_crypto.certificatekeypair", + verbose_name="Verification Keypair", + ), + ), + ] diff --git a/authentik/providers/saml/migrations/0008_auto_20201112_1036.py b/authentik/providers/saml/migrations/0008_auto_20201112_1036.py new file mode 100644 index 00000000..18dc55c8 --- /dev/null +++ b/authentik/providers/saml/migrations/0008_auto_20201112_1036.py @@ -0,0 +1,71 @@ +# Generated by Django 3.1.3 on 2020-11-12 10:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0002_create_self_signed_kp"), + ("authentik_providers_saml", "0007_samlprovider_verification_kp"), + ] + + operations = [ + migrations.RemoveField( + model_name="samlprovider", + name="require_signing", + ), + migrations.AlterField( + model_name="samlprovider", + name="audience", + field=models.TextField( + default="", + help_text="Value of the audience restriction field of the asseration.", + ), + ), + migrations.AlterField( + model_name="samlprovider", + name="issuer", + field=models.TextField( + default="authentik", help_text="Also known as EntityID" + ), + ), + migrations.AlterField( + model_name="samlprovider", + name="signing_kp", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Keypair used to sign outgoing Responses going to the Service Provider.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_crypto.certificatekeypair", + verbose_name="Signing Keypair", + ), + ), + migrations.AlterField( + model_name="samlprovider", + name="sp_binding", + field=models.TextField( + choices=[("redirect", "Redirect"), ("post", "Post")], + default="redirect", + help_text="This determines how authentik sends the response back to the Service Provider.", + verbose_name="Service Provider Binding", + ), + ), + migrations.AlterField( + model_name="samlprovider", + name="verification_kp", + field=models.ForeignKey( + blank=True, + default=None, + help_text="When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="authentik_crypto.certificatekeypair", + verbose_name="Verification Certificate", + ), + ), + ] diff --git a/authentik/providers/saml/migrations/0009_auto_20201112_2016.py b/authentik/providers/saml/migrations/0009_auto_20201112_2016.py new file mode 100644 index 00000000..5ac5b73a --- /dev/null +++ b/authentik/providers/saml/migrations/0009_auto_20201112_2016.py @@ -0,0 +1,69 @@ +# Generated by Django 3.1.3 on 2020-11-12 20:16 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.sources.saml.processors import constants + + +def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + SAMLProvider = apps.get_model("authentik_providers_saml", "SAMLProvider") + signature_translation_map = { + "rsa-sha1": constants.RSA_SHA1, + "rsa-sha256": constants.RSA_SHA256, + "ecdsa-sha256": constants.RSA_SHA256, + "dsa-sha1": constants.DSA_SHA1, + } + digest_translation_map = { + "sha1": constants.SHA1, + "sha256": constants.SHA256, + } + + for source in SAMLProvider.objects.all(): + source.signature_algorithm = signature_translation_map.get( + source.signature_algorithm, constants.RSA_SHA256 + ) + source.digest_algorithm = digest_translation_map.get( + source.digest_algorithm, constants.SHA256 + ) + source.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_saml", "0008_auto_20201112_1036"), + ] + + operations = [ + migrations.AlterField( + model_name="samlprovider", + name="digest_algorithm", + field=models.CharField( + choices=[ + (constants.SHA1, "SHA1"), + (constants.SHA256, "SHA256"), + (constants.SHA384, "SHA384"), + (constants.SHA512, "SHA512"), + ], + default=constants.SHA256, + max_length=50, + ), + ), + migrations.AlterField( + model_name="samlprovider", + name="signature_algorithm", + field=models.CharField( + choices=[ + (constants.RSA_SHA1, "RSA-SHA1"), + (constants.RSA_SHA256, "RSA-SHA256"), + (constants.RSA_SHA384, "RSA-SHA384"), + (constants.RSA_SHA512, "RSA-SHA512"), + (constants.DSA_SHA1, "DSA-SHA1"), + ], + default=constants.RSA_SHA256, + max_length=50, + ), + ), + ] diff --git a/passbook/providers/saml/migrations/__init__.py b/authentik/providers/saml/migrations/__init__.py similarity index 100% rename from passbook/providers/saml/migrations/__init__.py rename to authentik/providers/saml/migrations/__init__.py diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py new file mode 100644 index 00000000..c4dcbba7 --- /dev/null +++ b/authentik/providers/saml/models.py @@ -0,0 +1,207 @@ +"""authentik saml_idp Models""" +from typing import Optional, Type +from urllib.parse import urlparse + +from django.db import models +from django.forms import ModelForm +from django.http import HttpRequest +from django.shortcuts import reverse +from django.utils.translation import gettext_lazy as _ +from structlog import get_logger + +from authentik.core.models import PropertyMapping, Provider +from authentik.crypto.models import CertificateKeyPair +from authentik.lib.utils.template import render_to_string +from authentik.lib.utils.time import timedelta_string_validator +from authentik.sources.saml.processors.constants import ( + DSA_SHA1, + RSA_SHA1, + RSA_SHA256, + RSA_SHA384, + RSA_SHA512, + SHA1, + SHA256, + SHA384, + SHA512, +) + +LOGGER = get_logger() + + +class SAMLBindings(models.TextChoices): + """SAML Bindings supported by authentik""" + + REDIRECT = "redirect" + POST = "post" + + +class SAMLProvider(Provider): + """SAML 2.0 Endpoint for applications which support SAML.""" + + acs_url = models.URLField(verbose_name=_("ACS URL")) + audience = models.TextField( + default="", + help_text=_("Value of the audience restriction field of the asseration."), + ) + issuer = models.TextField( + help_text=_("Also known as EntityID"), default="authentik" + ) + sp_binding = models.TextField( + choices=SAMLBindings.choices, + default=SAMLBindings.REDIRECT, + verbose_name=_("Service Provider Binding"), + help_text=_( + ( + "This determines how authentik sends the " + "response back to the Service Provider." + ) + ), + ) + + assertion_valid_not_before = models.TextField( + default="minutes=-5", + validators=[timedelta_string_validator], + help_text=_( + ( + "Assertion valid not before current time + this value " + "(Format: hours=-1;minutes=-2;seconds=-3)." + ) + ), + ) + assertion_valid_not_on_or_after = models.TextField( + default="minutes=5", + validators=[timedelta_string_validator], + help_text=_( + ( + "Assertion not valid on or after current time + this value " + "(Format: hours=1;minutes=2;seconds=3)." + ) + ), + ) + + session_valid_not_on_or_after = models.TextField( + default="minutes=86400", + validators=[timedelta_string_validator], + help_text=_( + ( + "Session not valid on or after current time + this value " + "(Format: hours=1;minutes=2;seconds=3)." + ) + ), + ) + + digest_algorithm = models.CharField( + max_length=50, + choices=( + (SHA1, _("SHA1")), + (SHA256, _("SHA256")), + (SHA384, _("SHA384")), + (SHA512, _("SHA512")), + ), + default=SHA256, + ) + signature_algorithm = models.CharField( + max_length=50, + choices=( + (RSA_SHA1, _("RSA-SHA1")), + (RSA_SHA256, _("RSA-SHA256")), + (RSA_SHA384, _("RSA-SHA384")), + (RSA_SHA512, _("RSA-SHA512")), + (DSA_SHA1, _("DSA-SHA1")), + ), + default=RSA_SHA256, + ) + + verification_kp = models.ForeignKey( + CertificateKeyPair, + default=None, + null=True, + blank=True, + help_text=_( + ( + "When selected, incoming assertion's Signatures will be validated against this " + "certificate. To allow unsigned Requests, leave on default." + ) + ), + on_delete=models.SET_NULL, + verbose_name=_("Verification Certificate"), + related_name="+", + ) + signing_kp = models.ForeignKey( + CertificateKeyPair, + default=None, + null=True, + blank=True, + help_text=_( + "Keypair used to sign outgoing Responses going to the Service Provider." + ), + on_delete=models.SET_NULL, + verbose_name=_("Signing Keypair"), + ) + + @property + def launch_url(self) -> Optional[str]: + """Guess launch_url based on acs URL""" + launch_url = urlparse(self.acs_url) + return self.acs_url.replace(launch_url.path, "") + + @property + def form(self) -> Type[ModelForm]: + from authentik.providers.saml.forms import SAMLProviderForm + + return SAMLProviderForm + + def __str__(self): + return f"SAML Provider {self.name}" + + def link_download_metadata(self): + """Get link to download XML metadata for admin interface""" + try: + # pylint: disable=no-member + return reverse( + "authentik_providers_saml:metadata", + kwargs={"application_slug": self.application.slug}, + ) + except Provider.application.RelatedObjectDoesNotExist: + return None + + def html_metadata_view(self, request: HttpRequest) -> Optional[str]: + """return template and context modal to view Metadata without downloading it""" + from authentik.providers.saml.views import DescriptorDownloadView + + try: + # pylint: disable=no-member + metadata = DescriptorDownloadView.get_metadata(request, self) + return render_to_string( + "providers/saml/admin_metadata_modal.html", + {"provider": self, "metadata": metadata}, + ) + except Provider.application.RelatedObjectDoesNotExist: + return None + + class Meta: + + verbose_name = _("SAML Provider") + verbose_name_plural = _("SAML Providers") + + +class SAMLPropertyMapping(PropertyMapping): + """Map User/Group attribute to SAML Attribute, which can be used by the Service Provider.""" + + saml_name = models.TextField(verbose_name="SAML Name") + friendly_name = models.TextField(default=None, blank=True, null=True) + + @property + def form(self) -> Type[ModelForm]: + from authentik.providers.saml.forms import SAMLPropertyMappingForm + + return SAMLPropertyMappingForm + + def __str__(self): + name = self.friendly_name if self.friendly_name != "" else self.saml_name + return f"{self.name} ({name})" + + class Meta: + + verbose_name = _("SAML Property Mapping") + verbose_name_plural = _("SAML Property Mappings") diff --git a/passbook/providers/saml/processors/__init__.py b/authentik/providers/saml/processors/__init__.py similarity index 100% rename from passbook/providers/saml/processors/__init__.py rename to authentik/providers/saml/processors/__init__.py diff --git a/authentik/providers/saml/processors/assertion.py b/authentik/providers/saml/processors/assertion.py new file mode 100644 index 00000000..6cba0b28 --- /dev/null +++ b/authentik/providers/saml/processors/assertion.py @@ -0,0 +1,263 @@ +"""SAML Assertion generator""" +from hashlib import sha256 +from types import GeneratorType + +import xmlsec +from django.http import HttpRequest +from lxml import etree # nosec +from lxml.etree import Element, SubElement # nosec +from structlog import get_logger + +from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.lib.utils.time import timedelta_from_string +from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider +from authentik.providers.saml.processors.request_parser import AuthNRequest +from authentik.providers.saml.utils import get_random_id +from authentik.providers.saml.utils.time import get_time_string +from authentik.sources.saml.exceptions import UnsupportedNameIDFormat +from authentik.sources.saml.processors.constants import ( + DIGEST_ALGORITHM_TRANSLATION_MAP, + NS_MAP, + NS_SAML_ASSERTION, + NS_SAML_PROTOCOL, + SAML_NAME_ID_FORMAT_EMAIL, + SAML_NAME_ID_FORMAT_PERSISTENT, + SAML_NAME_ID_FORMAT_TRANSIENT, + SAML_NAME_ID_FORMAT_X509, + SIGN_ALGORITHM_TRANSFORM_MAP, +) + +LOGGER = get_logger() + + +class AssertionProcessor: + """Generate a SAML Response from an AuthNRequest""" + + provider: SAMLProvider + http_request: HttpRequest + auth_n_request: AuthNRequest + + _issue_instant: str + _assertion_id: str + + _valid_not_before: str + _valid_not_on_or_after: str + + def __init__( + self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest + ): + self.provider = provider + self.http_request = request + self.auth_n_request = auth_n_request + + self._issue_instant = get_time_string() + self._assertion_id = get_random_id() + + self._valid_not_before = get_time_string( + timedelta_from_string(self.provider.assertion_valid_not_before) + ) + self._valid_not_on_or_after = get_time_string( + timedelta_from_string(self.provider.assertion_valid_not_on_or_after) + ) + + def get_attributes(self) -> Element: + """Get AttributeStatement Element with Attributes from Property Mappings.""" + # https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions + attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement") + for mapping in self.provider.property_mappings.all().select_subclasses(): + if not isinstance(mapping, SAMLPropertyMapping): + continue + try: + mapping: SAMLPropertyMapping + value = mapping.evaluate( + user=self.http_request.user, + request=self.http_request, + provider=self.provider, + ) + if value is None: + continue + + attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute") + attribute.attrib["FriendlyName"] = mapping.friendly_name + attribute.attrib["Name"] = mapping.saml_name + + if not isinstance(value, (list, GeneratorType)): + value = [value] + + for value_item in value: + attribute_value = SubElement( + attribute, f"{{{NS_SAML_ASSERTION}}}AttributeValue" + ) + if not isinstance(value_item, str): + value_item = str(value_item) + attribute_value.text = value_item + + attribute_statement.append(attribute) + + except PropertyMappingExpressionException as exc: + LOGGER.warning(exc) + continue + return attribute_statement + + def get_issuer(self) -> Element: + """Get Issuer Element""" + issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP) + issuer.text = self.provider.issuer + return issuer + + def get_assertion_auth_n_statement(self) -> Element: + """Generate AuthnStatement with AuthnContext and ContextClassRef Elements.""" + auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement") + auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before + auth_n_statement.attrib["SessionIndex"] = self._assertion_id + + auth_n_context = SubElement( + auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext" + ) + auth_n_context_class_ref = SubElement( + auth_n_context, f"{{{NS_SAML_ASSERTION}}}AuthnContextClassRef" + ) + auth_n_context_class_ref.text = ( + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" + ) + return auth_n_statement + + def get_assertion_conditions(self) -> Element: + """Generate Conditions with AudienceRestriction and Audience Elements.""" + conditions = Element(f"{{{NS_SAML_ASSERTION}}}Conditions") + conditions.attrib["NotBefore"] = self._valid_not_before + conditions.attrib["NotOnOrAfter"] = self._valid_not_on_or_after + audience_restriction = SubElement( + conditions, f"{{{NS_SAML_ASSERTION}}}AudienceRestriction" + ) + audience = SubElement(audience_restriction, f"{{{NS_SAML_ASSERTION}}}Audience") + audience.text = self.provider.audience + return conditions + + def get_name_id(self) -> Element: + """Get NameID Element""" + name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID") + name_id.attrib["Format"] = self.auth_n_request.name_id_policy + if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_EMAIL: + name_id.text = self.http_request.user.email + return name_id + if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_PERSISTENT: + name_id.text = self.http_request.user.username + return name_id + if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509: + # This attribute is statically set by the LDAP source + name_id.text = self.http_request.user.attributes.get( + "distinguishedName", "" + ) + return name_id + if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: + # This attribute is statically set by the LDAP source + session_key: str = self.http_request.user.session.session_key + name_id.text = sha256(session_key.encode()).hexdigest() + return name_id + raise UnsupportedNameIDFormat( + f"Assertion contains NameID with unsupported format {name_id.attrib['Format']}." + ) + + def get_assertion_subject(self) -> Element: + """Generate Subject Element with NameID and SubjectConfirmation Objects""" + subject = Element(f"{{{NS_SAML_ASSERTION}}}Subject") + subject.append(self.get_name_id()) + + subject_confirmation = SubElement( + subject, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmation" + ) + subject_confirmation.attrib["Method"] = "urn:oasis:names:tc:SAML:2.0:cm:bearer" + + subject_confirmation_data = SubElement( + subject_confirmation, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmationData" + ) + if self.auth_n_request.id: + subject_confirmation_data.attrib["InResponseTo"] = self.auth_n_request.id + subject_confirmation_data.attrib["NotOnOrAfter"] = self._valid_not_on_or_after + subject_confirmation_data.attrib["Recipient"] = self.provider.acs_url + return subject + + def get_assertion(self) -> Element: + """Generate Main Assertion Element""" + assertion = Element(f"{{{NS_SAML_ASSERTION}}}Assertion", nsmap=NS_MAP) + assertion.attrib["Version"] = "2.0" + assertion.attrib["ID"] = self._assertion_id + assertion.attrib["IssueInstant"] = self._issue_instant + assertion.append(self.get_issuer()) + + if self.provider.signing_kp: + sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get( + self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1 + ) + signature = xmlsec.template.create( + assertion, + xmlsec.constants.TransformExclC14N, + sign_algorithm_transform, + ns="ds", # type: ignore + ) + assertion.append(signature) + + assertion.append(self.get_assertion_subject()) + assertion.append(self.get_assertion_conditions()) + assertion.append(self.get_assertion_auth_n_statement()) + + assertion.append(self.get_attributes()) + return assertion + + def get_response(self) -> Element: + """Generate Root response element""" + response = Element(f"{{{NS_SAML_PROTOCOL}}}Response", nsmap=NS_MAP) + response.attrib["Version"] = "2.0" + response.attrib["IssueInstant"] = self._issue_instant + response.attrib["Destination"] = self.provider.acs_url + response.attrib["ID"] = get_random_id() + if self.auth_n_request.id: + response.attrib["InResponseTo"] = self.auth_n_request.id + + response.append(self.get_issuer()) + + status = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status") + status_code = SubElement(status, f"{{{NS_SAML_PROTOCOL}}}StatusCode") + status_code.attrib["Value"] = "urn:oasis:names:tc:SAML:2.0:status:Success" + + response.append(self.get_assertion()) + return response + + def build_response(self) -> str: + """Build string XML Response and sign if signing is enabled.""" + root_response = self.get_response() + if self.provider.signing_kp: + digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get( + self.provider.digest_algorithm, xmlsec.constants.TransformSha1 + ) + assertion = root_response.xpath("//saml:Assertion", namespaces=NS_MAP)[0] + xmlsec.tree.add_ids(assertion, ["ID"]) + signature_node = xmlsec.tree.find_node( + assertion, xmlsec.constants.NodeSignature + ) + ref = xmlsec.template.add_reference( + signature_node, + digest_algorithm_transform, + uri="#" + self._assertion_id, + ) + xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped) + xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N) + key_info = xmlsec.template.ensure_key_info(signature_node) + xmlsec.template.add_x509_data(key_info) + + ctx = xmlsec.SignatureContext() + + key = xmlsec.Key.from_memory( + self.provider.signing_kp.key_data, + xmlsec.constants.KeyDataFormatPem, + None, + ) + key.load_cert_from_memory( + self.provider.signing_kp.certificate_data, + xmlsec.constants.KeyDataFormatCertPem, + ) + ctx.key = key + ctx.sign(signature_node) + + return etree.tostring(root_response).decode("utf-8") # nosec diff --git a/authentik/providers/saml/processors/metadata.py b/authentik/providers/saml/processors/metadata.py new file mode 100644 index 00000000..0e8a1d37 --- /dev/null +++ b/authentik/providers/saml/processors/metadata.py @@ -0,0 +1,108 @@ +"""SAML Identity Provider Metadata Processor""" +from typing import Iterator, Optional + +from django.http import HttpRequest +from django.shortcuts import reverse +from lxml.etree import Element, SubElement, tostring # nosec + +from authentik.providers.saml.models import SAMLProvider +from authentik.providers.saml.utils.encoding import strip_pem_header +from authentik.sources.saml.processors.constants import ( + NS_MAP, + NS_SAML_METADATA, + NS_SIGNATURE, + SAML_BINDING_POST, + SAML_BINDING_REDIRECT, + SAML_NAME_ID_FORMAT_EMAIL, + SAML_NAME_ID_FORMAT_PERSISTENT, + SAML_NAME_ID_FORMAT_TRANSIENT, + SAML_NAME_ID_FORMAT_X509, +) + + +class MetadataProcessor: + """SAML Identity Provider Metadata Processor""" + + provider: SAMLProvider + http_request: HttpRequest + + def __init__(self, provider: SAMLProvider, request: HttpRequest): + self.provider = provider + self.http_request = request + + def get_signing_key_descriptor(self) -> Optional[Element]: + """Get Singing KeyDescriptor, if enabled for the provider""" + if self.provider.signing_kp: + key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor") + key_descriptor.attrib["use"] = "signing" + key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo") + x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data") + x509_certificate = SubElement( + x509_data, f"{{{NS_SIGNATURE}}}X509Certificate" + ) + x509_certificate.text = strip_pem_header( + self.provider.signing_kp.certificate_data.replace("\r", "") + ) + return key_descriptor + return None + + def get_name_id_formats(self) -> Iterator[Element]: + """Get compatible NameID Formats""" + formats = [ + SAML_NAME_ID_FORMAT_EMAIL, + SAML_NAME_ID_FORMAT_PERSISTENT, + SAML_NAME_ID_FORMAT_X509, + SAML_NAME_ID_FORMAT_TRANSIENT, + ] + for name_id_format in formats: + element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat") + element.text = name_id_format + yield element + + def get_bindings(self) -> Iterator[Element]: + """Get all Bindings supported""" + binding_url_map = { + SAML_BINDING_POST: self.http_request.build_absolute_uri( + reverse( + "authentik_providers_saml:sso-post", + kwargs={"application_slug": self.provider.application.slug}, + ) + ), + SAML_BINDING_REDIRECT: self.http_request.build_absolute_uri( + reverse( + "authentik_providers_saml:sso-redirect", + kwargs={"application_slug": self.provider.application.slug}, + ) + ), + } + for binding, url in binding_url_map.items(): + element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService") + element.attrib["Binding"] = binding + element.attrib["Location"] = url + yield element + + def build_entity_descriptor(self) -> str: + """Build full EntityDescriptor""" + entity_descriptor = Element( + f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP + ) + entity_descriptor.attrib["entityID"] = self.provider.issuer + + idp_sso_descriptor = SubElement( + entity_descriptor, f"{{{NS_SAML_METADATA}}}IDPSSODescriptor" + ) + idp_sso_descriptor.attrib[ + "protocolSupportEnumeration" + ] = "urn:oasis:names:tc:SAML:2.0:protocol" + + signing_descriptor = self.get_signing_key_descriptor() + if signing_descriptor is not None: + idp_sso_descriptor.append(signing_descriptor) + + for name_id_format in self.get_name_id_formats(): + idp_sso_descriptor.append(name_id_format) + + for binding in self.get_bindings(): + idp_sso_descriptor.append(binding) + + return tostring(entity_descriptor, pretty_print=True).decode() diff --git a/authentik/providers/saml/processors/request_parser.py b/authentik/providers/saml/processors/request_parser.py new file mode 100644 index 00000000..9f38bae6 --- /dev/null +++ b/authentik/providers/saml/processors/request_parser.py @@ -0,0 +1,169 @@ +"""SAML AuthNRequest Parser and dataclass""" +from base64 import b64decode +from dataclasses import dataclass +from typing import Optional +from urllib.parse import quote_plus + +import xmlsec +from defusedxml import ElementTree +from lxml import etree # nosec +from structlog import get_logger + +from authentik.providers.saml.exceptions import CannotHandleAssertion +from authentik.providers.saml.models import SAMLProvider +from authentik.providers.saml.utils.encoding import decode_base64_and_inflate +from authentik.sources.saml.processors.constants import ( + DSA_SHA1, + NS_MAP, + NS_SAML_PROTOCOL, + RSA_SHA1, + RSA_SHA256, + RSA_SHA384, + RSA_SHA512, + SAML_NAME_ID_FORMAT_EMAIL, +) + +LOGGER = get_logger() +ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = ( + "Verification Certificate configured, but request is not signed." +) +ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER = ( + "Provider does not have a Validation Certificate configured." +) +ERROR_FAILED_TO_VERIFY = "Failed to verify signature" + + +@dataclass +class AuthNRequest: + """AuthNRequest Dataclass""" + + # pylint: disable=invalid-name + id: Optional[str] = None + + relay_state: Optional[str] = None + + name_id_policy: str = SAML_NAME_ID_FORMAT_EMAIL + + +class AuthNRequestParser: + """AuthNRequest Parser""" + + provider: SAMLProvider + + def __init__(self, provider: SAMLProvider): + self.provider = provider + + def _parse_xml(self, decoded_xml: str, relay_state: Optional[str]) -> AuthNRequest: + root = ElementTree.fromstring(decoded_xml) + + request_acs_url = root.attrib["AssertionConsumerServiceURL"] + + if self.provider.acs_url.lower() != request_acs_url.lower(): + msg = ( + f"ACS URL of {request_acs_url} doesn't match Provider " + f"ACS URL of {self.provider.acs_url}." + ) + LOGGER.info(msg) + raise CannotHandleAssertion(msg) + + auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state) + + # Check if AuthnRequest has a NameID Policy object + name_id_policies = root.findall(f"{{{NS_SAML_PROTOCOL}}}:NameIDPolicy") + if len(name_id_policies) > 0: + name_id_policy = name_id_policies[0] + auth_n_request.name_id_policy = name_id_policy.attrib["Format"] + + return auth_n_request + + def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest: + """Validate and parse raw request with enveloped signautre.""" + decoded_xml = b64decode(saml_request.encode()).decode() + + verifier = self.provider.verification_kp + + root = etree.fromstring(decoded_xml) # nosec + xmlsec.tree.add_ids(root, ["ID"]) + signature_nodes = root.xpath( + "/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP + ) + if len(signature_nodes) != 1: + raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) + + signature_node = signature_nodes[0] + + if verifier and signature_node is None: + raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) + + if signature_node is not None: + if not verifier: + raise CannotHandleAssertion(ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER) + + try: + ctx = xmlsec.SignatureContext() + key = xmlsec.Key.from_memory( + verifier.certificate_data, + xmlsec.constants.KeyDataFormatCertPem, + None, + ) + ctx.key = key + ctx.verify(signature_node) + except xmlsec.VerificationError as exc: + raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc + + return self._parse_xml(decoded_xml, relay_state) + + def parse_detached( + self, + saml_request: str, + relay_state: Optional[str], + signature: Optional[str] = None, + sig_alg: Optional[str] = None, + ) -> AuthNRequest: + """Validate and parse raw request with detached signature""" + decoded_xml = decode_base64_and_inflate(saml_request) + + verifier = self.provider.verification_kp + + if verifier and not (signature and sig_alg): + raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) + + if signature and sig_alg: + if not verifier: + raise CannotHandleAssertion(ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER) + + querystring = f"SAMLRequest={quote_plus(saml_request)}&" + if relay_state is not None: + querystring += f"RelayState={quote_plus(relay_state)}&" + querystring += f"SigAlg={quote_plus(sig_alg)}" + + dsig_ctx = xmlsec.SignatureContext() + key = xmlsec.Key.from_memory( + verifier.certificate_data, xmlsec.constants.KeyDataFormatCertPem, None + ) + dsig_ctx.key = key + + sign_algorithm_transform_map = { + DSA_SHA1: xmlsec.constants.TransformDsaSha1, + RSA_SHA1: xmlsec.constants.TransformRsaSha1, + RSA_SHA256: xmlsec.constants.TransformRsaSha256, + RSA_SHA384: xmlsec.constants.TransformRsaSha384, + RSA_SHA512: xmlsec.constants.TransformRsaSha512, + } + sign_algorithm_transform = sign_algorithm_transform_map.get( + sig_alg, xmlsec.constants.TransformRsaSha1 + ) + + try: + dsig_ctx.verify_binary( + querystring.encode("utf-8"), + sign_algorithm_transform, + b64decode(signature), + ) + except xmlsec.VerificationError as exc: + raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc + return self._parse_xml(decoded_xml, relay_state) + + def idp_initiated(self) -> AuthNRequest: + """Create IdP Initiated AuthNRequest""" + return AuthNRequest() diff --git a/authentik/providers/saml/settings.py b/authentik/providers/saml/settings.py new file mode 100644 index 00000000..5477bb56 --- /dev/null +++ b/authentik/providers/saml/settings.py @@ -0,0 +1,6 @@ +"""saml provider settings""" + +AUTHENTIK_PROVIDERS_SAML_PROCESSORS = [ + "authentik.providers.saml.processors.generic", + "authentik.providers.saml.processors.salesforce", +] diff --git a/authentik/providers/saml/templates/providers/saml/admin_metadata_modal.html b/authentik/providers/saml/templates/providers/saml/admin_metadata_modal.html new file mode 100644 index 00000000..b21ff716 --- /dev/null +++ b/authentik/providers/saml/templates/providers/saml/admin_metadata_modal.html @@ -0,0 +1,22 @@ +{% load i18n %} + + + +
+
+

{% trans 'Metadata' %}

+
+ + +
+
+ diff --git a/passbook/providers/saml/templates/providers/saml/consent.html b/authentik/providers/saml/templates/providers/saml/consent.html similarity index 100% rename from passbook/providers/saml/templates/providers/saml/consent.html rename to authentik/providers/saml/templates/providers/saml/consent.html diff --git a/passbook/providers/saml/templates/providers/saml/logged_out.html b/authentik/providers/saml/templates/providers/saml/logged_out.html similarity index 100% rename from passbook/providers/saml/templates/providers/saml/logged_out.html rename to authentik/providers/saml/templates/providers/saml/logged_out.html diff --git a/authentik/providers/saml/templates/providers/saml/property_mapping_form.html b/authentik/providers/saml/templates/providers/saml/property_mapping_form.html new file mode 100644 index 00000000..c202b5b2 --- /dev/null +++ b/authentik/providers/saml/templates/providers/saml/property_mapping_form.html @@ -0,0 +1,14 @@ +{% extends "generic/form.html" %} + +{% load i18n %} + +{% block beneath_form %} +
+ +
+

+ Expression using Python. See here for a list of all variables. +

+
+
+{% endblock %} diff --git a/passbook/providers/saml/tests/__init__.py b/authentik/providers/saml/tests/__init__.py similarity index 100% rename from passbook/providers/saml/tests/__init__.py rename to authentik/providers/saml/tests/__init__.py diff --git a/authentik/providers/saml/tests/test_auth_n_request.py b/authentik/providers/saml/tests/test_auth_n_request.py new file mode 100644 index 00000000..e01c45c5 --- /dev/null +++ b/authentik/providers/saml/tests/test_auth_n_request.py @@ -0,0 +1,211 @@ +"""Test AuthN Request generator and parser""" +from base64 import b64encode + +from django.contrib.sessions.middleware import SessionMiddleware +from django.http.request import HttpRequest, QueryDict +from django.test import RequestFactory, TestCase +from guardian.utils import get_anonymous_user + +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow +from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider +from authentik.providers.saml.processors.assertion import AssertionProcessor +from authentik.providers.saml.processors.request_parser import AuthNRequestParser +from authentik.sources.saml.exceptions import MismatchedRequestID +from authentik.sources.saml.models import SAMLSource +from authentik.sources.saml.processors.constants import SAML_NAME_ID_FORMAT_EMAIL +from authentik.sources.saml.processors.request import ( + SESSION_REQUEST_ID, + RequestProcessor, +) +from authentik.sources.saml.processors.response import ResponseProcessor + +REDIRECT_REQUEST = ( + "fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iu" + "EjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+H" + "nAQbb6+07EGj7EI1j8SCeaVs21oVQ9dAoRqcf6OIhh6VLpV+pxZKZaFwlATbTUzeyqKazaqiDCO5WEQwZzKCagkwr8obWc" + "qjb0PsYKvRSe1iErKQTTj3lYdc3HLBl68kyM4L340u19M5j4LiPs+zybjgC1gclvMNJFn104vB2P5L/TpW/kVNkqvBrug/" + "+mjVikeP224y4/P7CdK6Nl9rC9JBTDihySi5vIbkFw==" +) +REDIRECT_SIGNATURE = ( + "UlOe1BItHVHM+io6rUZAenIqfibm7hM6wr9I1rcP5kPJ4N8cbkyqmAMh5LD2lUq3PDERJfjdO/oOKnvJmbD2y9MOObyR2d" + "7Udv62KERrA0qM917Q+w8wrLX7w2nHY96EDvkXD4iAomR5EE9dHRuubDy7uRv2syEevc0gfoLi7W/5vp96vJgsaSqxnTp+" + "QiYq49KyWyMtxRULF2yd+vYDnHCDME73mNSULEHfwCU71dvbKpnFaej78q7wS20gUk6ysOOXXtvDHbiVcpUb/9oyDgNAxU" + "jVvPdh96AhBFj2HCuGZhP0CGotafTciu6YlsiwUpuBkIYgZmNWYa3FR9LS4Q==" +) +REDIRECT_SIG_ALG = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" +REDIRECT_RELAY_STATE = ( + "ss:mem:7a054b4af44f34f89dd2d973f383c250b6b076e7f06cfa8276008a6504eaf3c7" +) +REDIRECT_CERT = """-----BEGIN CERTIFICATE----- +MIIDCDCCAfCgAwIBAgIRAM5s+bhOHk4ChSpPkGSh0NswDQYJKoZIhvcNAQELBQAw +KzEpMCcGA1UEAwwgcGFzc2Jvb2sgU2VsZi1zaWduZWQgQ2VydGlmaWNhdGUwHhcN +MjAxMTA3MjAzNDIxWhcNMjExMTA4MjAzNDIxWjBUMSkwJwYDVQQDDCBwYXNzYm9v +ayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTERMA8GA1UECgwIcGFzc2Jvb2sxFDAS +BgNVBAsMC1NlbGYtc2lnbmVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAuh+Bv6a/ogpic72X/sq86YiLzVjixnGqjc4wpsPPP00GX8jUAZJL4Tjo+sYK +IU2DF2/azlVqjkbLho4rGuuc8YkbFXBEXPYc5h3bseO2vk6sbbbWKV0mro1VFhBh +T59hBORuMMefmQdhFzsRNOGklIptQdg0quD8ET3+/uNfIT98S2ruZdYteFls46Sa +MokZFYVD6pWEYV4P2MKVAFqJX9bqBW0LfCCfFqHAOJjUZj9dtleg86d2WfedUOG2 +LK0iLrydjhThbI0GUDhv0jWYkRlv04fdJ1WSRANYA3gBOnyw+Iigh2xNnYbVZMXT +I0BupIJ4UoODMc4QpD2GYJ6oGwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCCEF3e +Y99KxEBSR4H4/TvKbnh4QtHswOf7MaGdjtrld7l4u4Hc4NEklNdDn1XLKhZwnq3Z +LRsRlJutDzZ18SRmAJPXPbka7z7D+LA1mbNQElOgiKyQHD9rIJSBr6X5SM9As3CR +7QUsb8dg7kc+Jn7WuLZIEVxxMtekt0buWEdMJiklF0tCS3LNsP083FaQk/H1K0z6 +3PWP26EFdwir3RyTKLY5CBLjKrUAo9O1l/WBVFYbdetnipbGGu5f6nk6nnxbwLLI +Dm52Vkq+xFDDUq9IqIoYvLaE86MDvtpMQEx65tIGU19vUf3fL/+sSfdRZ1HDzP4d +qNAZMq1DqpibfCBg +-----END CERTIFICATE-----""" + + +def dummy_get_response(request: HttpRequest): # pragma: no cover + """Dummy get_response for SessionMiddleware""" + return None + + +class TestAuthNRequest(TestCase): + """Test AuthN Request generator and parser""" + + def setUp(self): + cert = CertificateKeyPair.objects.first() + self.provider: SAMLProvider = SAMLProvider.objects.create( + authorization_flow=Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ), + acs_url="http://testserver/source/saml/provider/acs/", + signing_kp=cert, + verification_kp=cert, + ) + self.provider.property_mappings.set(SAMLPropertyMapping.objects.all()) + self.provider.save() + self.source = SAMLSource.objects.create( + slug="provider", + issuer="authentik", + signing_kp=cert, + ) + self.factory = RequestFactory() + + def test_signed_valid(self): + """Test generated AuthNRequest with valid signature""" + http_request = self.factory.get("/") + + middleware = SessionMiddleware(dummy_get_response) + middleware.process_request(http_request) + http_request.session.save() + + # First create an AuthNRequest + request_proc = RequestProcessor(self.source, http_request, "test_state") + request = request_proc.build_auth_n() + # Now we check the ID and signature + parsed_request = AuthNRequestParser(self.provider).parse( + b64encode(request.encode()).decode(), "test_state" + ) + self.assertEqual(parsed_request.id, request_proc.request_id) + self.assertEqual(parsed_request.relay_state, "test_state") + + def test_request_full_signed(self): + """Test full SAML Request/Response flow, fully signed""" + http_request = self.factory.get("/") + http_request.user = get_anonymous_user() + + middleware = SessionMiddleware(dummy_get_response) + middleware.process_request(http_request) + http_request.session.save() + + # First create an AuthNRequest + request_proc = RequestProcessor(self.source, http_request, "test_state") + request = request_proc.build_auth_n() + + # To get an assertion we need a parsed request (parsed by provider) + parsed_request = AuthNRequestParser(self.provider).parse( + b64encode(request.encode()).decode(), "test_state" + ) + # Now create a response and convert it to string (provider) + response_proc = AssertionProcessor(self.provider, http_request, parsed_request) + response = response_proc.build_response() + + # Now parse the response (source) + http_request.POST = QueryDict(mutable=True) + http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() + + response_parser = ResponseProcessor(self.source) + response_parser.parse(http_request) + + def test_request_id_invalid(self): + """Test generated AuthNRequest with invalid request ID""" + http_request = self.factory.get("/") + http_request.user = get_anonymous_user() + + middleware = SessionMiddleware(dummy_get_response) + middleware.process_request(http_request) + http_request.session.save() + + # First create an AuthNRequest + request_proc = RequestProcessor(self.source, http_request, "test_state") + request = request_proc.build_auth_n() + + # change the request ID + http_request.session[SESSION_REQUEST_ID] = "test" + http_request.session.save() + + # To get an assertion we need a parsed request (parsed by provider) + parsed_request = AuthNRequestParser(self.provider).parse( + b64encode(request.encode()).decode(), "test_state" + ) + # Now create a response and convert it to string (provider) + response_proc = AssertionProcessor(self.provider, http_request, parsed_request) + response = response_proc.build_response() + + # Now parse the response (source) + http_request.POST = QueryDict(mutable=True) + http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() + + response_parser = ResponseProcessor(self.source) + + with self.assertRaises(MismatchedRequestID): + response_parser.parse(http_request) + + def test_signed_valid_detached(self): + """Test generated AuthNRequest with valid signature (detached)""" + http_request = self.factory.get("/") + + middleware = SessionMiddleware(dummy_get_response) + middleware.process_request(http_request) + http_request.session.save() + + # First create an AuthNRequest + request_proc = RequestProcessor(self.source, http_request, "test_state") + params = request_proc.build_auth_n_detached() + # Now we check the ID and signature + parsed_request = AuthNRequestParser(self.provider).parse_detached( + params["SAMLRequest"], + params["RelayState"], + params["Signature"], + params["SigAlg"], + ) + self.assertEqual(parsed_request.id, request_proc.request_id) + self.assertEqual(parsed_request.relay_state, "test_state") + + def test_signed_detached_static(self): + """Test request with detached signature, + taken from https://www.samltool.com/generic_sso_req.php""" + static_keypair = CertificateKeyPair.objects.create( + name="samltool", certificate_data=REDIRECT_CERT + ) + provider = SAMLProvider( + name="samltool", + authorization_flow=Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ), + acs_url="https://10.120.20.200/saml-sp/SAML2/POST", + audience="https://10.120.20.200/saml-sp/SAML2/POST", + issuer="https://10.120.20.200/saml-sp/SAML2/POST", + signing_kp=static_keypair, + verification_kp=static_keypair, + ) + parsed_request = AuthNRequestParser(provider).parse_detached( + REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG + ) + self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab") + self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL) + self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE) diff --git a/authentik/providers/saml/tests/test_utils_time.py b/authentik/providers/saml/tests/test_utils_time.py new file mode 100644 index 00000000..419d205e --- /dev/null +++ b/authentik/providers/saml/tests/test_utils_time.py @@ -0,0 +1,27 @@ +"""Test time utils""" +from datetime import timedelta + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator + + +class TestTimeUtils(TestCase): + """Test time-utils""" + + def test_valid(self): + """Test valid expression""" + expr = "hours=3;minutes=1" + expected = timedelta(hours=3, minutes=1) + self.assertEqual(timedelta_from_string(expr), expected) + + def test_invalid(self): + """Test invalid expression""" + with self.assertRaises(ValueError): + timedelta_from_string("foo") + + def test_validation(self): + """Test Django model field validator""" + with self.assertRaises(ValidationError): + timedelta_string_validator("foo") diff --git a/authentik/providers/saml/urls.py b/authentik/providers/saml/urls.py new file mode 100644 index 00000000..2f3ce3ec --- /dev/null +++ b/authentik/providers/saml/urls.py @@ -0,0 +1,29 @@ +"""authentik SAML IDP URLs""" +from django.urls import path + +from authentik.providers.saml import views + +urlpatterns = [ + # SSO Bindings + path( + "/sso/binding/redirect/", + views.SAMLSSOBindingRedirectView.as_view(), + name="sso-redirect", + ), + path( + "/sso/binding/post/", + views.SAMLSSOBindingPOSTView.as_view(), + name="sso-post", + ), + # SSO IdP Initiated + path( + "/sso/binding/init/", + views.SAMLSSOBindingInitView.as_view(), + name="sso-init", + ), + path( + "/metadata/", + views.DescriptorDownloadView.as_view(), + name="metadata", + ), +] diff --git a/passbook/providers/saml/utils/__init__.py b/authentik/providers/saml/utils/__init__.py similarity index 100% rename from passbook/providers/saml/utils/__init__.py rename to authentik/providers/saml/utils/__init__.py diff --git a/passbook/providers/saml/utils/encoding.py b/authentik/providers/saml/utils/encoding.py similarity index 100% rename from passbook/providers/saml/utils/encoding.py rename to authentik/providers/saml/utils/encoding.py diff --git a/passbook/providers/saml/utils/time.py b/authentik/providers/saml/utils/time.py similarity index 100% rename from passbook/providers/saml/utils/time.py rename to authentik/providers/saml/utils/time.py diff --git a/authentik/providers/saml/views.py b/authentik/providers/saml/views.py new file mode 100644 index 00000000..dd347d65 --- /dev/null +++ b/authentik/providers/saml/views.py @@ -0,0 +1,239 @@ +"""authentik SAML IDP Views""" +from typing import Optional + +from django.core.validators import URLValidator +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.decorators import method_decorator +from django.utils.http import urlencode +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from structlog import get_logger + +from authentik.audit.models import Event, EventAction +from authentik.core.models import Application, Provider +from authentik.flows.models import in_memory_stage +from authentik.flows.planner import ( + PLAN_CONTEXT_APPLICATION, + PLAN_CONTEXT_SSO, + FlowPlanner, +) +from authentik.flows.stage import StageView +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.lib.utils.urls import redirect_with_qs +from authentik.lib.views import bad_request_message +from authentik.policies.views import PolicyAccessView +from authentik.providers.saml.exceptions import CannotHandleAssertion +from authentik.providers.saml.models import SAMLBindings, SAMLProvider +from authentik.providers.saml.processors.assertion import AssertionProcessor +from authentik.providers.saml.processors.metadata import MetadataProcessor +from authentik.providers.saml.processors.request_parser import ( + AuthNRequest, + AuthNRequestParser, +) +from authentik.providers.saml.utils.encoding import deflate_and_base64_encode, nice64 +from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE + +LOGGER = get_logger() +URL_VALIDATOR = URLValidator(schemes=("http", "https")) +REQUEST_KEY_SAML_REQUEST = "SAMLRequest" +REQUEST_KEY_SAML_SIGNATURE = "Signature" +REQUEST_KEY_SAML_SIG_ALG = "SigAlg" +REQUEST_KEY_SAML_RESPONSE = "SAMLResponse" +REQUEST_KEY_RELAY_STATE = "RelayState" + +SESSION_KEY_AUTH_N_REQUEST = "authn_request" + + +class SAMLSSOView(PolicyAccessView): + """ "SAML SSO Base View, which plans a flow and injects our final stage. + Calls get/post handler.""" + + def resolve_provider_application(self): + self.application = get_object_or_404( + Application, slug=self.kwargs["application_slug"] + ) + self.provider: SAMLProvider = get_object_or_404( + SAMLProvider, pk=self.application.provider_id + ) + + def check_saml_request(self) -> Optional[HttpRequest]: + """Handler to verify the SAML Request. Must be implemented by a subclass""" + raise NotImplementedError + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: + """Verify the SAML Request, and if valid initiate the FlowPlanner for the application""" + # Call the method handler, which checks the SAML + # Request and returns a HTTP Response on error + method_response = self.check_saml_request() + if method_response: + return method_response + # Regardless, we start the planner and return to it + planner = FlowPlanner(self.provider.authorization_flow) + planner.allow_empty_flows = True + plan = planner.plan( + request, + { + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_APPLICATION: self.application, + PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html", + }, + ) + plan.append(in_memory_stage(SAMLFlowFinalView)) + request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_flows:flow-executor-shell", + request.GET, + flow_slug=self.provider.authorization_flow.slug, + ) + + def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: + """GET and POST use the same handler, but we can't + override .dispatch easily because PolicyAccessView's dispatch""" + return self.get(request, application_slug) + + +class SAMLSSOBindingRedirectView(SAMLSSOView): + """SAML Handler for SSO/Redirect bindings, which are sent via GET""" + + def check_saml_request(self) -> Optional[HttpRequest]: + """Handle REDIRECT bindings""" + if REQUEST_KEY_SAML_REQUEST not in self.request.GET: + LOGGER.info("handle_saml_request: SAML payload missing") + return bad_request_message( + self.request, "The SAML request payload is missing." + ) + + try: + auth_n_request = AuthNRequestParser(self.provider).parse_detached( + self.request.GET[REQUEST_KEY_SAML_REQUEST], + self.request.GET.get(REQUEST_KEY_RELAY_STATE), + self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE), + self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG), + ) + self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request + except CannotHandleAssertion as exc: + LOGGER.info(exc) + return bad_request_message(self.request, str(exc)) + return None + + +@method_decorator(csrf_exempt, name="dispatch") +class SAMLSSOBindingPOSTView(SAMLSSOView): + """SAML Handler for SSO/POST bindings""" + + def check_saml_request(self) -> Optional[HttpRequest]: + """Handle POST bindings""" + if REQUEST_KEY_SAML_REQUEST not in self.request.POST: + LOGGER.info("check_saml_request: SAML payload missing") + return bad_request_message( + self.request, "The SAML request payload is missing." + ) + + try: + auth_n_request = AuthNRequestParser(self.provider).parse( + self.request.POST[REQUEST_KEY_SAML_REQUEST], + self.request.POST.get(REQUEST_KEY_RELAY_STATE), + ) + self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request + except CannotHandleAssertion as exc: + LOGGER.info(exc) + return bad_request_message(self.request, str(exc)) + return None + + +class SAMLSSOBindingInitView(SAMLSSOView): + """SAML Handler for for IdP Initiated login flows""" + + def check_saml_request(self) -> Optional[HttpRequest]: + """Create SAML Response from scratch""" + LOGGER.debug( + "handle_saml_no_request: No SAML Request, using IdP-initiated flow." + ) + auth_n_request = AuthNRequestParser(self.provider).idp_initiated() + self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request + + +# This View doesn't have a URL on purpose, as its called by the FlowExecutor +class SAMLFlowFinalView(StageView): + """View used by FlowExecutor after all stages have passed. Logs the authorization, + and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for + (if POST is configured).""" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + provider: SAMLProvider = get_object_or_404( + SAMLProvider, pk=application.provider_id + ) + # Log Application Authorization + Event.new( + EventAction.AUTHORIZE_APPLICATION, + authorized_application=application, + flow=self.executor.plan.flow_pk, + ).from_http(self.request) + + if SESSION_KEY_AUTH_N_REQUEST not in self.request.session: + return self.executor.stage_invalid() + + auth_n_request: AuthNRequest = self.request.session.pop( + SESSION_KEY_AUTH_N_REQUEST + ) + response = AssertionProcessor( + provider, request, auth_n_request + ).build_response() + + if provider.sp_binding == SAMLBindings.POST: + form_attrs = { + "ACSUrl": provider.acs_url, + REQUEST_KEY_SAML_RESPONSE: nice64(response), + } + if auth_n_request.relay_state: + form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state + return render( + self.request, + "generic/autosubmit_form.html", + { + "url": provider.acs_url, + "title": _("Redirecting to %(app)s..." % {"app": application.name}), + "attrs": form_attrs, + }, + ) + if provider.sp_binding == SAMLBindings.REDIRECT: + url_args = { + REQUEST_KEY_SAML_RESPONSE: deflate_and_base64_encode(response), + } + if auth_n_request.relay_state: + url_args[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state + querystring = urlencode(url_args) + return redirect(f"{provider.acs_url}?{querystring}") + return bad_request_message(request, "Invalid sp_binding specified") + + +class DescriptorDownloadView(View): + """Replies with the XML Metadata IDSSODescriptor.""" + + @staticmethod + def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str: + """Return rendered XML Metadata""" + return MetadataProcessor(provider, request).build_entity_descriptor() + + def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: + """Replies with the XML Metadata IDSSODescriptor.""" + application = get_object_or_404(Application, slug=application_slug) + provider: SAMLProvider = get_object_or_404( + SAMLProvider, pk=application.provider_id + ) + try: + metadata = DescriptorDownloadView.get_metadata(request, provider) + except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member + return bad_request_message( + request, "Provider is not assigned to an application." + ) + else: + response = HttpResponse(metadata, content_type="application/xml") + response[ + "Content-Disposition" + ] = f'attachment; filename="{provider.name}_authentik_meta.xml"' + return response diff --git a/passbook/recovery/__init__.py b/authentik/recovery/__init__.py similarity index 100% rename from passbook/recovery/__init__.py rename to authentik/recovery/__init__.py diff --git a/authentik/recovery/apps.py b/authentik/recovery/apps.py new file mode 100644 index 00000000..e9f33fd6 --- /dev/null +++ b/authentik/recovery/apps.py @@ -0,0 +1,11 @@ +"""authentik Recovery app config""" +from django.apps import AppConfig + + +class AuthentikRecoveryConfig(AppConfig): + """authentik Recovery app config""" + + name = "authentik.recovery" + label = "authentik_recovery" + verbose_name = "authentik Recovery" + mountpoint = "recovery/" diff --git a/passbook/recovery/management/__init__.py b/authentik/recovery/management/__init__.py similarity index 100% rename from passbook/recovery/management/__init__.py rename to authentik/recovery/management/__init__.py diff --git a/passbook/recovery/management/commands/__init__.py b/authentik/recovery/management/commands/__init__.py similarity index 100% rename from passbook/recovery/management/commands/__init__.py rename to authentik/recovery/management/commands/__init__.py diff --git a/authentik/recovery/management/commands/create_recovery_key.py b/authentik/recovery/management/commands/create_recovery_key.py new file mode 100644 index 00000000..19ce7c95 --- /dev/null +++ b/authentik/recovery/management/commands/create_recovery_key.py @@ -0,0 +1,54 @@ +"""authentik recovery createkey command""" +from datetime import timedelta +from getpass import getuser + +from django.core.management.base import BaseCommand +from django.urls import reverse +from django.utils.timezone import now +from django.utils.translation import gettext as _ +from structlog import get_logger + +from authentik.core.models import Token, TokenIntents, User + +LOGGER = get_logger() + + +class Command(BaseCommand): + """Create Token used to recover access""" + + help = _("Create a Key which can be used to restore access to authentik.") + + def add_arguments(self, parser): + parser.add_argument( + "duration", + default=1, + action="store", + help="How long the token is valid for (in years).", + ) + parser.add_argument( + "user", action="store", help="Which user the Token gives access to." + ) + + def get_url(self, token: Token) -> str: + """Get full recovery link""" + return reverse("authentik_recovery:use-token", kwargs={"key": str(token.key)}) + + def handle(self, *args, **options): + """Create Token used to recover access""" + duration = int(options.get("duration", 1)) + _now = now() + expiry = _now + timedelta(days=duration * 365.2425) + user = User.objects.get(username=options.get("user")) + token = Token.objects.create( + expires=expiry, + user=user, + intent=TokenIntents.INTENT_RECOVERY, + description=f"Recovery Token generated by {getuser()} on {_now}", + ) + self.stdout.write( + ( + f"Store this link safely, as it will allow" + f" anyone to access authentik as {user}." + ) + ) + self.stdout.write(self.get_url(token)) diff --git a/authentik/recovery/tests.py b/authentik/recovery/tests.py new file mode 100644 index 00000000..f696c2c9 --- /dev/null +++ b/authentik/recovery/tests.py @@ -0,0 +1,34 @@ +"""recovery tests""" +from io import StringIO + +from django.core.management import call_command +from django.shortcuts import reverse +from django.test import TestCase + +from authentik.core.models import Token, TokenIntents, User + + +class TestRecovery(TestCase): + """recovery tests""" + + def setUp(self): + self.user = User.objects.create_user(username="recovery-test-user") + + def test_create_key(self): + """Test creation of a new key""" + out = StringIO() + self.assertEqual(len(Token.objects.all()), 0) + call_command("create_recovery_key", "1", self.user.username, stdout=out) + token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user) + self.assertIn(token.key, out.getvalue()) + self.assertEqual(len(Token.objects.all()), 1) + + def test_recovery_view(self): + """Test recovery view""" + out = StringIO() + call_command("create_recovery_key", "1", self.user.username, stdout=out) + token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user) + self.client.get( + reverse("authentik_recovery:use-token", kwargs={"key": token.key}) + ) + self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk) diff --git a/authentik/recovery/urls.py b/authentik/recovery/urls.py new file mode 100644 index 00000000..6e761442 --- /dev/null +++ b/authentik/recovery/urls.py @@ -0,0 +1,9 @@ +"""recovery views""" + +from django.urls import path + +from authentik.recovery.views import UseTokenView + +urlpatterns = [ + path("use-token//", UseTokenView.as_view(), name="use-token"), +] diff --git a/authentik/recovery/views.py b/authentik/recovery/views.py new file mode 100644 index 00000000..b0c1978c --- /dev/null +++ b/authentik/recovery/views.py @@ -0,0 +1,24 @@ +"""recovery views""" +from django.contrib import messages +from django.contrib.auth import login +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import redirect +from django.utils.translation import gettext as _ +from django.views import View + +from authentik.core.models import Token, TokenIntents + + +class UseTokenView(View): + """Use token to login""" + + def get(self, request: HttpRequest, key: str) -> HttpResponse: + """Check if token exists, log user in and delete token.""" + tokens = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_RECOVERY) + if not tokens.exists(): + raise Http404 + token = tokens.first() + login(request, token.user, backend="django.contrib.auth.backends.ModelBackend") + token.delete() + messages.warning(request, _("Used recovery-link to authenticate.")) + return redirect("authentik_core:shell") diff --git a/passbook/root/__init__.py b/authentik/root/__init__.py similarity index 100% rename from passbook/root/__init__.py rename to authentik/root/__init__.py diff --git a/authentik/root/asgi.py b/authentik/root/asgi.py new file mode 100644 index 00000000..454daffb --- /dev/null +++ b/authentik/root/asgi.py @@ -0,0 +1,148 @@ +""" +ASGI config for authentik project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" +import typing +from time import time +from typing import Any, ByteString, Dict + +import django +from asgiref.compatibility import guarantee_single_callable +from channels.routing import ProtocolTypeRouter, URLRouter +from defusedxml import defuse_stdlib +from django.core.asgi import get_asgi_application +from sentry_sdk.integrations.asgi import SentryAsgiMiddleware +from structlog import get_logger + +# DJANGO_SETTINGS_MODULE is set in gunicorn.conf.py + +defuse_stdlib() +django.setup() + +# pylint: disable=wrong-import-position +from authentik.root import websocket # noqa # isort:skip + + +# See https://github.com/encode/starlette/blob/master/starlette/types.py +Scope = typing.MutableMapping[str, typing.Any] +Message = typing.MutableMapping[str, typing.Any] + +Receive = typing.Callable[[], typing.Awaitable[Message]] +Send = typing.Callable[[Message], typing.Awaitable[None]] + +ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]] + +ASGI_IP_HEADERS = ( + b"x-forwarded-for", + b"x-real-ip", +) + +LOGGER = get_logger("authentik.asgi") + + +class ASGILoggerMiddleware: + """Main ASGI Logger middleware, starts an ASGILogger for each request""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + responder = ASGILogger(self.app) + await responder(scope, receive, send) + return + + +class ASGILogger: + """ASGI Logger, instantiated for each request""" + + app: ASGIApp + + scope: Scope + headers: Dict[ByteString, Any] + + status_code: int + start: float + content_length: int + + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + self.scope = scope + self.content_length = 0 + self.headers = dict(scope.get("headers", [])) + + async def send_hooked(message: Message) -> None: + """Hooked send method, which records status code and content-length, and for the final + requests logs it""" + headers = dict(message.get("headers", [])) + + if "status" in message: + self.status_code = message["status"] + + if b"Content-Length" in headers: + self.content_length += int(headers.get(b"Content-Length", b"0")) + + if message["type"] == "http.response.body" and not message.get( + "more_body", None + ): + runtime = int((time() - self.start) * 10 ** 6) + self.log(runtime) + await send(message) + + if self.headers.get(b"host", b"") == b"authentik-healthcheck-host": + # Don't log healthcheck/readiness requests + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": ""}) + return + + self.start = time() + if scope["type"] == "lifespan": + # https://code.djangoproject.com/ticket/31508 + # https://github.com/encode/uvicorn/issues/266 + return + await self.app(scope, receive, send_hooked) + + def _get_ip(self) -> str: + client_ip = None + for header in ASGI_IP_HEADERS: + if header in self.headers: + client_ip = self.headers[header].decode() + if not client_ip: + client_ip, _ = self.scope.get("client", ("", 0)) + # Check if header has multiple values, and use the first one + return client_ip.split(", ")[0] + + def log(self, runtime: float): + """Outpot access logs in a structured format""" + host = self._get_ip() + query_string = "" + if self.scope.get("query_string", b"") != b"": + query_string = f"?{self.scope.get('query_string').decode()}" + LOGGER.info( + f"{self.scope.get('path', '')}{query_string}", + host=host, + method=self.scope.get("method", ""), + scheme=self.scope.get("scheme", ""), + status=self.status_code, + size=self.content_length / 1000 if self.content_length > 0 else "-", + runtime=runtime, + ) + + +application = ASGILogger( + guarantee_single_callable( + SentryAsgiMiddleware( + ProtocolTypeRouter( + { + "http": get_asgi_application(), + "websocket": URLRouter(websocket.websocket_urlpatterns), + } + ) + ) + ) +) diff --git a/authentik/root/celery.py b/authentik/root/celery.py new file mode 100644 index 00000000..28443eba --- /dev/null +++ b/authentik/root/celery.py @@ -0,0 +1,55 @@ +"""authentik core celery""" +import os +from logging.config import dictConfig + +from celery import Celery +from celery.signals import after_task_publish, setup_logging, task_postrun, task_prerun +from django.conf import settings +from structlog import get_logger + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") + +LOGGER = get_logger() +CELERY_APP = Celery("authentik") + + +# pylint: disable=unused-argument +@setup_logging.connect +def config_loggers(*args, **kwags): + """Apply logging settings from settings.py to celery""" + dictConfig(settings.LOGGING) + + +# pylint: disable=unused-argument +@after_task_publish.connect +def after_task_publish(sender=None, headers=None, body=None, **kwargs): + """Log task_id after it was published""" + info = headers if "task" in headers else body + LOGGER.debug( + "Task published", task_id=info.get("id", ""), task_name=info.get("task", "") + ) + + +# pylint: disable=unused-argument +@task_prerun.connect +def task_prerun(task_id, task, *args, **kwargs): + """Log task_id on worker""" + LOGGER.debug("Task started", task_id=task_id, task_name=task.__name__) + + +# pylint: disable=unused-argument +@task_postrun.connect +def task_postrun(task_id, task, *args, retval=None, state=None, **kwargs): + """Log task_id on worker""" + LOGGER.debug("Task finished", task_id=task_id, task_name=task.__name__, state=state) + + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +CELERY_APP.config_from_object(settings, namespace="CELERY") + +# Load task modules from all registered Django app configs. +CELERY_APP.autodiscover_tasks() diff --git a/passbook/root/messages/__init__.py b/authentik/root/messages/__init__.py similarity index 100% rename from passbook/root/messages/__init__.py rename to authentik/root/messages/__init__.py diff --git a/passbook/root/messages/consumer.py b/authentik/root/messages/consumer.py similarity index 100% rename from passbook/root/messages/consumer.py rename to authentik/root/messages/consumer.py diff --git a/passbook/root/messages/storage.py b/authentik/root/messages/storage.py similarity index 100% rename from passbook/root/messages/storage.py rename to authentik/root/messages/storage.py diff --git a/authentik/root/monitoring.py b/authentik/root/monitoring.py new file mode 100644 index 00000000..1ffa0d87 --- /dev/null +++ b/authentik/root/monitoring.py @@ -0,0 +1,25 @@ +"""Metrics view""" +from base64 import b64encode + +from django.conf import settings +from django.http import HttpRequest, HttpResponse +from django.views import View +from django_prometheus.exports import ExportToDjangoView + + +class MetricsView(View): + """Wrapper around ExportToDjangoView, using http-basic auth""" + + def get(self, request: HttpRequest) -> HttpResponse: + """Check for HTTP-Basic auth""" + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + auth_type, _, given_credentials = auth_header.partition(" ") + credentials = f"monitor:{settings.SECRET_KEY}" + expected = b64encode(str.encode(credentials)).decode() + + if auth_type != "Basic" or given_credentials != expected: + response = HttpResponse(status=401) + response["WWW-Authenticate"] = 'Basic realm="authentik-monitoring"' + return response + + return ExportToDjangoView(request) diff --git a/authentik/root/settings.py b/authentik/root/settings.py new file mode 100644 index 00000000..9f90076f --- /dev/null +++ b/authentik/root/settings.py @@ -0,0 +1,460 @@ +""" +Django settings for authentik project. + +Generated by 'django-admin startproject' using Django 2.1.3. + +For more information on this file, see +https://docs.djangoproject.com/en/2.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.1/ref/settings/ +""" + +import importlib +import os +import sys +from json import dumps +from time import time + +import structlog +from celery.schedules import crontab +from sentry_sdk import init as sentry_init +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +from authentik import __version__ +from authentik.core.middleware import structlog_add_request_id +from authentik.lib.config import CONFIG +from authentik.lib.logging import add_common_fields, add_process_id +from authentik.lib.sentry import before_send + + +def j_print(event: str, log_level: str = "info", **kwargs): + """Print event in the same format as structlog with JSON. + Used before structlog is configured.""" + data = { + "event": event, + "level": log_level, + "logger": __name__, + "timestamp": time(), + } + data.update(**kwargs) + print(dumps(data), file=sys.stderr) + + +LOGGER = structlog.get_logger() + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +STATIC_ROOT = BASE_DIR + "/static" +STATICFILES_DIRS = [BASE_DIR + "/web"] +MEDIA_ROOT = BASE_DIR + "/media" + +SECRET_KEY = CONFIG.y( + "secret_key", "9$@r!d^1^jrn#fk#1#@ks#9&i$^s#1)_13%$rwjrhd=e8jfi_s" +) # noqa Debug + +DEBUG = CONFIG.y_bool("debug") +INTERNAL_IPS = ["127.0.0.1"] +ALLOWED_HOSTS = ["*"] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +LOGIN_URL = "authentik_flows:default-authentication" + +# Custom user model +AUTH_USER_MODEL = "authentik_core.User" + +_cookie_suffix = "_debug" if DEBUG else "" +CSRF_COOKIE_NAME = "authentik_csrf" +LANGUAGE_COOKIE_NAME = f"authentik_language{_cookie_suffix}" +SESSION_COOKIE_NAME = f"authentik_session{_cookie_suffix}" + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "guardian.backends.ObjectPermissionBackend", +] + +# Application definition +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "authentik.admin.apps.AuthentikAdminConfig", + "authentik.api.apps.AuthentikAPIConfig", + "authentik.audit.apps.AuthentikAuditConfig", + "authentik.crypto.apps.AuthentikCryptoConfig", + "authentik.flows.apps.AuthentikFlowsConfig", + "authentik.outposts.apps.AuthentikOutpostConfig", + "authentik.lib.apps.AuthentikLibConfig", + "authentik.policies.apps.AuthentikPoliciesConfig", + "authentik.policies.dummy.apps.AuthentikPolicyDummyConfig", + "authentik.policies.expiry.apps.AuthentikPolicyExpiryConfig", + "authentik.policies.expression.apps.AuthentikPolicyExpressionConfig", + "authentik.policies.hibp.apps.AuthentikPolicyHIBPConfig", + "authentik.policies.password.apps.AuthentikPoliciesPasswordConfig", + "authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig", + "authentik.policies.reputation.apps.AuthentikPolicyReputationConfig", + "authentik.providers.proxy.apps.AuthentikProviderProxyConfig", + "authentik.providers.oauth2.apps.AuthentikProviderOAuth2Config", + "authentik.providers.saml.apps.AuthentikProviderSAMLConfig", + "authentik.recovery.apps.AuthentikRecoveryConfig", + "authentik.sources.ldap.apps.AuthentikSourceLDAPConfig", + "authentik.sources.oauth.apps.AuthentikSourceOAuthConfig", + "authentik.sources.saml.apps.AuthentikSourceSAMLConfig", + "authentik.stages.captcha.apps.AuthentikStageCaptchaConfig", + "authentik.stages.consent.apps.AuthentikStageConsentConfig", + "authentik.stages.dummy.apps.AuthentikStageDummyConfig", + "authentik.stages.email.apps.AuthentikStageEmailConfig", + "authentik.stages.prompt.apps.AuthentikStagPromptConfig", + "authentik.stages.identification.apps.AuthentikStageIdentificationConfig", + "authentik.stages.invitation.apps.AuthentikStageUserInvitationConfig", + "authentik.stages.user_delete.apps.AuthentikStageUserDeleteConfig", + "authentik.stages.user_login.apps.AuthentikStageUserLoginConfig", + "authentik.stages.user_logout.apps.AuthentikStageUserLogoutConfig", + "authentik.stages.user_write.apps.AuthentikStageUserWriteConfig", + "authentik.stages.otp_static.apps.AuthentikStageOTPStaticConfig", + "authentik.stages.otp_time.apps.AuthentikStageOTPTimeConfig", + "authentik.stages.otp_validate.apps.AuthentikStageOTPValidateConfig", + "authentik.stages.password.apps.AuthentikStagePasswordConfig", + "rest_framework", + "django_filters", + "drf_yasg2", + "guardian", + "django_prometheus", + "channels", + "dbbackup", +] + +GUARDIAN_MONKEY_PATCH = False + +SWAGGER_SETTINGS = { + "DEFAULT_INFO": "authentik.api.v2.urls.info", + "SECURITY_DEFINITIONS": { + "token": {"type": "apiKey", "name": "Authorization", "in": "header"} + }, +} + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "authentik.api.pagination.Pagination", + "PAGE_SIZE": 100, + "DEFAULT_FILTER_BACKENDS": [ + "rest_framework_guardian.filters.ObjectPermissionsFilter", + "django_filters.rest_framework.DjangoFilterBackend", + "rest_framework.filters.OrderingFilter", + "rest_framework.filters.SearchFilter", + ], + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.DjangoObjectPermissions", + ), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "authentik.api.auth.AuthentikTokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + ), +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": ( + f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" + f"/{CONFIG.y('redis.cache_db')}" + ), + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + } +} +DJANGO_REDIS_IGNORE_EXCEPTIONS = True +DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" +SESSION_COOKIE_SAMESITE = "lax" + +MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage" + +MIDDLEWARE = [ + "django_prometheus.middleware.PrometheusBeforeMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "authentik.core.middleware.RequestIDMiddleware", + "authentik.audit.middleware.AuditMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "authentik.core.middleware.ImpersonateMiddleware", + "django_prometheus.middleware.PrometheusAfterMiddleware", +] + +ROOT_URLCONF = "authentik.root.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "authentik.lib.config.context_processor", + ], + }, + }, +] + +ASGI_APPLICATION = "authentik.root.asgi.application" + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [ + f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" + f"/{CONFIG.y('redis.ws_db')}" + ], + }, + }, +} + + +# Database +# https://docs.djangoproject.com/en/2.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": CONFIG.y("postgresql.host"), + "NAME": CONFIG.y("postgresql.name"), + "USER": CONFIG.y("postgresql.user"), + "PASSWORD": CONFIG.y("postgresql.password"), + } +} + +# Password validation +# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + + +# Internationalization +# https://docs.djangoproject.com/en/2.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Celery settings +# Add a 10 minute timeout to all Celery tasks. +CELERY_TASK_SOFT_TIME_LIMIT = 600 +CELERY_BEAT_SCHEDULE = { + "clean_expired_models": { + "task": "authentik.core.tasks.clean_expired_models", + "schedule": crontab(minute="*/5"), + "options": {"queue": "authentik_scheduled"}, + }, + "db_backup": { + "task": "authentik.core.tasks.backup_database", + "schedule": crontab(minute=0, hour=0), + "options": {"queue": "authentik_scheduled"}, + }, +} +CELERY_TASK_CREATE_MISSING_QUEUES = True +CELERY_TASK_DEFAULT_QUEUE = "authentik" +CELERY_BROKER_URL = ( + f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}" + f":6379/{CONFIG.y('redis.message_queue_db')}" +) +CELERY_RESULT_BACKEND = ( + f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}" + f":6379/{CONFIG.y('redis.message_queue_db')}" +) + +# Database backup +DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage" +DBBACKUP_STORAGE_OPTIONS = {"location": "./backups" if DEBUG else "/backups"} +DBBACKUP_CONNECTOR_MAPPING = { + "django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector" +} +if CONFIG.y("postgresql.s3_backup"): + DBBACKUP_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + DBBACKUP_STORAGE_OPTIONS = { + "access_key": CONFIG.y("postgresql.s3_backup.access_key"), + "secret_key": CONFIG.y("postgresql.s3_backup.secret_key"), + "bucket_name": CONFIG.y("postgresql.s3_backup.bucket"), + "region_name": CONFIG.y("postgresql.s3_backup.region", "eu-central-1"), + "default_acl": "private", + "endpoint_url": CONFIG.y("postgresql.s3_backup.host"), + } + j_print( + "Database backup to S3 is configured.", + host=CONFIG.y("postgresql.s3_backup.host"), + ) + +# Sentry integration +_ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False) +if not DEBUG and _ERROR_REPORTING: + sentry_init( + dsn="https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8", + integrations=[ + DjangoIntegration(transaction_style="function_name"), + CeleryIntegration(), + RedisIntegration(), + ], + before_send=before_send, + release="authentik@%s" % __version__, + traces_sample_rate=0.6, + environment=CONFIG.y("error_reporting.environment", "customer"), + send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False), + ) + j_print( + "Error reporting is enabled.", + env=CONFIG.y("error_reporting.environment", "customer"), + ) + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.1/howto/static-files/ + +STATIC_URL = "/static/" +MEDIA_URL = "/media/" + + +structlog.configure_once( + processors=[ + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + add_process_id, + add_common_fields(CONFIG.y("error_reporting.environment", "customer")), + structlog_add_request_id, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + context_class=structlog.threadlocal.wrap_dict(dict), + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, +) + +LOG_PRE_CHAIN = [ + # Add the log level and a timestamp to the event_dict if the log entry + # is not from structlog. + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, +] + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "plain": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.processors.JSONRenderer(sort_keys=True), + "foreign_pre_chain": LOG_PRE_CHAIN, + }, + "colored": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.dev.ConsoleRenderer(colors=DEBUG), + "foreign_pre_chain": LOG_PRE_CHAIN, + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "colored" if DEBUG else "plain", + }, + }, + "loggers": {}, +} + +TEST = False +TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner" +LOG_LEVEL = CONFIG.y("log_level").upper() + + +_LOGGING_HANDLER_MAP = { + "": LOG_LEVEL, + "authentik": LOG_LEVEL, + "django": "WARNING", + "celery": "WARNING", + "selenium": "WARNING", + "grpc": LOG_LEVEL, + "docker": "WARNING", + "urllib3": "WARNING", + "websockets": "WARNING", + "daphne": "WARNING", + "dbbackup": "ERROR", + "kubernetes": "INFO", + "asyncio": "WARNING", +} +for handler_name, level in _LOGGING_HANDLER_MAP.items(): + # pyright: reportGeneralTypeIssues=false + LOGGING["loggers"][handler_name] = { + "handlers": ["console"], + "level": level, + "propagate": False, + } + + +_DISALLOWED_ITEMS = [ + "INSTALLED_APPS", + "MIDDLEWARE", + "AUTHENTICATION_BACKENDS", + "CELERY_BEAT_SCHEDULE", +] +# Load subapps's INSTALLED_APPS +for _app in INSTALLED_APPS: + if _app.startswith("authentik"): + if "apps" in _app: + _app = ".".join(_app.split(".")[:-2]) + try: + app_settings = importlib.import_module("%s.settings" % _app) + INSTALLED_APPS.extend(getattr(app_settings, "INSTALLED_APPS", [])) + MIDDLEWARE.extend(getattr(app_settings, "MIDDLEWARE", [])) + AUTHENTICATION_BACKENDS.extend( + getattr(app_settings, "AUTHENTICATION_BACKENDS", []) + ) + CELERY_BEAT_SCHEDULE.update( + getattr(app_settings, "CELERY_BEAT_SCHEDULE", {}) + ) + for _attr in dir(app_settings): + if not _attr.startswith("__") and _attr not in _DISALLOWED_ITEMS: + globals()[_attr] = getattr(app_settings, _attr) + except ImportError: + pass + +if DEBUG: + INSTALLED_APPS.append("debug_toolbar") + MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") + CELERY_TASK_ALWAYS_EAGER = True + +INSTALLED_APPS.append("authentik.core.apps.AuthentikCoreConfig") + +j_print("Booting authentik", version=__version__) diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py new file mode 100644 index 00000000..7a79577f --- /dev/null +++ b/authentik/root/test_runner.py @@ -0,0 +1,38 @@ +"""Integrate ./manage.py test with pytest""" +from django.conf import settings + +from authentik.lib.config import CONFIG + + +class PytestTestRunner: + """Runs pytest to discover and run tests.""" + + def __init__(self, verbosity=1, failfast=False, keepdb=False, **_): + self.verbosity = verbosity + self.failfast = failfast + self.keepdb = keepdb + settings.TEST = True + settings.CELERY_TASK_ALWAYS_EAGER = True + CONFIG.raw.get("authentik")["avatars"] = "none" + + def run_tests(self, test_labels): + """Run pytest and return the exitcode. + + It translates some of Django's test command option to pytest's. + """ + import pytest + + argv = [] + if self.verbosity == 0: + argv.append("--quiet") + if self.verbosity == 2: + argv.append("--verbose") + if self.verbosity == 3: + argv.append("-vv") + if self.failfast: + argv.append("--exitfirst") + if self.keepdb: + argv.append("--reuse-db") + + argv.extend(test_labels) + return pytest.main(argv) diff --git a/passbook/root/tests.py b/authentik/root/tests.py similarity index 100% rename from passbook/root/tests.py rename to authentik/root/tests.py diff --git a/authentik/root/urls.py b/authentik/root/urls.py new file mode 100644 index 00000000..c5a42798 --- /dev/null +++ b/authentik/root/urls.py @@ -0,0 +1,73 @@ +"""authentik URL Configuration""" +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from django.views.generic import RedirectView +from django.views.i18n import JavaScriptCatalog +from structlog import get_logger + +from authentik.core.views import error +from authentik.lib.utils.reflection import get_apps +from authentik.root.monitoring import MetricsView + +LOGGER = get_logger() +admin.autodiscover() +admin.site.login = RedirectView.as_view( + pattern_name="authentik_flows:default-authentication" +) +admin.site.logout = RedirectView.as_view( + pattern_name="authentik_flows:default-invalidation" +) + +handler400 = error.BadRequestView.as_view() +handler403 = error.ForbiddenView.as_view() +handler404 = error.NotFoundView.as_view() +handler500 = error.ServerErrorView.as_view() + +urlpatterns = [] + +for _authentik_app in get_apps(): + mountpoints = None + base_url_module = _authentik_app.name + ".urls" + if hasattr(_authentik_app, "mountpoint"): + mountpoint = getattr(_authentik_app, "mountpoint") + mountpoints = {base_url_module: mountpoint} + if hasattr(_authentik_app, "mountpoints"): + mountpoints = getattr(_authentik_app, "mountpoints") + if not mountpoints: + continue + for module, mountpoint in mountpoints.items(): + namespace = _authentik_app.label + module.replace(base_url_module, "") + _path = path( + mountpoint, + include( + (module, _authentik_app.label), + namespace=namespace, + ), + ) + urlpatterns.append(_path) + LOGGER.debug( + "Mounted URLs", + app_name=_authentik_app.name, + mountpoint=mountpoint, + namespace=namespace, + ) + +urlpatterns += [ + path("administration/django/", admin.site.urls), + path("metrics/", MetricsView.as_view(), name="metrics"), + path("-/jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), +] + +if settings.DEBUG: + import debug_toolbar + + urlpatterns = ( + [ + path("-/debug/", include(debug_toolbar.urls)), + ] + + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + urlpatterns + ) diff --git a/authentik/root/websocket.py b/authentik/root/websocket.py new file mode 100644 index 00000000..d53b52a1 --- /dev/null +++ b/authentik/root/websocket.py @@ -0,0 +1,11 @@ +"""root Websocket URLS""" +from channels.auth import AuthMiddlewareStack +from django.urls import path + +from authentik.outposts.channels import OutpostConsumer +from authentik.root.messages.consumer import MessageConsumer + +websocket_urlpatterns = [ + path("ws/outpost//", OutpostConsumer.as_asgi()), + path("ws/client/", AuthMiddlewareStack(MessageConsumer.as_asgi())), +] diff --git a/passbook/sources/__init__.py b/authentik/sources/__init__.py similarity index 100% rename from passbook/sources/__init__.py rename to authentik/sources/__init__.py diff --git a/passbook/sources/ldap/__init__.py b/authentik/sources/ldap/__init__.py similarity index 100% rename from passbook/sources/ldap/__init__.py rename to authentik/sources/ldap/__init__.py diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py new file mode 100644 index 00000000..ef622ac3 --- /dev/null +++ b/authentik/sources/ldap/api.py @@ -0,0 +1,54 @@ +"""Source API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource + + +class LDAPSourceSerializer(ModelSerializer): + """LDAP Source Serializer""" + + class Meta: + model = LDAPSource + fields = SOURCE_SERIALIZER_FIELDS + [ + "server_uri", + "bind_cn", + "bind_password", + "start_tls", + "base_dn", + "additional_user_dn", + "additional_group_dn", + "user_object_filter", + "group_object_filter", + "user_group_membership_field", + "object_uniqueness_field", + "sync_users", + "sync_users_password", + "sync_groups", + "sync_parent_group", + "property_mappings", + ] + extra_kwargs = {"bind_password": {"write_only": True}} + + +class LDAPPropertyMappingSerializer(ModelSerializer): + """LDAP PropertyMapping Serializer""" + + class Meta: + model = LDAPPropertyMapping + fields = ["pk", "name", "expression", "object_field"] + + +class LDAPSourceViewSet(ModelViewSet): + """LDAP Source Viewset""" + + queryset = LDAPSource.objects.all() + serializer_class = LDAPSourceSerializer + + +class LDAPPropertyMappingViewSet(ModelViewSet): + """LDAP PropertyMapping Viewset""" + + queryset = LDAPPropertyMapping.objects.all() + serializer_class = LDAPPropertyMappingSerializer diff --git a/authentik/sources/ldap/apps.py b/authentik/sources/ldap/apps.py new file mode 100644 index 00000000..c6bde458 --- /dev/null +++ b/authentik/sources/ldap/apps.py @@ -0,0 +1,15 @@ +"""authentik ldap source config""" +from importlib import import_module + +from django.apps import AppConfig + + +class AuthentikSourceLDAPConfig(AppConfig): + """Authentik ldap app config""" + + name = "authentik.sources.ldap" + label = "authentik_sources_ldap" + verbose_name = "authentik Sources.LDAP" + + def ready(self): + import_module("authentik.sources.ldap.signals") diff --git a/authentik/sources/ldap/auth.py b/authentik/sources/ldap/auth.py new file mode 100644 index 00000000..aa534717 --- /dev/null +++ b/authentik/sources/ldap/auth.py @@ -0,0 +1,76 @@ +"""authentik LDAP Authentication Backend""" +from typing import Optional + +import ldap3 +from django.contrib.auth.backends import ModelBackend +from django.http import HttpRequest +from structlog import get_logger + +from authentik.core.models import User +from authentik.sources.ldap.models import LDAPSource + +LOGGER = get_logger() + + +class LDAPBackend(ModelBackend): + """Authenticate users against LDAP Server""" + + def authenticate(self, request: HttpRequest, **kwargs): + """Try to authenticate a user via ldap""" + if "password" not in kwargs: + return None + for source in LDAPSource.objects.filter(enabled=True): + LOGGER.debug("LDAP Auth attempt", source=source) + user = self.auth_user(source, **kwargs) + if user: + return user + return None + + def auth_user( + self, source: LDAPSource, password: str, **filters: str + ) -> Optional[User]: + """Try to bind as either user_dn or mail with password. + Returns True on success, otherwise False""" + users = User.objects.filter(**filters) + if not users.exists(): + return None + user: User = users.first() + if "distinguishedName" not in user.attributes: + LOGGER.debug( + "User doesn't have DN set, assuming not LDAP imported.", user=user + ) + return None + # Either has unusable password, + # or has a password, but couldn't be authenticated by ModelBackend. + # This means we check with a bind to see if the LDAP password has changed + if self.auth_user_by_bind(source, user, password): + # Password given successfully binds to LDAP, so we save it in our Database + LOGGER.debug("Updating user's password in DB", user=user) + user.set_password(password, signal=False) + user.save() + return user + # Password doesn't match + LOGGER.debug("Failed to bind, password invalid") + return None + + def auth_user_by_bind( + self, source: LDAPSource, user: User, password: str + ) -> Optional[User]: + """Attempt authentication by binding to the LDAP server as `user`. This + method should be avoided as its slow to do the bind.""" + # Try to bind as new user + LOGGER.debug("Attempting Binding as user", user=user) + try: + temp_connection = ldap3.Connection( + source.connection.server, + user=user.attributes.get("distinguishedName"), + password=password, + raise_exceptions=True, + ) + temp_connection.bind() + return user + except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception: + LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception) + except ldap3.core.exceptions.LDAPException as exception: + LOGGER.warning(exception) + return None diff --git a/authentik/sources/ldap/forms.py b/authentik/sources/ldap/forms.py new file mode 100644 index 00000000..d78bf6a6 --- /dev/null +++ b/authentik/sources/ldap/forms.py @@ -0,0 +1,83 @@ +"""authentik LDAP Forms""" + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from authentik.admin.fields import CodeMirrorWidget +from authentik.core.expression import PropertyMappingEvaluator +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource + + +class LDAPSourceForm(forms.ModelForm): + """LDAPSource Form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all() + + class Meta: + + model = LDAPSource + fields = [ + # we don't use all common fields, as we don't use flows for this + "name", + "slug", + "enabled", + # -- start of our custom fields + "server_uri", + "start_tls", + "bind_cn", + "bind_password", + "base_dn", + "sync_users", + "sync_users_password", + "sync_groups", + "property_mappings", + "additional_user_dn", + "additional_group_dn", + "user_object_filter", + "group_object_filter", + "user_group_membership_field", + "object_uniqueness_field", + "sync_parent_group", + ] + widgets = { + "name": forms.TextInput(), + "server_uri": forms.TextInput(), + "bind_cn": forms.TextInput(), + "bind_password": forms.TextInput(), + "base_dn": forms.TextInput(), + "additional_user_dn": forms.TextInput(), + "additional_group_dn": forms.TextInput(), + "user_object_filter": forms.TextInput(), + "group_object_filter": forms.TextInput(), + "user_group_membership_field": forms.TextInput(), + "object_uniqueness_field": forms.TextInput(), + } + + +class LDAPPropertyMappingForm(forms.ModelForm): + """LDAP Property Mapping form""" + + template_name = "ldap/property_mapping_form.html" + + def clean_expression(self): + """Test Syntax""" + expression = self.cleaned_data.get("expression") + evaluator = PropertyMappingEvaluator() + evaluator.validate(expression) + return expression + + class Meta: + + model = LDAPPropertyMapping + fields = ["name", "object_field", "expression"] + widgets = { + "name": forms.TextInput(), + "ldap_property": forms.TextInput(), + "object_field": forms.TextInput(), + "expression": CodeMirrorWidget(mode="python"), + } + help_texts = { + "object_field": _("Field of the user object this value is written to.") + } diff --git a/authentik/sources/ldap/migrations/0001_initial.py b/authentik/sources/ldap/migrations/0001_initial.py new file mode 100644 index 00000000..dd42e98d --- /dev/null +++ b/authentik/sources/ldap/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="LDAPPropertyMapping", + fields=[ + ( + "propertymapping_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.PropertyMapping", + ), + ), + ("object_field", models.TextField()), + ], + options={ + "verbose_name": "LDAP Property Mapping", + "verbose_name_plural": "LDAP Property Mappings", + }, + bases=("authentik_core.propertymapping",), + ), + migrations.CreateModel( + name="LDAPSource", + fields=[ + ( + "source_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.Source", + ), + ), + ( + "server_uri", + models.TextField( + validators=[ + django.core.validators.URLValidator( + schemes=["ldap", "ldaps"] + ) + ], + verbose_name="Server URI", + ), + ), + ("bind_cn", models.TextField(verbose_name="Bind CN")), + ("bind_password", models.TextField()), + ( + "start_tls", + models.BooleanField(default=False, verbose_name="Enable Start TLS"), + ), + ("base_dn", models.TextField(verbose_name="Base DN")), + ( + "additional_user_dn", + models.TextField( + help_text="Prepended to Base DN for User-queries.", + verbose_name="Addition User DN", + ), + ), + ( + "additional_group_dn", + models.TextField( + help_text="Prepended to Base DN for Group-queries.", + verbose_name="Addition Group DN", + ), + ), + ( + "user_object_filter", + models.TextField( + default="(objectCategory=Person)", + help_text="Consider Objects matching this filter to be Users.", + ), + ), + ( + "user_group_membership_field", + models.TextField( + default="memberOf", + help_text="Field which contains Groups of user.", + ), + ), + ( + "group_object_filter", + models.TextField( + default="(objectCategory=Group)", + help_text="Consider Objects matching this filter to be Groups.", + ), + ), + ( + "object_uniqueness_field", + models.TextField( + default="objectSid", + help_text="Field which contains a unique Identifier.", + ), + ), + ("sync_groups", models.BooleanField(default=True)), + ( + "sync_parent_group", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_core.Group", + ), + ), + ], + options={ + "verbose_name": "LDAP Source", + "verbose_name_plural": "LDAP Sources", + }, + bases=("authentik_core.source",), + ), + ] diff --git a/authentik/sources/ldap/migrations/0002_ldapsource_sync_users.py b/authentik/sources/ldap/migrations/0002_ldapsource_sync_users.py new file mode 100644 index 00000000..93df6ff7 --- /dev/null +++ b/authentik/sources/ldap/migrations/0002_ldapsource_sync_users.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.6 on 2020-05-23 19:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="ldapsource", + name="sync_users", + field=models.BooleanField(default=True), + ), + ] diff --git a/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py b/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py new file mode 100644 index 00000000..7ee555d1 --- /dev/null +++ b/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.6 on 2020-05-23 19:30 + +from django.apps.registry import Apps +from django.db import migrations + + +def create_default_ad_property_mappings(apps: Apps, schema_editor): + LDAPPropertyMapping = apps.get_model( + "authentik_sources_ldap", "LDAPPropertyMapping" + ) + mapping = { + "name": "return ldap.get('name')", + "first_name": "return ldap.get('givenName')", + "last_name": "return ldap.get('sn')", + "username": "return ldap.get('sAMAccountName')", + "email": "return ldap.get('mail')", + } + db_alias = schema_editor.connection.alias + for object_field, expression in mapping.items(): + LDAPPropertyMapping.objects.using(db_alias).get_or_create( + expression=expression, + object_field=object_field, + defaults={ + "name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}" + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0002_ldapsource_sync_users"), + ] + + operations = [ + migrations.RunPython(create_default_ad_property_mappings), + ] diff --git a/authentik/sources/ldap/migrations/0004_auto_20200524_1146.py b/authentik/sources/ldap/migrations/0004_auto_20200524_1146.py new file mode 100644 index 00000000..2bc5397e --- /dev/null +++ b/authentik/sources/ldap/migrations/0004_auto_20200524_1146.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.6 on 2020-05-24 11:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0003_default_ldap_property_mappings"), + ] + + operations = [ + migrations.AlterField( + model_name="ldapsource", + name="additional_group_dn", + field=models.TextField( + blank=True, + help_text="Prepended to Base DN for Group-queries.", + verbose_name="Addition Group DN", + ), + ), + migrations.AlterField( + model_name="ldapsource", + name="additional_user_dn", + field=models.TextField( + blank=True, + help_text="Prepended to Base DN for User-queries.", + verbose_name="Addition User DN", + ), + ), + ] diff --git a/authentik/sources/ldap/migrations/0005_auto_20200913_1947.py b/authentik/sources/ldap/migrations/0005_auto_20200913_1947.py new file mode 100644 index 00000000..c33be769 --- /dev/null +++ b/authentik/sources/ldap/migrations/0005_auto_20200913_1947.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.1 on 2020-09-13 19:47 + +from django.db import migrations, models + +import authentik.lib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0004_auto_20200524_1146"), + ] + + operations = [ + migrations.AlterField( + model_name="ldapsource", + name="server_uri", + field=models.TextField( + validators=[ + authentik.lib.models.DomainlessURLValidator( + schemes=["ldap", "ldaps"] + ) + ], + verbose_name="Server URI", + ), + ), + ] diff --git a/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py b/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py new file mode 100644 index 00000000..b742bb3e --- /dev/null +++ b/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py @@ -0,0 +1,50 @@ +# Generated by Django 3.1.1 on 2020-09-15 19:19 + +from django.apps.registry import Apps +from django.db import migrations + + +def create_default_property_mappings(apps: Apps, schema_editor): + LDAPPropertyMapping = apps.get_model( + "authentik_sources_ldap", "LDAPPropertyMapping" + ) + db_alias = schema_editor.connection.alias + mapping = { + "name": "name", + "first_name": "givenName", + "last_name": "sn", + "email": "mail", + } + for object_field, ldap_field in mapping.items(): + expression = f"return ldap.get('{ldap_field}')" + LDAPPropertyMapping.objects.using(db_alias).get_or_create( + expression=expression, + object_field=object_field, + defaults={ + "name": f"Autogenerated LDAP Mapping: {ldap_field} -> {object_field}" + }, + ) + ad_mapping = { + "username": "sAMAccountName", + "attributes.upn": "userPrincipalName", + } + for object_field, ldap_field in ad_mapping.items(): + expression = f"return ldap.get('{ldap_field}')" + LDAPPropertyMapping.objects.using(db_alias).get_or_create( + expression=expression, + object_field=object_field, + defaults={ + "name": f"Autogenerated Active Directory Mapping: {ldap_field} -> {object_field}" + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0005_auto_20200913_1947"), + ] + + operations = [ + migrations.RunPython(create_default_property_mappings), + ] diff --git a/authentik/sources/ldap/migrations/0007_ldapsource_sync_users_password.py b/authentik/sources/ldap/migrations/0007_ldapsource_sync_users_password.py new file mode 100644 index 00000000..51bda8d2 --- /dev/null +++ b/authentik/sources/ldap/migrations/0007_ldapsource_sync_users_password.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.1 on 2020-09-21 09:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0006_auto_20200915_1919"), + ] + + operations = [ + migrations.AddField( + model_name="ldapsource", + name="sync_users_password", + field=models.BooleanField( + default=True, + help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.", + unique=True, + ), + ), + ] diff --git a/passbook/sources/ldap/migrations/__init__.py b/authentik/sources/ldap/migrations/__init__.py similarity index 100% rename from passbook/sources/ldap/migrations/__init__.py rename to authentik/sources/ldap/migrations/__init__.py diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py new file mode 100644 index 00000000..3e746fb6 --- /dev/null +++ b/authentik/sources/ldap/models.py @@ -0,0 +1,132 @@ +"""authentik LDAP Models""" +from datetime import datetime +from typing import Optional, Type + +from django.core.cache import cache +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ +from ldap3 import ALL, Connection, Server + +from authentik.core.models import Group, PropertyMapping, Source +from authentik.lib.models import DomainlessURLValidator +from authentik.lib.utils.template import render_to_string + + +class LDAPSource(Source): + """Federate LDAP Directory with authentik, or create new accounts in LDAP.""" + + server_uri = models.TextField( + validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])], + verbose_name=_("Server URI"), + ) + bind_cn = models.TextField(verbose_name=_("Bind CN")) + bind_password = models.TextField() + start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS")) + + base_dn = models.TextField(verbose_name=_("Base DN")) + additional_user_dn = models.TextField( + help_text=_("Prepended to Base DN for User-queries."), + verbose_name=_("Addition User DN"), + blank=True, + ) + additional_group_dn = models.TextField( + help_text=_("Prepended to Base DN for Group-queries."), + verbose_name=_("Addition Group DN"), + blank=True, + ) + + user_object_filter = models.TextField( + default="(objectCategory=Person)", + help_text=_("Consider Objects matching this filter to be Users."), + ) + user_group_membership_field = models.TextField( + default="memberOf", help_text=_("Field which contains Groups of user.") + ) + group_object_filter = models.TextField( + default="(objectCategory=Group)", + help_text=_("Consider Objects matching this filter to be Groups."), + ) + object_uniqueness_field = models.TextField( + default="objectSid", help_text=_("Field which contains a unique Identifier.") + ) + + sync_users = models.BooleanField(default=True) + sync_users_password = models.BooleanField( + default=True, + help_text=_( + ( + "When a user changes their password, sync it back to LDAP. " + "This can only be enabled on a single LDAP source." + ) + ), + unique=True, + ) + sync_groups = models.BooleanField(default=True) + sync_parent_group = models.ForeignKey( + Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT + ) + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.ldap.forms import LDAPSourceForm + + return LDAPSourceForm + + def state_cache_prefix(self, suffix: str) -> str: + """Key by which the ldap source status is saved""" + return f"source_ldap_{self.pk}_state_{suffix}" + + @property + def ui_additional_info(self) -> str: + last_sync = cache.get(self.state_cache_prefix("last_sync"), None) + if last_sync: + last_sync = datetime.fromtimestamp(last_sync) + + return render_to_string( + "ldap/source_list_status.html", {"source": self, "last_sync": last_sync} + ) + + _connection: Optional[Connection] = None + + @property + def connection(self) -> Connection: + """Get a fully connected and bound LDAP Connection""" + if not self._connection: + server = Server(self.server_uri, get_info=ALL) + self._connection = Connection( + server, + raise_exceptions=True, + user=self.bind_cn, + password=self.bind_password, + ) + + self._connection.bind() + if self.start_tls: + self._connection.start_tls() + return self._connection + + class Meta: + + verbose_name = _("LDAP Source") + verbose_name_plural = _("LDAP Sources") + + +class LDAPPropertyMapping(PropertyMapping): + """Map LDAP Property to User or Group object attribute""" + + object_field = models.TextField() + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.ldap.forms import LDAPPropertyMappingForm + + return LDAPPropertyMappingForm + + def __str__(self): + return self.name + + class Meta: + + verbose_name = _("LDAP Property Mapping") + verbose_name_plural = _("LDAP Property Mappings") diff --git a/authentik/sources/ldap/password.py b/authentik/sources/ldap/password.py new file mode 100644 index 00000000..bab51f25 --- /dev/null +++ b/authentik/sources/ldap/password.py @@ -0,0 +1,155 @@ +"""Help validate and update passwords in LDAP""" +from enum import IntFlag +from re import split +from typing import Optional + +import ldap3 +import ldap3.core.exceptions +from structlog import get_logger + +from authentik.core.models import User +from authentik.sources.ldap.models import LDAPSource + +LOGGER = get_logger() + +NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/" +RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t" + + +class PwdProperties(IntFlag): + """Possible values for the pwdProperties attribute""" + + DOMAIN_PASSWORD_COMPLEX = 1 + DOMAIN_PASSWORD_NO_ANON_CHANGE = 2 + DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4 + DOMAIN_LOCKOUT_ADMINS = 8 + DOMAIN_PASSWORD_STORE_CLEARTEXT = 16 + DOMAIN_REFUSE_PASSWORD_CHANGE = 32 + + +class PasswordCategories(IntFlag): + """Password categories as defined by Microsoft, a category can only be counted + once, hence intflag.""" + + NONE = 0 + ALPHA_LOWER = 1 + ALPHA_UPPER = 2 + ALPHA_OTHER = 4 + NUMERIC = 8 + SYMBOL = 16 + + +class LDAPPasswordChanger: + """Help validate and update passwords in LDAP""" + + _source: LDAPSource + + def __init__(self, source: LDAPSource) -> None: + self._source = source + + def get_domain_root_dn(self) -> str: + """Attempt to get root DN via MS specific fields or generic LDAP fields""" + info = self._source.connection.server.info + if "rootDomainNamingContext" in info.other: + return info.other["rootDomainNamingContext"][0] + naming_contexts = info.naming_contexts + naming_contexts.sort(key=len) + return naming_contexts[0] + + def check_ad_password_complexity_enabled(self) -> bool: + """Check if DOMAIN_PASSWORD_COMPLEX is enabled""" + root_dn = self.get_domain_root_dn() + root_attrs = self._source.connection.extend.standard.paged_search( + search_base=root_dn, + search_filter="(objectClass=*)", + search_scope=ldap3.BASE, + attributes=["pwdProperties"], + ) + root_attrs = list(root_attrs)[0] + pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"]) + if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties: + return True + + return False + + def change_password(self, user: User, password: str): + """Change user's password""" + user_dn = user.attributes.get("distinguishedName", None) + if not user_dn: + raise AttributeError("User has no distinguishedName set.") + self._source.connection.extend.microsoft.modify_password(user_dn, password) + + def _ad_check_password_existing(self, password: str, user_dn: str) -> bool: + """Check if a password contains sAMAccount or displayName""" + users = list( + self._source.connection.extend.standard.paged_search( + search_base=user_dn, + search_filter=self._source.user_object_filter, + search_scope=ldap3.BASE, + attributes=["displayName", "sAMAccountName"], + ) + ) + if len(users) != 1: + raise AssertionError() + user_attributes = users[0]["attributes"] + # If sAMAccountName is longer than 3 chars, check if its contained in password + if len(user_attributes["sAMAccountName"]) >= 3: + if password.lower() in user_attributes["sAMAccountName"].lower(): + return False + display_name_tokens = split( + RE_DISPLAYNAME_SEPARATORS, user_attributes["displayName"] + ) + for token in display_name_tokens: + # Ignore tokens under 3 chars + if len(token) < 3: + continue + if token.lower() in password.lower(): + return False + return True + + def ad_password_complexity( + self, password: str, user: Optional[User] = None + ) -> bool: + """Check if password matches Active direcotry password policies + + https://docs.microsoft.com/en-us/windows/security/threat-protection/ + security-policy-settings/password-must-meet-complexity-requirements + """ + if user: + # Check if password contains sAMAccountName or displayNames + if "distinguishedName" in user.attributes: + existing_user_check = self._ad_check_password_existing( + password, user.attributes.get("distinguishedName") + ) + if not existing_user_check: + LOGGER.debug("Password failed name check", user=user) + return existing_user_check + + # Step 2, match at least 3 of 5 categories + matched_categories = PasswordCategories.NONE + required = 3 + for letter in password: + # Only match one category per letter, + if letter.islower(): + matched_categories |= PasswordCategories.ALPHA_LOWER + elif letter.isupper(): + matched_categories |= PasswordCategories.ALPHA_UPPER + elif not letter.isascii() and letter.isalpha(): + # Not exactly matching microsoft's policy, but count it as "Other unicode" char + # when its alpha and not ascii + matched_categories |= PasswordCategories.ALPHA_OTHER + elif letter.isnumeric(): + matched_categories |= PasswordCategories.NUMERIC + elif letter in NON_ALPHA: + matched_categories |= PasswordCategories.SYMBOL + if bin(matched_categories).count("1") < required: + LOGGER.debug( + "Password didn't match enough categories", + has=matched_categories, + must=required, + ) + return False + LOGGER.debug( + "Password matched categories", has=matched_categories, must=required + ) + return True diff --git a/authentik/sources/ldap/settings.py b/authentik/sources/ldap/settings.py new file mode 100644 index 00000000..4acdfbfd --- /dev/null +++ b/authentik/sources/ldap/settings.py @@ -0,0 +1,14 @@ +"""LDAP Settings""" +from celery.schedules import crontab + +AUTHENTICATION_BACKENDS = [ + "authentik.sources.ldap.auth.LDAPBackend", +] + +CELERY_BEAT_SCHEDULE = { + "sources_ldap_sync": { + "task": "authentik.sources.ldap.tasks.ldap_sync_all", + "schedule": crontab(minute=0), # Run every hour + "options": {"queue": "authentik_scheduled"}, + } +} diff --git a/authentik/sources/ldap/signals.py b/authentik/sources/ldap/signals.py new file mode 100644 index 00000000..5e7f8c3c --- /dev/null +++ b/authentik/sources/ldap/signals.py @@ -0,0 +1,59 @@ +"""authentik ldap source signals""" +from typing import Any, Dict + +from django.core.exceptions import ValidationError +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ +from ldap3.core.exceptions import LDAPException + +from authentik.core.models import User +from authentik.core.signals import password_changed +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.sources.ldap.models import LDAPSource +from authentik.sources.ldap.password import LDAPPasswordChanger +from authentik.sources.ldap.tasks import ldap_sync +from authentik.stages.prompt.signals import password_validate + + +@receiver(post_save, sender=LDAPSource) +# pylint: disable=unused-argument +def sync_ldap_source_on_save(sender, instance: LDAPSource, **_): + """Ensure that source is synced on save (if enabled)""" + if instance.enabled: + ldap_sync.delay(instance.pk) + + +@receiver(password_validate) +# pylint: disable=unused-argument +def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__): + """if there's an LDAP Source with enabled password sync, check the password""" + sources = LDAPSource.objects.filter(sync_users_password=True) + if not sources.exists(): + return + source = sources.first() + changer = LDAPPasswordChanger(source) + if changer.check_ad_password_complexity_enabled(): + passing = changer.ad_password_complexity( + password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None) + ) + if not passing: + raise ValidationError( + _("Password does not match Active Direcory Complexity.") + ) + + +@receiver(password_changed) +# pylint: disable=unused-argument +def ldap_sync_password(sender, user: User, password: str, **_): + """Connect to ldap and update password. We do this in the background to get + automatic retries on error.""" + sources = LDAPSource.objects.filter(sync_users_password=True) + if not sources.exists(): + return + source = sources.first() + changer = LDAPPasswordChanger(source) + try: + changer.change_password(user, password) + except LDAPException as exc: + raise ValidationError("Failed to set password") from exc diff --git a/authentik/sources/ldap/sync.py b/authentik/sources/ldap/sync.py new file mode 100644 index 00000000..fa7ad88a --- /dev/null +++ b/authentik/sources/ldap/sync.py @@ -0,0 +1,191 @@ +"""Sync LDAP Users and groups into authentik""" +from typing import Any, Dict + +import ldap3 +import ldap3.core.exceptions +from django.db.utils import IntegrityError +from structlog import get_logger + +from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.models import Group, User +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource + +LOGGER = get_logger() + + +class LDAPSynchronizer: + """Sync LDAP Users and groups into authentik""" + + _source: LDAPSource + + def __init__(self, source: LDAPSource): + self._source = source + + @property + def base_dn_users(self) -> str: + """Shortcut to get full base_dn for user lookups""" + if self._source.additional_user_dn: + return f"{self._source.additional_user_dn},{self._source.base_dn}" + return self._source.base_dn + + @property + def base_dn_groups(self) -> str: + """Shortcut to get full base_dn for group lookups""" + if self._source.additional_group_dn: + return f"{self._source.additional_group_dn},{self._source.base_dn}" + return self._source.base_dn + + def sync_groups(self) -> int: + """Iterate over all LDAP Groups and create authentik_core.Group instances""" + if not self._source.sync_groups: + LOGGER.warning("Group syncing is disabled for this Source") + return -1 + groups = self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_groups, + search_filter=self._source.group_object_filter, + search_scope=ldap3.SUBTREE, + attributes=ldap3.ALL_ATTRIBUTES, + ) + group_count = 0 + for group in groups: + attributes = group.get("attributes", {}) + if self._source.object_uniqueness_field not in attributes: + LOGGER.warning( + "Cannot find uniqueness Field in attributes", user=attributes.keys() + ) + continue + uniq = attributes[self._source.object_uniqueness_field] + _, created = Group.objects.update_or_create( + attributes__ldap_uniq=uniq, + parent=self._source.sync_parent_group, + defaults={ + "name": attributes.get("name", ""), + "attributes": { + "ldap_uniq": uniq, + "distinguishedName": attributes.get("distinguishedName"), + }, + }, + ) + LOGGER.debug( + "Synced group", group=attributes.get("name", ""), created=created + ) + group_count += 1 + return group_count + + def sync_users(self) -> int: + """Iterate over all LDAP Users and create authentik_core.User instances""" + if not self._source.sync_users: + LOGGER.warning("User syncing is disabled for this Source") + return -1 + users = self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_users, + search_filter=self._source.user_object_filter, + search_scope=ldap3.SUBTREE, + attributes=ldap3.ALL_ATTRIBUTES, + ) + user_count = 0 + for user in users: + attributes = user.get("attributes", {}) + if self._source.object_uniqueness_field not in attributes: + LOGGER.warning( + "Cannot find uniqueness Field in attributes", user=user.keys() + ) + continue + uniq = attributes[self._source.object_uniqueness_field] + try: + defaults = self._build_object_properties(attributes) + user, created = User.objects.update_or_create( + attributes__ldap_uniq=uniq, + defaults=defaults, + ) + except IntegrityError as exc: + LOGGER.warning("Failed to create user", exc=exc) + LOGGER.warning( + ( + "To merge new User with existing user, set the User's " + f"Attribute 'ldap_uniq' to '{uniq}'" + ) + ) + else: + if created: + user.set_unusable_password() + user.save() + LOGGER.debug( + "Synced User", user=attributes.get("name", ""), created=created + ) + user_count += 1 + return user_count + + def sync_membership(self): + """Iterate over all Users and assign Groups using memberOf Field""" + users = self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_users, + search_filter=self._source.user_object_filter, + search_scope=ldap3.SUBTREE, + attributes=[ + self._source.user_group_membership_field, + self._source.object_uniqueness_field, + ], + ) + group_cache: Dict[str, Group] = {} + for user in users: + member_of = user.get("attributes", {}).get( + self._source.user_group_membership_field, [] + ) + uniq = user.get("attributes", {}).get( + self._source.object_uniqueness_field, [] + ) + for group_dn in member_of: + # Check if group_dn is within our base_dn_groups, and skip if not + if not group_dn.endswith(self.base_dn_groups): + continue + # Check if we fetched the group already, and if not cache it for later + if group_dn not in group_cache: + groups = Group.objects.filter( + attributes__distinguishedName=group_dn + ) + if not groups.exists(): + LOGGER.warning( + "Group does not exist in our DB yet, run sync_groups first.", + group=group_dn, + ) + return + group_cache[group_dn] = groups.first() + group = group_cache[group_dn] + users = User.objects.filter(attributes__ldap_uniq=uniq) + group.users.add(*list(users)) + # Now that all users are added, lets write everything + for _, group in group_cache.items(): + group.save() + LOGGER.debug("Successfully updated group membership") + + def _build_object_properties( + self, attributes: Dict[str, Any] + ) -> Dict[str, Dict[Any, Any]]: + properties = {"attributes": {}} + for mapping in self._source.property_mappings.all().select_subclasses(): + if not isinstance(mapping, LDAPPropertyMapping): + continue + mapping: LDAPPropertyMapping + try: + value = mapping.evaluate(user=None, request=None, ldap=attributes) + if value is None: + continue + object_field = mapping.object_field + if object_field.startswith("attributes."): + properties["attributes"][ + object_field.replace("attributes.", "") + ] = value + else: + properties[object_field] = value + except PropertyMappingExpressionException as exc: + LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping) + continue + if self._source.object_uniqueness_field in attributes: + properties["attributes"]["ldap_uniq"] = attributes.get( + self._source.object_uniqueness_field + ) + properties["attributes"]["distinguishedName"] = attributes.get( + "distinguishedName" + ) + return properties diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py new file mode 100644 index 00000000..fcb47c75 --- /dev/null +++ b/authentik/sources/ldap/tasks.py @@ -0,0 +1,45 @@ +"""LDAP Sync tasks""" +from time import time + +from django.core.cache import cache +from django.utils.text import slugify +from ldap3.core.exceptions import LDAPException + +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.root.celery import CELERY_APP +from authentik.sources.ldap.models import LDAPSource +from authentik.sources.ldap.sync import LDAPSynchronizer + + +@CELERY_APP.task() +def ldap_sync_all(): + """Sync all sources""" + for source in LDAPSource.objects.filter(enabled=True): + ldap_sync.delay(source.pk) + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def ldap_sync(self: MonitoredTask, source_pk: int): + """Synchronization of an LDAP Source""" + try: + source: LDAPSource = LDAPSource.objects.get(pk=source_pk) + except LDAPSource.DoesNotExist: + # Because the source couldn't be found, we don't have a UID + # to set the state with + return + self.set_uid(slugify(source.name)) + try: + syncer = LDAPSynchronizer(source) + user_count = syncer.sync_users() + group_count = syncer.sync_groups() + syncer.sync_membership() + cache_key = source.state_cache_prefix("last_sync") + cache.set(cache_key, time(), timeout=60 * 60) + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, + [f"Synced {user_count} users", f"Synced {group_count} groups"], + ) + ) + except LDAPException as exc: + self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) diff --git a/authentik/sources/ldap/templates/ldap/property_mapping_form.html b/authentik/sources/ldap/templates/ldap/property_mapping_form.html new file mode 100644 index 00000000..c202b5b2 --- /dev/null +++ b/authentik/sources/ldap/templates/ldap/property_mapping_form.html @@ -0,0 +1,14 @@ +{% extends "generic/form.html" %} + +{% load i18n %} + +{% block beneath_form %} +
+ +
+

+ Expression using Python. See here for a list of all variables. +

+
+
+{% endblock %} diff --git a/passbook/sources/ldap/templates/ldap/source_list_status.html b/authentik/sources/ldap/templates/ldap/source_list_status.html similarity index 100% rename from passbook/sources/ldap/templates/ldap/source_list_status.html rename to authentik/sources/ldap/templates/ldap/source_list_status.html diff --git a/passbook/sources/ldap/tests/__init__.py b/authentik/sources/ldap/tests/__init__.py similarity index 100% rename from passbook/sources/ldap/tests/__init__.py rename to authentik/sources/ldap/tests/__init__.py diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py new file mode 100644 index 00000000..f3a09246 --- /dev/null +++ b/authentik/sources/ldap/tests/test_auth.py @@ -0,0 +1,47 @@ +"""LDAP Source tests""" +from unittest.mock import Mock, PropertyMock, patch + +from django.test import TestCase + +from authentik.core.models import User +from authentik.providers.oauth2.generators import generate_client_secret +from authentik.sources.ldap.auth import LDAPBackend +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource +from authentik.sources.ldap.sync import LDAPSynchronizer +from authentik.sources.ldap.tests.utils import _build_mock_connection + +LDAP_PASSWORD = generate_client_secret() +LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) + + +class LDAPSyncTests(TestCase): + """LDAP Sync tests""" + + def setUp(self): + self.source = LDAPSource.objects.create( + name="ldap", + slug="ldap", + base_dn="DC=AD2012,DC=LAB", + additional_user_dn="ou=users", + additional_group_dn="ou=groups", + ) + self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) + self.source.save() + + @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) + def test_auth_synced_user(self): + """Test Cached auth""" + syncer = LDAPSynchronizer(self.source) + syncer.sync_users() + + user = User.objects.get(username="user0_sn") + auth_user_by_bind = Mock(return_value=user) + with patch( + "authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind", + auth_user_by_bind, + ): + backend = LDAPBackend() + self.assertEqual( + backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD), + user, + ) diff --git a/authentik/sources/ldap/tests/test_password.py b/authentik/sources/ldap/tests/test_password.py new file mode 100644 index 00000000..82f497e5 --- /dev/null +++ b/authentik/sources/ldap/tests/test_password.py @@ -0,0 +1,54 @@ +"""LDAP Source tests""" +from unittest.mock import PropertyMock, patch + +from django.test import TestCase + +from authentik.core.models import User +from authentik.providers.oauth2.generators import generate_client_secret +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource +from authentik.sources.ldap.password import LDAPPasswordChanger +from authentik.sources.ldap.tests.utils import _build_mock_connection + +LDAP_PASSWORD = generate_client_secret() +LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) + + +class LDAPPasswordTests(TestCase): + """LDAP Password tests""" + + def setUp(self): + self.source = LDAPSource.objects.create( + name="ldap", + slug="ldap", + base_dn="DC=AD2012,DC=LAB", + additional_user_dn="ou=users", + additional_group_dn="ou=groups", + ) + self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) + self.source.save() + + @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) + def test_password_complexity(self): + """Test password without user""" + pwc = LDAPPasswordChanger(self.source) + self.assertFalse(pwc.ad_password_complexity("test")) # 1 category + self.assertFalse(pwc.ad_password_complexity("test1")) # 2 categories + self.assertTrue(pwc.ad_password_complexity("test1!")) # 2 categories + + @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) + def test_password_complexity_user(self): + """test password with user""" + pwc = LDAPPasswordChanger(self.source) + user = User.objects.create( + username="test", + attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"}, + ) + self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category + self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories + self.assertTrue(pwc.ad_password_complexity("test1!", user)) # 2 categories + self.assertFalse( + pwc.ad_password_complexity("erin!qewrqewr", user) + ) # displayName token + self.assertFalse( + pwc.ad_password_complexity("hagens!qewrqewr", user) + ) # displayName token diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py new file mode 100644 index 00000000..e6c14dca --- /dev/null +++ b/authentik/sources/ldap/tests/test_sync.py @@ -0,0 +1,51 @@ +"""LDAP Source tests""" +from unittest.mock import PropertyMock, patch + +from django.test import TestCase + +from authentik.core.models import Group, User +from authentik.providers.oauth2.generators import generate_client_secret +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource +from authentik.sources.ldap.sync import LDAPSynchronizer +from authentik.sources.ldap.tasks import ldap_sync_all +from authentik.sources.ldap.tests.utils import _build_mock_connection + +LDAP_PASSWORD = generate_client_secret() +LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) + + +class LDAPSyncTests(TestCase): + """LDAP Sync tests""" + + def setUp(self): + self.source = LDAPSource.objects.create( + name="ldap", + slug="ldap", + base_dn="DC=AD2012,DC=LAB", + additional_user_dn="ou=users", + additional_group_dn="ou=groups", + ) + self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) + self.source.save() + + @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) + def test_sync_users(self): + """Test user sync""" + syncer = LDAPSynchronizer(self.source) + syncer.sync_users() + self.assertTrue(User.objects.filter(username="user0_sn").exists()) + self.assertFalse(User.objects.filter(username="user1_sn").exists()) + + @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) + def test_sync_groups(self): + """Test group sync""" + syncer = LDAPSynchronizer(self.source) + syncer.sync_groups() + syncer.sync_membership() + group = Group.objects.filter(name="test-group") + self.assertTrue(group.exists()) + + @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) + def test_tasks(self): + """Test Scheduled tasks""" + ldap_sync_all.delay().get() diff --git a/passbook/sources/ldap/tests/utils.py b/authentik/sources/ldap/tests/utils.py similarity index 100% rename from passbook/sources/ldap/tests/utils.py rename to authentik/sources/ldap/tests/utils.py diff --git a/passbook/sources/oauth/__init__.py b/authentik/sources/oauth/__init__.py similarity index 100% rename from passbook/sources/oauth/__init__.py rename to authentik/sources/oauth/__init__.py diff --git a/authentik/sources/oauth/api.py b/authentik/sources/oauth/api.py new file mode 100644 index 00000000..8aceead4 --- /dev/null +++ b/authentik/sources/oauth/api.py @@ -0,0 +1,29 @@ +"""OAuth Source Serializer""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.admin.forms.source import SOURCE_SERIALIZER_FIELDS +from authentik.sources.oauth.models import OAuthSource + + +class OAuthSourceSerializer(ModelSerializer): + """OAuth Source Serializer""" + + class Meta: + model = OAuthSource + fields = SOURCE_SERIALIZER_FIELDS + [ + "provider_type", + "request_token_url", + "authorization_url", + "access_token_url", + "profile_url", + "consumer_key", + "consumer_secret", + ] + + +class OAuthSourceViewSet(ModelViewSet): + """Source Viewset""" + + queryset = OAuthSource.objects.all() + serializer_class = OAuthSourceSerializer diff --git a/authentik/sources/oauth/apps.py b/authentik/sources/oauth/apps.py new file mode 100644 index 00000000..dde12f47 --- /dev/null +++ b/authentik/sources/oauth/apps.py @@ -0,0 +1,26 @@ +"""authentik oauth_client config""" +from importlib import import_module + +from django.apps import AppConfig +from django.conf import settings +from structlog import get_logger + +LOGGER = get_logger() + + +class AuthentikSourceOAuthConfig(AppConfig): + """authentik source.oauth config""" + + name = "authentik.sources.oauth" + label = "authentik_sources_oauth" + verbose_name = "authentik Sources.OAuth" + mountpoint = "source/oauth/" + + def ready(self): + """Load source_types from config file""" + for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES: + try: + import_module(source_type) + LOGGER.debug("Loaded OAuth Source Type", type=source_type) + except ImportError as exc: + LOGGER.debug(exc) diff --git a/authentik/sources/oauth/auth.py b/authentik/sources/oauth/auth.py new file mode 100644 index 00000000..62836f57 --- /dev/null +++ b/authentik/sources/oauth/auth.py @@ -0,0 +1,23 @@ +"""authentik oauth_client Authorization backend""" +from typing import Optional + +from django.contrib.auth.backends import ModelBackend +from django.http import HttpRequest + +from authentik.core.models import User +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection + + +class AuthorizedServiceBackend(ModelBackend): + "Authentication backend for users registered with remote OAuth provider." + + def authenticate( + self, request: HttpRequest, source: OAuthSource, identifier: str + ) -> Optional[User]: + "Fetch user for a given source by id." + access = UserOAuthSourceConnection.objects.filter( + source=source, identifier=identifier + ).select_related("user") + if not access.exists(): + return None + return access.first().user diff --git a/passbook/sources/oauth/clients/__init__.py b/authentik/sources/oauth/clients/__init__.py similarity index 100% rename from passbook/sources/oauth/clients/__init__.py rename to authentik/sources/oauth/clients/__init__.py diff --git a/authentik/sources/oauth/clients/base.py b/authentik/sources/oauth/clients/base.py new file mode 100644 index 00000000..0672e3fb --- /dev/null +++ b/authentik/sources/oauth/clients/base.py @@ -0,0 +1,75 @@ +"""OAuth Clients""" +from typing import Any, Dict, Optional +from urllib.parse import urlencode + +from django.http import HttpRequest +from requests import Session +from requests.exceptions import RequestException +from requests.models import Response +from structlog import get_logger + +from authentik import __version__ +from authentik.sources.oauth.models import OAuthSource + +LOGGER = get_logger() + + +class BaseOAuthClient: + """Base OAuth Client""" + + session: Session + + source: OAuthSource + request: HttpRequest + + callback: Optional[str] + + def __init__( + self, source: OAuthSource, request: HttpRequest, callback: Optional[str] = None + ): + self.source = source + self.session = Session() + self.request = request + self.callback = callback + self.session.headers.update({"User-Agent": f"authentik {__version__}"}) + + def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]: + "Fetch access token from callback request." + raise NotImplementedError("Defined in a sub-class") # pragma: no cover + + def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]: + "Fetch user profile information." + try: + response = self.do_request("get", self.source.profile_url, token=token) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch user profile", exc=exc) + return None + else: + return response.json() + + def get_redirect_args(self) -> Dict[str, str]: + "Get request parameters for redirect url." + raise NotImplementedError("Defined in a sub-class") # pragma: no cover + + def get_redirect_url(self, parameters=None): + "Build authentication redirect url." + args = self.get_redirect_args() + additional = parameters or {} + args.update(additional) + params = urlencode(args) + LOGGER.info("redirect args", **args) + return f"{self.source.authorization_url}?{params}" + + def parse_raw_token(self, raw_token: str) -> Dict[str, Any]: + "Parse token and secret from raw token response." + raise NotImplementedError("Defined in a sub-class") # pragma: no cover + + def do_request(self, method: str, url: str, **kwargs) -> Response: + """Wrapper around self.session.request, which can add special headers""" + return self.session.request(method, url, **kwargs) + + @property + def session_key(self) -> str: + """Return Session Key""" + raise NotImplementedError("Defined in a sub-class") # pragma: no cover diff --git a/authentik/sources/oauth/clients/oauth1.py b/authentik/sources/oauth/clients/oauth1.py new file mode 100644 index 00000000..4ba92620 --- /dev/null +++ b/authentik/sources/oauth/clients/oauth1.py @@ -0,0 +1,102 @@ +"""OAuth 1 Clients""" +from typing import Any, Dict, Optional +from urllib.parse import parse_qsl + +from requests.exceptions import RequestException +from requests.models import Response +from requests_oauthlib import OAuth1 +from structlog import get_logger + +from authentik.sources.oauth.clients.base import BaseOAuthClient +from authentik.sources.oauth.exceptions import OAuthSourceException + +LOGGER = get_logger() + + +class OAuthClient(BaseOAuthClient): + """OAuth1 Client""" + + _default_headers = { + "Accept": "application/json", + } + + def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]: + "Fetch access token from callback request." + raw_token = self.request.session.get(self.session_key, None) + verifier = self.request.GET.get("oauth_verifier", None) + callback = self.request.build_absolute_uri(self.callback) + if raw_token is not None and verifier is not None: + token = self.parse_raw_token(raw_token) + try: + response = self.do_request( + "post", + self.source.access_token_url, + token=token, + headers=self._default_headers, + oauth_verifier=verifier, + oauth_callback=callback, + ) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch access token", exc=exc) + return None + else: + return self.parse_raw_token(response.text) + return None + + def get_request_token(self) -> str: + "Fetch the OAuth request token. Only required for OAuth 1.0." + callback = self.request.build_absolute_uri(self.callback) + try: + response = self.do_request( + "post", + self.source.request_token_url, + headers=self._default_headers, + oauth_callback=callback, + ) + response.raise_for_status() + except RequestException as exc: + raise OAuthSourceException from exc + else: + return response.text + + def get_redirect_args(self) -> Dict[str, Any]: + "Get request parameters for redirect url." + callback = self.request.build_absolute_uri(self.callback) + raw_token = self.get_request_token() + token = self.parse_raw_token(raw_token) + self.request.session[self.session_key] = raw_token + return { + "oauth_token": token["oauth_token"], + "oauth_callback": callback, + } + + def parse_raw_token(self, raw_token: str) -> Dict[str, Any]: + "Parse token and secret from raw token response." + return dict(parse_qsl(raw_token)) + + def do_request(self, method: str, url: str, **kwargs) -> Response: + "Build remote url request. Constructs necessary auth." + resource_owner_key = None + resource_owner_secret = None + if "token" in kwargs: + user_token: Dict[str, Any] = kwargs.pop("token") + resource_owner_key = user_token["oauth_token"] + resource_owner_secret = user_token["oauth_token_secret"] + + callback = kwargs.pop("oauth_callback", None) + verifier = kwargs.pop("oauth_verifier", None) + oauth = OAuth1( + resource_owner_key=resource_owner_key, + resource_owner_secret=resource_owner_secret, + client_key=self.source.consumer_key, + client_secret=self.source.consumer_secret, + verifier=verifier, + callback_uri=callback, + ) + kwargs["auth"] = oauth + return super().do_request(method, url, **kwargs) + + @property + def session_key(self) -> str: + return f"oauth-client-{self.source.name}-request-token" diff --git a/authentik/sources/oauth/clients/oauth2.py b/authentik/sources/oauth/clients/oauth2.py new file mode 100644 index 00000000..d5228f4a --- /dev/null +++ b/authentik/sources/oauth/clients/oauth2.py @@ -0,0 +1,113 @@ +"""OAuth 2 Clients""" +from json import loads +from typing import Any, Dict, Optional +from urllib.parse import parse_qsl + +from django.utils.crypto import constant_time_compare, get_random_string +from requests.exceptions import RequestException +from requests.models import Response +from structlog import get_logger + +from authentik.sources.oauth.clients.base import BaseOAuthClient + +LOGGER = get_logger() + + +class OAuth2Client(BaseOAuthClient): + """OAuth2 Client""" + + _default_headers = { + "Accept": "application/json", + } + + def check_application_state(self) -> bool: + "Check optional state parameter." + stored = self.request.session.get(self.session_key, None) + returned = self.request.GET.get("state", None) + check = False + if stored is not None: + if returned is not None: + check = constant_time_compare(stored, returned) + else: + LOGGER.warning("No state parameter returned by the source.") + else: + LOGGER.warning("No state stored in the session.") + return check + + def get_application_state(self) -> str: + "Generate state optional parameter." + return get_random_string(32) + + def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]: + "Fetch access token from callback request." + callback = self.request.build_absolute_uri(self.callback or self.request.path) + if not self.check_application_state(): + LOGGER.warning("Application state check failed.") + return None + if "code" in self.request.GET: + args = { + "client_id": self.source.consumer_key, + "redirect_uri": callback, + "client_secret": self.source.consumer_secret, + "code": self.request.GET["code"], + "grant_type": "authorization_code", + } + else: + LOGGER.warning("No code returned by the source") + return None + try: + response = self.session.request( + "post", + self.source.access_token_url, + data=args, + headers=self._default_headers, + ) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch access token", exc=exc) + return None + else: + return response.json() + + def get_redirect_args(self) -> Dict[str, str]: + "Get request parameters for redirect url." + callback = self.request.build_absolute_uri(self.callback) + client_id: str = self.source.consumer_key + args: Dict[str, str] = { + "client_id": client_id, + "redirect_uri": callback, + "response_type": "code", + } + state = self.get_application_state() + if state is not None: + args["state"] = state + self.request.session[self.session_key] = state + return args + + def parse_raw_token(self, raw_token: str) -> Dict[str, Any]: + "Parse token and secret from raw token response." + # Load as json first then parse as query string + try: + token_data = loads(raw_token) + except ValueError: + return dict(parse_qsl(raw_token)) + else: + return token_data + + def do_request(self, method: str, url: str, **kwargs) -> Response: + "Build remote url request. Constructs necessary auth." + if "token" in kwargs: + token = kwargs.pop("token") + + params = kwargs.get("params", {}) + params["access_token"] = token["access_token"] + kwargs["params"] = params + + headers = kwargs.get("headers", {}) + headers["Authorization"] = f"{token['token_type']} {token['access_token']}" + kwargs["headers"] = headers + return super().do_request(method, url, **kwargs) + + @property + def session_key(self): + return "oauth-client-{0}-request-state".format(self.source.name) diff --git a/authentik/sources/oauth/exceptions.py b/authentik/sources/oauth/exceptions.py new file mode 100644 index 00000000..0929e74c --- /dev/null +++ b/authentik/sources/oauth/exceptions.py @@ -0,0 +1,6 @@ +"""OAuth Source Exception""" +from authentik.lib.sentry import SentryIgnoredException + + +class OAuthSourceException(SentryIgnoredException): + """General Error during OAuth Flow occurred""" diff --git a/authentik/sources/oauth/forms.py b/authentik/sources/oauth/forms.py new file mode 100644 index 00000000..7018c096 --- /dev/null +++ b/authentik/sources/oauth/forms.py @@ -0,0 +1,131 @@ +"""authentik oauth_client forms""" + +from django import forms + +from authentik.admin.forms.source import SOURCE_FORM_FIELDS +from authentik.flows.models import Flow, FlowDesignation +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.types.manager import MANAGER + + +class OAuthSourceForm(forms.ModelForm): + """OAuthSource Form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["authentication_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.AUTHENTICATION + ) + self.fields["enrollment_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.ENROLLMENT + ) + if hasattr(self.Meta, "overrides"): + for overide_field, overide_value in getattr(self.Meta, "overrides").items(): + self.fields[overide_field].initial = overide_value + self.fields[overide_field].widget.attrs["readonly"] = "readonly" + + class Meta: + + model = OAuthSource + fields = SOURCE_FORM_FIELDS + [ + "provider_type", + "request_token_url", + "authorization_url", + "access_token_url", + "profile_url", + "consumer_key", + "consumer_secret", + ] + widgets = { + "name": forms.TextInput(), + "consumer_key": forms.TextInput(), + "consumer_secret": forms.TextInput(), + "provider_type": forms.Select(choices=MANAGER.get_name_tuple()), + } + + +class GitHubOAuthSourceForm(OAuthSourceForm): + """OAuth Source form with pre-determined URL for GitHub""" + + class Meta(OAuthSourceForm.Meta): + + overrides = { + "provider_type": "github", + "request_token_url": "", + "authorization_url": "https://github.com/login/oauth/authorize", + "access_token_url": "https://github.com/login/oauth/access_token", + "profile_url": "https://api.github.com/user", + } + + +class TwitterOAuthSourceForm(OAuthSourceForm): + """OAuth Source form with pre-determined URL for Twitter""" + + class Meta(OAuthSourceForm.Meta): + + overrides = { + "provider_type": "twitter", + "request_token_url": "https://api.twitter.com/oauth/request_token", + "authorization_url": "https://api.twitter.com/oauth/authenticate", + "access_token_url": "https://api.twitter.com/oauth/access_token", + "profile_url": ( + "https://api.twitter.com/1.1/account/" + "verify_credentials.json?include_email=true" + ), + } + + +class FacebookOAuthSourceForm(OAuthSourceForm): + """OAuth Source form with pre-determined URL for Facebook""" + + class Meta(OAuthSourceForm.Meta): + + overrides = { + "provider_type": "facebook", + "request_token_url": "", + "authorization_url": "https://www.facebook.com/v7.0/dialog/oauth", + "access_token_url": "https://graph.facebook.com/v7.0/oauth/access_token", + "profile_url": "https://graph.facebook.com/v7.0/me?fields=id,name,email", + } + + +class DiscordOAuthSourceForm(OAuthSourceForm): + """OAuth Source form with pre-determined URL for Discord""" + + class Meta(OAuthSourceForm.Meta): + + overrides = { + "provider_type": "discord", + "request_token_url": "", + "authorization_url": "https://discord.com/api/oauth2/authorize", + "access_token_url": "https://discord.com/api/oauth2/token", + "profile_url": "https://discord.com/api/users/@me", + } + + +class GoogleOAuthSourceForm(OAuthSourceForm): + """OAuth Source form with pre-determined URL for Google""" + + class Meta(OAuthSourceForm.Meta): + + overrides = { + "provider_type": "google", + "request_token_url": "", + "authorization_url": "https://accounts.google.com/o/oauth2/auth", + "access_token_url": "https://accounts.google.com/o/oauth2/token", + "profile_url": "https://www.googleapis.com/oauth2/v1/userinfo", + } + + +class AzureADOAuthSourceForm(OAuthSourceForm): + """OAuth Source form with pre-determined URL for AzureAD""" + + class Meta(OAuthSourceForm.Meta): + + overrides = { + "provider_type": "azure-ad", + "request_token_url": "", + "authorization_url": "https://login.microsoftonline.com/common/oauth2/authorize", + "access_token_url": "https://login.microsoftonline.com/common/oauth2/token", + "profile_url": "https://graph.windows.net/myorganization/me?api-version=1.6", + } diff --git a/authentik/sources/oauth/migrations/0001_initial.py b/authentik/sources/oauth/migrations/0001_initial.py new file mode 100644 index 00000000..b13defbe --- /dev/null +++ b/authentik/sources/oauth/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="OAuthSource", + fields=[ + ( + "source_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.Source", + ), + ), + ("provider_type", models.CharField(max_length=255)), + ( + "request_token_url", + models.CharField( + blank=True, max_length=255, verbose_name="Request Token URL" + ), + ), + ( + "authorization_url", + models.CharField(max_length=255, verbose_name="Authorization URL"), + ), + ( + "access_token_url", + models.CharField(max_length=255, verbose_name="Access Token URL"), + ), + ( + "profile_url", + models.CharField(max_length=255, verbose_name="Profile URL"), + ), + ("consumer_key", models.TextField()), + ("consumer_secret", models.TextField()), + ], + options={ + "verbose_name": "Generic OAuth Source", + "verbose_name_plural": "Generic OAuth Sources", + }, + bases=("authentik_core.source",), + ), + migrations.CreateModel( + name="UserOAuthSourceConnection", + fields=[ + ( + "usersourceconnection_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.UserSourceConnection", + ), + ), + ("identifier", models.CharField(max_length=255)), + ("access_token", models.TextField(blank=True, default=None, null=True)), + ], + options={ + "verbose_name": "User OAuth Source Connection", + "verbose_name_plural": "User OAuth Source Connections", + }, + bases=("authentik_core.usersourceconnection",), + ), + ] diff --git a/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py b/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py new file mode 100644 index 00000000..7452ef5b --- /dev/null +++ b/authentik/sources/oauth/migrations/0002_auto_20200520_1108.py @@ -0,0 +1,50 @@ +# Generated by Django 3.0.6 on 2020-05-20 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_oauth", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="oauthsource", + name="access_token_url", + field=models.CharField( + help_text="URL used by authentik to retrive tokens.", + max_length=255, + verbose_name="Access Token URL", + ), + ), + migrations.AlterField( + model_name="oauthsource", + name="request_token_url", + field=models.CharField( + blank=True, + help_text="URL used to request the initial token. This URL is only required for OAuth 1.", + max_length=255, + verbose_name="Request Token URL", + ), + ), + migrations.AlterField( + model_name="oauthsource", + name="authorization_url", + field=models.CharField( + help_text="URL the user is redirect to to conest the flow.", + max_length=255, + verbose_name="Authorization URL", + ), + ), + migrations.AlterField( + model_name="oauthsource", + name="profile_url", + field=models.CharField( + help_text="URL used by authentik to get user information.", + max_length=255, + verbose_name="Profile URL", + ), + ), + ] diff --git a/passbook/sources/oauth/migrations/__init__.py b/authentik/sources/oauth/migrations/__init__.py similarity index 100% rename from passbook/sources/oauth/migrations/__init__.py rename to authentik/sources/oauth/migrations/__init__.py diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py new file mode 100644 index 00000000..4e0534fc --- /dev/null +++ b/authentik/sources/oauth/models.py @@ -0,0 +1,207 @@ +"""OAuth Client models""" +from typing import Optional, Type + +from django.db import models +from django.forms import ModelForm +from django.urls import reverse, reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from authentik.core.models import Source, UserSourceConnection +from authentik.core.types import UILoginButton + + +class OAuthSource(Source): + """Login using a Generic OAuth provider.""" + + provider_type = models.CharField(max_length=255) + request_token_url = models.CharField( + blank=True, + max_length=255, + verbose_name=_("Request Token URL"), + help_text=_( + "URL used to request the initial token. This URL is only required for OAuth 1." + ), + ) + authorization_url = models.CharField( + max_length=255, + verbose_name=_("Authorization URL"), + help_text=_("URL the user is redirect to to conest the flow."), + ) + access_token_url = models.CharField( + max_length=255, + verbose_name=_("Access Token URL"), + help_text=_("URL used by authentik to retrive tokens."), + ) + profile_url = models.CharField( + max_length=255, + verbose_name=_("Profile URL"), + help_text=_("URL used by authentik to get user information."), + ) + consumer_key = models.TextField() + consumer_secret = models.TextField() + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import OAuthSourceForm + + return OAuthSourceForm + + @property + def ui_login_button(self) -> UILoginButton: + return UILoginButton( + url=reverse_lazy( + "authentik_sources_oauth:oauth-client-login", + kwargs={"source_slug": self.slug}, + ), + icon_path=f"authentik/sources/{self.provider_type}.svg", + name=self.name, + ) + + @property + def ui_additional_info(self) -> str: + url = reverse_lazy( + "authentik_sources_oauth:oauth-client-callback", + kwargs={"source_slug": self.slug}, + ) + return f"Callback URL:
{url}
" + + @property + def ui_user_settings(self) -> Optional[str]: + view_name = "authentik_sources_oauth:oauth-client-user" + return reverse(view_name, kwargs={"source_slug": self.slug}) + + def __str__(self) -> str: + return f"OAuth Source {self.name}" + + class Meta: + + verbose_name = _("Generic OAuth Source") + verbose_name_plural = _("Generic OAuth Sources") + + +class GitHubOAuthSource(OAuthSource): + """Social Login using GitHub.com or a GitHub-Enterprise Instance.""" + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import GitHubOAuthSourceForm + + return GitHubOAuthSourceForm + + class Meta: + + abstract = True + verbose_name = _("GitHub OAuth Source") + verbose_name_plural = _("GitHub OAuth Sources") + + +class TwitterOAuthSource(OAuthSource): + """Social Login using Twitter.com""" + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import TwitterOAuthSourceForm + + return TwitterOAuthSourceForm + + class Meta: + + abstract = True + verbose_name = _("Twitter OAuth Source") + verbose_name_plural = _("Twitter OAuth Sources") + + +class FacebookOAuthSource(OAuthSource): + """Social Login using Facebook.com.""" + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import FacebookOAuthSourceForm + + return FacebookOAuthSourceForm + + class Meta: + + abstract = True + verbose_name = _("Facebook OAuth Source") + verbose_name_plural = _("Facebook OAuth Sources") + + +class DiscordOAuthSource(OAuthSource): + """Social Login using Discord.""" + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import DiscordOAuthSourceForm + + return DiscordOAuthSourceForm + + class Meta: + + abstract = True + verbose_name = _("Discord OAuth Source") + verbose_name_plural = _("Discord OAuth Sources") + + +class GoogleOAuthSource(OAuthSource): + """Social Login using Google or Gsuite.""" + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import GoogleOAuthSourceForm + + return GoogleOAuthSourceForm + + class Meta: + + abstract = True + verbose_name = _("Google OAuth Source") + verbose_name_plural = _("Google OAuth Sources") + + +class AzureADOAuthSource(OAuthSource): + """Social Login using Azure AD.""" + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import AzureADOAuthSourceForm + + return AzureADOAuthSourceForm + + class Meta: + + abstract = True + verbose_name = _("Azure AD OAuth Source") + verbose_name_plural = _("Azure AD OAuth Sources") + + +class OpenIDOAuthSource(OAuthSource): + """Login using a Generic OpenID-Connect compliant provider.""" + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.oauth.forms import OAuthSourceForm + + return OAuthSourceForm + + class Meta: + + abstract = True + verbose_name = _("OpenID OAuth Source") + verbose_name_plural = _("OpenID OAuth Sources") + + +class UserOAuthSourceConnection(UserSourceConnection): + """Authorized remote OAuth provider.""" + + identifier = models.CharField(max_length=255) + access_token = models.TextField(blank=True, null=True, default=None) + + def save(self, *args, **kwargs): + self.access_token = self.access_token or None + super().save(*args, **kwargs) + + class Meta: + + verbose_name = _("User OAuth Source Connection") + verbose_name_plural = _("User OAuth Source Connections") diff --git a/authentik/sources/oauth/settings.py b/authentik/sources/oauth/settings.py new file mode 100644 index 00000000..45792a85 --- /dev/null +++ b/authentik/sources/oauth/settings.py @@ -0,0 +1,12 @@ +"""Oauth2 Client Settings""" + +AUTHENTIK_SOURCES_OAUTH_TYPES = [ + "authentik.sources.oauth.types.discord", + "authentik.sources.oauth.types.facebook", + "authentik.sources.oauth.types.github", + "authentik.sources.oauth.types.google", + "authentik.sources.oauth.types.reddit", + "authentik.sources.oauth.types.twitter", + "authentik.sources.oauth.types.azure_ad", + "authentik.sources.oauth.types.oidc", +] diff --git a/authentik/sources/oauth/templates/oauth_client/user.html b/authentik/sources/oauth/templates/oauth_client/user.html new file mode 100644 index 00000000..0576f097 --- /dev/null +++ b/authentik/sources/oauth/templates/oauth_client/user.html @@ -0,0 +1,24 @@ +{% load i18n %} + +
+
+ {% blocktrans with source_name=source.name %} + Source {{ source_name }} + {% endblocktrans %} +
+
+ {% if connections.exists %} +

{% trans 'Connected.' %}

+ + {% trans 'Disconnect' %} + + {% else %} +

Not connected.

+ + {% trans 'Connect' %} + + {% endif %} +
+
diff --git a/authentik/sources/oauth/tests.py b/authentik/sources/oauth/tests.py new file mode 100644 index 00000000..a6a22dff --- /dev/null +++ b/authentik/sources/oauth/tests.py @@ -0,0 +1,38 @@ +"""OAuth Source tests""" +from django.shortcuts import reverse +from django.test import Client, TestCase + +from authentik.sources.oauth.models import OAuthSource + + +class OAuthSourceTests(TestCase): + """OAuth Source tests""" + + def setUp(self): + self.client = Client() + self.source = OAuthSource.objects.create( + name="test", + slug="test", + provider_type="openid-connect", + authorization_url="", + profile_url="", + consumer_key="", + ) + + def test_source_redirect(self): + """test redirect view""" + self.client.get( + reverse( + "authentik_sources_oauth:oauth-client-login", + kwargs={"source_slug": self.source.slug}, + ) + ) + + def test_source_callback(self): + """test callback view""" + self.client.get( + reverse( + "authentik_sources_oauth:oauth-client-callback", + kwargs={"source_slug": self.source.slug}, + ) + ) diff --git a/passbook/sources/oauth/types/__init__.py b/authentik/sources/oauth/types/__init__.py similarity index 100% rename from passbook/sources/oauth/types/__init__.py rename to authentik/sources/oauth/types/__init__.py diff --git a/authentik/sources/oauth/types/azure_ad.py b/authentik/sources/oauth/types/azure_ad.py new file mode 100644 index 00000000..4f370593 --- /dev/null +++ b/authentik/sources/oauth/types/azure_ad.py @@ -0,0 +1,28 @@ +"""AzureAD OAuth2 Views""" +from typing import Any, Dict +from uuid import UUID + +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback + + +@MANAGER.source(kind=RequestKind.callback, name="Azure AD") +class AzureADOAuthCallback(OAuthCallback): + """AzureAD OAuth2 Callback""" + + def get_user_id(self, source: OAuthSource, info: Dict[str, Any]) -> str: + return str(UUID(info.get("objectId")).int) + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + mail = info.get("mail", None) or info.get("otherMails", [None])[0] + return { + "username": info.get("displayName"), + "email": mail, + "name": info.get("displayName"), + } diff --git a/authentik/sources/oauth/types/discord.py b/authentik/sources/oauth/types/discord.py new file mode 100644 index 00000000..af0ec3e3 --- /dev/null +++ b/authentik/sources/oauth/types/discord.py @@ -0,0 +1,34 @@ +"""Discord OAuth Views""" +from typing import Any, Dict + +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + + +@MANAGER.source(kind=RequestKind.redirect, name="Discord") +class DiscordOAuthRedirect(OAuthRedirect): + """Discord OAuth2 Redirect""" + + def get_additional_parameters(self, source): + return { + "scope": "email identify", + } + + +@MANAGER.source(kind=RequestKind.callback, name="Discord") +class DiscordOAuth2Callback(OAuthCallback): + """Discord OAuth2 Callback""" + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "username": info.get("username"), + "email": info.get("email", None), + "name": info.get("username"), + } diff --git a/authentik/sources/oauth/types/facebook.py b/authentik/sources/oauth/types/facebook.py new file mode 100644 index 00000000..78fcb039 --- /dev/null +++ b/authentik/sources/oauth/types/facebook.py @@ -0,0 +1,47 @@ +"""Facebook OAuth Views""" +from typing import Any, Dict, Optional + +from facebook import GraphAPI + +from authentik.sources.oauth.clients.oauth2 import OAuth2Client +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + + +@MANAGER.source(kind=RequestKind.redirect, name="Facebook") +class FacebookOAuthRedirect(OAuthRedirect): + """Facebook OAuth2 Redirect""" + + def get_additional_parameters(self, source): + return { + "scope": "email", + } + + +class FacebookOAuth2Client(OAuth2Client): + """Facebook OAuth2 Client""" + + def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]: + api = GraphAPI(access_token=token["access_token"]) + return api.get_object("me", fields="id,name,email") + + +@MANAGER.source(kind=RequestKind.callback, name="Facebook") +class FacebookOAuth2Callback(OAuthCallback): + """Facebook OAuth2 Callback""" + + client_class = FacebookOAuth2Client + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "username": info.get("name"), + "email": info.get("email"), + "name": info.get("name"), + } diff --git a/authentik/sources/oauth/types/github.py b/authentik/sources/oauth/types/github.py new file mode 100644 index 00000000..b0abbb49 --- /dev/null +++ b/authentik/sources/oauth/types/github.py @@ -0,0 +1,23 @@ +"""GitHub OAuth Views""" +from typing import Any, Dict + +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback + + +@MANAGER.source(kind=RequestKind.callback, name="GitHub") +class GitHubOAuth2Callback(OAuthCallback): + """GitHub OAuth2 Callback""" + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "username": info.get("login"), + "email": info.get("email"), + "name": info.get("name"), + } diff --git a/authentik/sources/oauth/types/google.py b/authentik/sources/oauth/types/google.py new file mode 100644 index 00000000..813fd5ea --- /dev/null +++ b/authentik/sources/oauth/types/google.py @@ -0,0 +1,34 @@ +"""Google OAuth Views""" +from typing import Any, Dict + +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + + +@MANAGER.source(kind=RequestKind.redirect, name="Google") +class GoogleOAuthRedirect(OAuthRedirect): + """Google OAuth2 Redirect""" + + def get_additional_parameters(self, source): + return { + "scope": "email profile", + } + + +@MANAGER.source(kind=RequestKind.callback, name="Google") +class GoogleOAuth2Callback(OAuthCallback): + """Google OAuth2 Callback""" + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "username": info.get("email"), + "email": info.get("email"), + "name": info.get("name"), + } diff --git a/authentik/sources/oauth/types/manager.py b/authentik/sources/oauth/types/manager.py new file mode 100644 index 00000000..6824ab62 --- /dev/null +++ b/authentik/sources/oauth/types/manager.py @@ -0,0 +1,64 @@ +"""Source type manager""" +from enum import Enum +from typing import Callable, Dict, List + +from django.utils.text import slugify +from structlog import get_logger + +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + +LOGGER = get_logger() + + +class RequestKind(Enum): + """Enum of OAuth Request types""" + + callback = "callback" + redirect = "redirect" + + +class SourceTypeManager: + """Manager to hold all Source types.""" + + __source_types: Dict[RequestKind, Dict[str, Callable]] = {} + __names: List[str] = [] + + def source(self, kind: RequestKind, name: str): + """Class decorator to register classes inline.""" + + def inner_wrapper(cls): + if kind.value not in self.__source_types: + self.__source_types[kind.value] = {} + self.__source_types[kind.value][slugify(name)] = cls + self.__names.append(name) + return cls + + return inner_wrapper + + def get_name_tuple(self): + """Get list of tuples of all registered names""" + return [(slugify(x), x) for x in set(self.__names)] + + def find(self, source: OAuthSource, kind: RequestKind) -> Callable: + """Find fitting Source Type""" + if kind.value in self.__source_types: + if source.provider_type in self.__source_types[kind.value]: + return self.__source_types[kind.value][source.provider_type] + LOGGER.warning( + "no matching type found, using default", + wanted=source.provider_type, + have=self.__source_types[kind.value].keys(), + ) + # Return defaults + if kind == RequestKind.callback: + return OAuthCallback + if kind == RequestKind.redirect: + return OAuthRedirect + raise KeyError( + f"Provider Type {source.provider_type} (type {kind.value}) not found." + ) + + +MANAGER = SourceTypeManager() diff --git a/authentik/sources/oauth/types/oidc.py b/authentik/sources/oauth/types/oidc.py new file mode 100644 index 00000000..90742b1c --- /dev/null +++ b/authentik/sources/oauth/types/oidc.py @@ -0,0 +1,37 @@ +"""OpenID Connect OAuth Views""" +from typing import Any, Dict + +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + + +@MANAGER.source(kind=RequestKind.redirect, name="OpenID Connect") +class OpenIDConnectOAuthRedirect(OAuthRedirect): + """OpenIDConnect OAuth2 Redirect""" + + def get_additional_parameters(self, source: OAuthSource): + return { + "scope": "openid email profile", + } + + +@MANAGER.source(kind=RequestKind.callback, name="OpenID Connect") +class OpenIDConnectOAuth2Callback(OAuthCallback): + """OpenIDConnect OAuth2 Callback""" + + def get_user_id(self, source: OAuthSource, info: Dict[str, str]) -> str: + return info.get("sub", "") + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "username": info.get("nickname"), + "email": info.get("email"), + "name": info.get("name"), + } diff --git a/authentik/sources/oauth/types/reddit.py b/authentik/sources/oauth/types/reddit.py new file mode 100644 index 00000000..33852a77 --- /dev/null +++ b/authentik/sources/oauth/types/reddit.py @@ -0,0 +1,50 @@ +"""Reddit OAuth Views""" +from typing import Any, Dict + +from requests.auth import HTTPBasicAuth + +from authentik.sources.oauth.clients.oauth2 import OAuth2Client +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback +from authentik.sources.oauth.views.redirect import OAuthRedirect + + +@MANAGER.source(kind=RequestKind.redirect, name="reddit") +class RedditOAuthRedirect(OAuthRedirect): + """Reddit OAuth2 Redirect""" + + def get_additional_parameters(self, source): + return { + "scope": "identity", + "duration": "permanent", + } + + +class RedditOAuth2Client(OAuth2Client): + """Reddit OAuth2 Client""" + + def get_access_token(self, **request_kwargs): + "Fetch access token from callback request." + auth = HTTPBasicAuth(self.source.consumer_key, self.source.consumer_secret) + return super().get_access_token(auth=auth) + + +@MANAGER.source(kind=RequestKind.callback, name="reddit") +class RedditOAuth2Callback(OAuthCallback): + """Reddit OAuth2 Callback""" + + client_class = RedditOAuth2Client + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "username": info.get("name"), + "email": None, + "name": info.get("name"), + "password": None, + } diff --git a/authentik/sources/oauth/types/twitter.py b/authentik/sources/oauth/types/twitter.py new file mode 100644 index 00000000..bd27f22d --- /dev/null +++ b/authentik/sources/oauth/types/twitter.py @@ -0,0 +1,23 @@ +"""Twitter OAuth Views""" +from typing import Any, Dict + +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.types.manager import MANAGER, RequestKind +from authentik.sources.oauth.views.callback import OAuthCallback + + +@MANAGER.source(kind=RequestKind.callback, name="Twitter") +class TwitterOAuthCallback(OAuthCallback): + """Twitter OAuth2 Callback""" + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + return { + "username": info.get("screen_name"), + "email": info.get("email"), + "name": info.get("name"), + } diff --git a/authentik/sources/oauth/urls.py b/authentik/sources/oauth/urls.py new file mode 100644 index 00000000..085546d6 --- /dev/null +++ b/authentik/sources/oauth/urls.py @@ -0,0 +1,30 @@ +"""authentik OAuth source urls""" + +from django.urls import path + +from authentik.sources.oauth.types.manager import RequestKind +from authentik.sources.oauth.views.dispatcher import DispatcherView +from authentik.sources.oauth.views.user import DisconnectView, UserSettingsView + +urlpatterns = [ + path( + "login//", + DispatcherView.as_view(kind=RequestKind.redirect), + name="oauth-client-login", + ), + path( + "callback//", + DispatcherView.as_view(kind=RequestKind.callback), + name="oauth-client-callback", + ), + path( + "user//", + UserSettingsView.as_view(), + name="oauth-client-user", + ), + path( + "user//disconnect/", + DisconnectView.as_view(), + name="oauth-client-disconnect", + ), +] diff --git a/passbook/sources/oauth/views/__init__.py b/authentik/sources/oauth/views/__init__.py similarity index 100% rename from passbook/sources/oauth/views/__init__.py rename to authentik/sources/oauth/views/__init__.py diff --git a/authentik/sources/oauth/views/base.py b/authentik/sources/oauth/views/base.py new file mode 100644 index 00000000..bfdd73fa --- /dev/null +++ b/authentik/sources/oauth/views/base.py @@ -0,0 +1,27 @@ +"""OAuth Base views""" +from typing import Optional, Type + +from django.http.request import HttpRequest + +from authentik.sources.oauth.clients.base import BaseOAuthClient +from authentik.sources.oauth.clients.oauth1 import OAuthClient +from authentik.sources.oauth.clients.oauth2 import OAuth2Client +from authentik.sources.oauth.models import OAuthSource + + +# pylint: disable=too-few-public-methods +class OAuthClientMixin: + "Mixin for getting OAuth client for a source." + + request: HttpRequest # Set by View class + + client_class: Optional[Type[BaseOAuthClient]] = None + + def get_client(self, source: OAuthSource, **kwargs) -> BaseOAuthClient: + "Get instance of the OAuth client for this source." + if self.client_class is not None: + # pylint: disable=not-callable + return self.client_class(source, self.request, **kwargs) + if source.request_token_url: + return OAuthClient(source, self.request, **kwargs) + return OAuth2Client(source, self.request, **kwargs) diff --git a/authentik/sources/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py new file mode 100644 index 00000000..c9874e78 --- /dev/null +++ b/authentik/sources/oauth/views/callback.py @@ -0,0 +1,234 @@ +"""OAuth Callback Views""" +from typing import Any, Dict, Optional + +from django.conf import settings +from django.contrib import messages +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.generic import View +from structlog import get_logger + +from authentik.audit.models import Event, EventAction +from authentik.core.models import User +from authentik.flows.models import Flow, in_memory_stage +from authentik.flows.planner import ( + PLAN_CONTEXT_PENDING_USER, + PLAN_CONTEXT_SSO, + FlowPlanner, +) +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.lib.utils.urls import redirect_with_qs +from authentik.policies.utils import delete_none_keys +from authentik.sources.oauth.auth import AuthorizedServiceBackend +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from authentik.sources.oauth.views.base import OAuthClientMixin +from authentik.sources.oauth.views.flows import ( + PLAN_CONTEXT_SOURCES_OAUTH_ACCESS, + PostUserEnrollmentStage, +) +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +LOGGER = get_logger() + + +class OAuthCallback(OAuthClientMixin, View): + "Base OAuth callback view." + + source_id = None + source = None + + # pylint: disable=too-many-return-statements + def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse: + """View Get handler""" + slug = kwargs.get("source_slug", "") + try: + self.source = OAuthSource.objects.get(slug=slug) + except OAuthSource.DoesNotExist: + raise Http404(f"Unknown OAuth source '{slug}'.") + + if not self.source.enabled: + raise Http404(f"Source {slug} is not enabled.") + client = self.get_client( + self.source, callback=self.get_callback_url(self.source) + ) + # Fetch access token + token = client.get_access_token() + if token is None: + return self.handle_login_failure(self.source, "Could not retrieve token.") + if "error" in token: + return self.handle_login_failure(self.source, token["error"]) + # Fetch profile info + info = client.get_profile_info(token) + if info is None: + return self.handle_login_failure(self.source, "Could not retrieve profile.") + identifier = self.get_user_id(self.source, info) + if identifier is None: + return self.handle_login_failure(self.source, "Could not determine id.") + # Get or create access record + defaults = { + "access_token": token.get("access_token"), + } + existing = UserOAuthSourceConnection.objects.filter( + source=self.source, identifier=identifier + ) + + if existing.exists(): + connection = existing.first() + connection.access_token = token.get("access_token") + UserOAuthSourceConnection.objects.filter(pk=connection.pk).update( + **defaults + ) + else: + connection = UserOAuthSourceConnection( + source=self.source, + identifier=identifier, + access_token=token.get("access_token"), + ) + user = AuthorizedServiceBackend().authenticate( + source=self.source, identifier=identifier, request=request + ) + if user is None: + if self.request.user.is_authenticated: + LOGGER.debug("Linking existing user", source=self.source) + return self.handle_existing_user_link(self.source, connection, info) + LOGGER.debug("Handling enrollment of new user", source=self.source) + return self.handle_enroll(self.source, connection, info) + LOGGER.debug("Handling existing user", source=self.source) + return self.handle_existing_user(self.source, user, connection, info) + + # pylint: disable=unused-argument + def get_callback_url(self, source: OAuthSource) -> str: + "Return callback url if different than the current url." + return "" + + # pylint: disable=unused-argument + def get_error_redirect(self, source: OAuthSource, reason: str) -> str: + "Return url to redirect on login failure." + return settings.LOGIN_URL + + def get_user_enroll_context( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> Dict[str, Any]: + """Create a dict of User data""" + raise NotImplementedError() + + # pylint: disable=unused-argument + def get_user_id( + self, source: UserOAuthSourceConnection, info: Dict[str, Any] + ) -> Optional[str]: + """Return unique identifier from the profile info.""" + if "id" in info: + return info["id"] + return None + + def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse: + "Message user and redirect on error." + LOGGER.warning("Authentication Failure", reason=reason) + messages.error(self.request, _("Authentication Failed.")) + return redirect(self.get_error_redirect(source, reason)) + + def handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: + """Prepare Authentication Plan, redirect user FlowExecutor""" + kwargs.update( + { + # Since we authenticate the user by their token, they have no backend set + PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", + PLAN_CONTEXT_SSO: True, + } + ) + # We run the Flow planner here so we can pass the Pending user in the context + planner = FlowPlanner(flow) + plan = planner.plan(self.request, kwargs) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_flows:flow-executor-shell", + self.request.GET, + flow_slug=flow.slug, + ) + + # pylint: disable=unused-argument + def handle_existing_user( + self, + source: OAuthSource, + user: User, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> HttpResponse: + "Login user and redirect." + messages.success( + self.request, + _( + "Successfully authenticated with %(source)s!" + % {"source": self.source.name} + ), + ) + flow_kwargs = {PLAN_CONTEXT_PENDING_USER: user} + return self.handle_login_flow(source.authentication_flow, **flow_kwargs) + + def handle_existing_user_link( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> HttpResponse: + """Handler when the user was already authenticated and linked an external source + to their account.""" + # there's already a user logged in, just link them up + user = self.request.user + access.user = user + access.save() + UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) + Event.new( + EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source + ).from_http(self.request) + messages.success( + self.request, + _("Successfully linked %(source)s!" % {"source": self.source.name}), + ) + return redirect( + reverse( + "authentik_sources_oauth:oauth-client-user", + kwargs={"source_slug": self.source.slug}, + ) + ) + + def handle_enroll( + self, + source: OAuthSource, + access: UserOAuthSourceConnection, + info: Dict[str, Any], + ) -> HttpResponse: + """User was not authenticated and previous request was not authenticated.""" + messages.success( + self.request, + _( + "Successfully authenticated with %(source)s!" + % {"source": self.source.name} + ), + ) + # Because we inject a stage into the planned flow, we can't use `self.handle_login_flow` + context = { + # Since we authenticate the user by their token, they have no backend set + PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_PROMPT: delete_none_keys( + self.get_user_enroll_context(source, access, info) + ), + PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access, + } + # We run the Flow planner here so we can pass the Pending user in the context + planner = FlowPlanner(source.enrollment_flow) + plan = planner.plan(self.request, context) + plan.append(in_memory_stage(PostUserEnrollmentStage)) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_flows:flow-executor-shell", + self.request.GET, + flow_slug=source.enrollment_flow.slug, + ) diff --git a/authentik/sources/oauth/views/dispatcher.py b/authentik/sources/oauth/views/dispatcher.py new file mode 100644 index 00000000..bb192ad7 --- /dev/null +++ b/authentik/sources/oauth/views/dispatcher.py @@ -0,0 +1,26 @@ +"""Dispatch OAuth views to respective views""" +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.views import View +from structlog import get_logger + +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.types.manager import MANAGER, RequestKind + +LOGGER = get_logger() + + +class DispatcherView(View): + """Dispatch OAuth Redirect/Callback views to their proper class based on URL parameters""" + + kind = "" + + def dispatch(self, *args, **kwargs): + """Find Source by slug and forward request""" + slug = kwargs.get("source_slug", None) + if not slug: + raise Http404 + source = get_object_or_404(OAuthSource, slug=slug) + view = MANAGER.find(source, kind=RequestKind(self.kind)) + LOGGER.debug("dispatching OAuth2 request to", view=view, kind=self.kind) + return view.as_view()(*args, **kwargs) diff --git a/authentik/sources/oauth/views/flows.py b/authentik/sources/oauth/views/flows.py new file mode 100644 index 00000000..ac326f1f --- /dev/null +++ b/authentik/sources/oauth/views/flows.py @@ -0,0 +1,30 @@ +"""OAuth Stages""" +from django.http import HttpRequest, HttpResponse + +from authentik.audit.models import Event, EventAction +from authentik.core.models import User +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.sources.oauth.models import UserOAuthSourceConnection + +PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access" + + +class PostUserEnrollmentStage(StageView): + """Dynamically injected stage which saves the OAuth Connection after + the user has been enrolled.""" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + access: UserOAuthSourceConnection = self.executor.plan.context[ + PLAN_CONTEXT_SOURCES_OAUTH_ACCESS + ] + user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + access.user = user + access.save() + UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) + Event.new( + EventAction.SOURCE_LINKED, + message="Linked OAuth Source", + source=access.source, + ).from_http(self.request) + return self.executor.stage_ok() diff --git a/authentik/sources/oauth/views/redirect.py b/authentik/sources/oauth/views/redirect.py new file mode 100644 index 00000000..af1bb4dc --- /dev/null +++ b/authentik/sources/oauth/views/redirect.py @@ -0,0 +1,45 @@ +"""OAuth Redirect Views""" +from typing import Any, Dict + +from django.http import Http404 +from django.urls import reverse +from django.views.generic import RedirectView +from structlog import get_logger + +from authentik.sources.oauth.models import OAuthSource +from authentik.sources.oauth.views.base import OAuthClientMixin + +LOGGER = get_logger() + + +class OAuthRedirect(OAuthClientMixin, RedirectView): + "Redirect user to OAuth source to enable access." + + permanent = False + params = None + + # pylint: disable=unused-argument + def get_additional_parameters(self, source: OAuthSource) -> Dict[str, Any]: + "Return additional redirect parameters for this source." + return self.params or {} + + def get_callback_url(self, source: OAuthSource) -> str: + "Return the callback url for this source." + return reverse( + "authentik_sources_oauth:oauth-client-callback", + kwargs={"source_slug": source.slug}, + ) + + def get_redirect_url(self, **kwargs) -> str: + "Build redirect url for a given source." + slug = kwargs.get("source_slug", "") + try: + source = OAuthSource.objects.get(slug=slug) + except OAuthSource.DoesNotExist: + raise Http404(f"Unknown OAuth source '{slug}'.") + else: + if not source.enabled: + raise Http404(f"source {slug} is not enabled.") + client = self.get_client(source, callback=self.get_callback_url(source)) + params = self.get_additional_parameters(source) + return client.get_redirect_url(params) diff --git a/authentik/sources/oauth/views/user.py b/authentik/sources/oauth/views/user.py new file mode 100644 index 00000000..c6d7dbe5 --- /dev/null +++ b/authentik/sources/oauth/views/user.py @@ -0,0 +1,70 @@ +"""authentik oauth_client user views""" +from typing import Optional + +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.generic import TemplateView, View + +from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection + + +class UserSettingsView(LoginRequiredMixin, TemplateView): + """Show user current connection state""" + + template_name = "oauth_client/user.html" + + def get_context_data(self, **kwargs): + source = get_object_or_404(OAuthSource, slug=self.kwargs.get("source_slug")) + connections = UserOAuthSourceConnection.objects.filter( + user=self.request.user, source=source + ) + kwargs["source"] = source + kwargs["connections"] = connections + return super().get_context_data(**kwargs) + + +class DisconnectView(LoginRequiredMixin, View): + """Delete connection with source""" + + source: Optional[OAuthSource] = None + aas: Optional[UserOAuthSourceConnection] = None + + def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse: + self.source = get_object_or_404(OAuthSource, slug=source_slug) + self.aas = get_object_or_404( + UserOAuthSourceConnection, source=self.source, user=request.user + ) + return super().dispatch(request, source_slug) + + def post(self, request: HttpRequest, source_slug: str) -> HttpResponse: + """Delete connection object""" + if "confirmdelete" in request.POST: + # User confirmed deletion + self.aas.delete() + messages.success(request, _("Connection successfully deleted")) + return redirect( + reverse( + "authentik_sources_oauth:oauth-client-user", + kwargs={"source_slug": self.source.slug}, + ) + ) + return self.get(request, source_slug) + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, source_slug: str) -> HttpResponse: + """Show delete form""" + return render( + request, + "generic/delete.html", + { + "object": self.source, + "delete_url": reverse( + "authentik_sources_oauth:oauth-client-disconnect", + kwargs={"source_slug": self.source.slug}, + ), + }, + ) diff --git a/passbook/sources/saml/__init__.py b/authentik/sources/saml/__init__.py similarity index 100% rename from passbook/sources/saml/__init__.py rename to authentik/sources/saml/__init__.py diff --git a/authentik/sources/saml/api.py b/authentik/sources/saml/api.py new file mode 100644 index 00000000..cb2c57ca --- /dev/null +++ b/authentik/sources/saml/api.py @@ -0,0 +1,33 @@ +"""SAMLSource API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.admin.forms.source import SOURCE_FORM_FIELDS +from authentik.sources.saml.models import SAMLSource + + +class SAMLSourceSerializer(ModelSerializer): + """SAMLSource Serializer""" + + class Meta: + + model = SAMLSource + fields = SOURCE_FORM_FIELDS + [ + "issuer", + "sso_url", + "slo_url", + "allow_idp_initiated", + "name_id_policy", + "binding_type", + "signing_kp", + "digest_algorithm", + "signature_algorithm", + "temporary_user_delete_after", + ] + + +class SAMLSourceViewSet(ModelViewSet): + """SAMLSource Viewset""" + + queryset = SAMLSource.objects.all() + serializer_class = SAMLSourceSerializer diff --git a/authentik/sources/saml/apps.py b/authentik/sources/saml/apps.py new file mode 100644 index 00000000..5b361551 --- /dev/null +++ b/authentik/sources/saml/apps.py @@ -0,0 +1,17 @@ +"""Authentik SAML app config""" + +from importlib import import_module + +from django.apps import AppConfig + + +class AuthentikSourceSAMLConfig(AppConfig): + """authentik saml_idp app config""" + + name = "authentik.sources.saml" + label = "authentik_sources_saml" + verbose_name = "authentik Sources.SAML" + mountpoint = "source/saml/" + + def ready(self): + import_module("authentik.sources.saml.signals") diff --git a/authentik/sources/saml/exceptions.py b/authentik/sources/saml/exceptions.py new file mode 100644 index 00000000..09f7afbf --- /dev/null +++ b/authentik/sources/saml/exceptions.py @@ -0,0 +1,18 @@ +"""authentik saml source exceptions""" +from authentik.lib.sentry import SentryIgnoredException + + +class MissingSAMLResponse(SentryIgnoredException): + """Exception raised when request does not contain SAML Response.""" + + +class UnsupportedNameIDFormat(SentryIgnoredException): + """Exception raised when SAML Response contains NameID Format not supported.""" + + +class MismatchedRequestID(SentryIgnoredException): + """Exception raised when the returned request ID doesn't match the saved ID.""" + + +class InvalidSignature(SentryIgnoredException): + """Signature of XML Object is either missing or invalid""" diff --git a/authentik/sources/saml/forms.py b/authentik/sources/saml/forms.py new file mode 100644 index 00000000..bd2fdcf9 --- /dev/null +++ b/authentik/sources/saml/forms.py @@ -0,0 +1,49 @@ +"""authentik SAML SP Forms""" + +from django import forms + +from authentik.admin.forms.source import SOURCE_FORM_FIELDS +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow, FlowDesignation +from authentik.sources.saml.models import SAMLSource + + +class SAMLSourceForm(forms.ModelForm): + """SAML Provider form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["authentication_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.AUTHENTICATION + ) + self.fields["enrollment_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.ENROLLMENT + ) + self.fields["signing_kp"].queryset = CertificateKeyPair.objects.filter( + certificate_data__isnull=False, + key_data__isnull=False, + ) + + class Meta: + + model = SAMLSource + fields = SOURCE_FORM_FIELDS + [ + "issuer", + "sso_url", + "slo_url", + "binding_type", + "name_id_policy", + "allow_idp_initiated", + "signing_kp", + "digest_algorithm", + "signature_algorithm", + "temporary_user_delete_after", + ] + widgets = { + "name": forms.TextInput(), + "issuer": forms.TextInput(), + "sso_url": forms.TextInput(), + "slo_url": forms.TextInput(), + "temporary_user_delete_after": forms.TextInput(), + } diff --git a/authentik/sources/saml/migrations/0001_initial.py b/authentik/sources/saml/migrations/0001_initial.py new file mode 100644 index 00000000..775d9bb5 --- /dev/null +++ b/authentik/sources/saml/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_crypto", "0001_initial"), + ("authentik_core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SAMLSource", + fields=[ + ( + "source_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.Source", + ), + ), + ( + "issuer", + models.TextField( + blank=True, + default=None, + help_text="Also known as Entity ID. Defaults the Metadata URL.", + verbose_name="Issuer", + ), + ), + ("idp_url", models.URLField(verbose_name="IDP URL")), + ( + "idp_logout_url", + models.URLField( + blank=True, + default=None, + null=True, + verbose_name="IDP Logout URL", + ), + ), + ("auto_logout", models.BooleanField(default=False)), + ( + "signing_kp", + models.ForeignKey( + default=None, + help_text="Certificate Key Pair of the IdP which Assertions are validated against.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_crypto.CertificateKeyPair", + ), + ), + ], + options={ + "verbose_name": "SAML Source", + "verbose_name_plural": "SAML Sources", + }, + bases=("authentik_core.source",), + ), + ] diff --git a/authentik/sources/saml/migrations/0002_auto_20200523_2329.py b/authentik/sources/saml/migrations/0002_auto_20200523_2329.py new file mode 100644 index 00000000..b1397f78 --- /dev/null +++ b/authentik/sources/saml/migrations/0002_auto_20200523_2329.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.6 on 2020-05-23 23:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_saml", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="samlsource", + name="binding_type", + field=models.CharField( + choices=[("REDIRECT", "Redirect"), ("POST", "Post")], + default="REDIRECT", + max_length=100, + ), + ), + migrations.AlterField( + model_name="samlsource", + name="idp_url", + field=models.URLField( + help_text="URL that the initial SAML Request is sent to. Also known as a Binding.", + verbose_name="IDP URL", + ), + ), + ] diff --git a/authentik/sources/saml/migrations/0003_auto_20200624_1957.py b/authentik/sources/saml/migrations/0003_auto_20200624_1957.py new file mode 100644 index 00000000..cf0db37a --- /dev/null +++ b/authentik/sources/saml/migrations/0003_auto_20200624_1957.py @@ -0,0 +1,70 @@ +# Generated by Django 3.0.7 on 2020-06-24 19:57 + +import django.db.models.deletion +from django.db import migrations, models + +import authentik.lib.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0002_create_self_signed_kp"), + ("authentik_sources_saml", "0002_auto_20200523_2329"), + ] + + operations = [ + migrations.RemoveField( + model_name="samlsource", + name="auto_logout", + ), + migrations.RenameField( + model_name="samlsource", + old_name="idp_url", + new_name="sso_url", + ), + migrations.RenameField( + model_name="samlsource", + old_name="idp_logout_url", + new_name="slo_url", + ), + migrations.AddField( + model_name="samlsource", + name="temporary_user_delete_after", + field=models.TextField( + default="days=1", + help_text="Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format 'transient', and the user doesn't log out manually. (Format: hours=1;minutes=2;seconds=3).", + validators=[authentik.lib.utils.time.timedelta_string_validator], + verbose_name="Delete temporary users after", + ), + ), + migrations.AlterField( + model_name="samlsource", + name="signing_kp", + field=models.ForeignKey( + help_text="Certificate Key Pair of the IdP which Assertion's Signature is validated against.", + on_delete=django.db.models.deletion.PROTECT, + to="authentik_crypto.CertificateKeyPair", + verbose_name="Singing Keypair", + ), + ), + migrations.AlterField( + model_name="samlsource", + name="slo_url", + field=models.URLField( + blank=True, + default=None, + help_text="Optional URL if your IDP supports Single-Logout.", + null=True, + verbose_name="SLO URL", + ), + ), + migrations.AlterField( + model_name="samlsource", + name="sso_url", + field=models.URLField( + help_text="URL that the initial Login request is sent to.", + verbose_name="SSO URL", + ), + ), + ] diff --git a/authentik/sources/saml/migrations/0004_auto_20200708_1207.py b/authentik/sources/saml/migrations/0004_auto_20200708_1207.py new file mode 100644 index 00000000..836510d8 --- /dev/null +++ b/authentik/sources/saml/migrations/0004_auto_20200708_1207.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.8 on 2020-07-08 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_saml", "0003_auto_20200624_1957"), + ] + + operations = [ + migrations.AlterField( + model_name="samlsource", + name="binding_type", + field=models.CharField( + choices=[ + ("REDIRECT", "Redirect Binding"), + ("POST", "POST Binding"), + ("POST_AUTO", "POST Binding with auto-confirmation"), + ], + default="REDIRECT", + max_length=100, + ), + ), + ] diff --git a/authentik/sources/saml/migrations/0005_samlsource_name_id_policy.py b/authentik/sources/saml/migrations/0005_samlsource_name_id_policy.py new file mode 100644 index 00000000..4269255e --- /dev/null +++ b/authentik/sources/saml/migrations/0005_samlsource_name_id_policy.py @@ -0,0 +1,40 @@ +# Generated by Django 3.0.8 on 2020-07-08 13:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_saml", "0004_auto_20200708_1207"), + ] + + operations = [ + migrations.AddField( + model_name="samlsource", + name="name_id_policy", + field=models.TextField( + choices=[ + ("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"), + ( + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + "Persistent", + ), + ( + "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName", + "X509", + ), + ( + "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName", + "Windows", + ), + ( + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "Transient", + ), + ], + default="urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + help_text="NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent.", + ), + ), + ] diff --git a/authentik/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py b/authentik/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py new file mode 100644 index 00000000..06aca587 --- /dev/null +++ b/authentik/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.1 on 2020-09-11 22:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_saml", "0005_samlsource_name_id_policy"), + ] + + operations = [ + migrations.AddField( + model_name="samlsource", + name="allow_idp_initiated", + field=models.BooleanField( + default=False, + help_text="Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done.", + ), + ), + ] diff --git a/authentik/sources/saml/migrations/0007_auto_20201112_1055.py b/authentik/sources/saml/migrations/0007_auto_20201112_1055.py new file mode 100644 index 00000000..d1b7d91a --- /dev/null +++ b/authentik/sources/saml/migrations/0007_auto_20201112_1055.py @@ -0,0 +1,51 @@ +# Generated by Django 3.1.3 on 2020-11-12 10:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0002_create_self_signed_kp"), + ("authentik_sources_saml", "0006_samlsource_allow_idp_initiated"), + ] + + operations = [ + migrations.AddField( + model_name="samlsource", + name="digest_algorithm", + field=models.CharField( + choices=[("sha1", "SHA1"), ("sha256", "SHA256")], + default="sha256", + max_length=50, + ), + ), + migrations.AddField( + model_name="samlsource", + name="signature_algorithm", + field=models.CharField( + choices=[ + ("rsa-sha1", "RSA-SHA1"), + ("rsa-sha256", "RSA-SHA256"), + ("ecdsa-sha256", "ECDSA-SHA256"), + ("dsa-sha1", "DSA-SHA1"), + ], + default="rsa-sha256", + max_length=50, + ), + ), + migrations.AlterField( + model_name="samlsource", + name="signing_kp", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Keypair which is used to sign outgoing requests. Leave empty to disable signing.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_crypto.certificatekeypair", + verbose_name="Singing Keypair", + ), + ), + ] diff --git a/authentik/sources/saml/migrations/0008_auto_20201112_2016.py b/authentik/sources/saml/migrations/0008_auto_20201112_2016.py new file mode 100644 index 00000000..dc823e6a --- /dev/null +++ b/authentik/sources/saml/migrations/0008_auto_20201112_2016.py @@ -0,0 +1,70 @@ +# Generated by Django 3.1.3 on 2020-11-12 20:16 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.sources.saml.processors import constants + + +def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + SAMLSource = apps.get_model("authentik_sources_saml", "SAMLSource") + signature_translation_map = { + "rsa-sha1": constants.RSA_SHA1, + "rsa-sha256": constants.RSA_SHA256, + "ecdsa-sha256": constants.RSA_SHA256, + "dsa-sha1": constants.DSA_SHA1, + } + digest_translation_map = { + "sha1": constants.SHA1, + "sha256": constants.SHA256, + } + + for source in SAMLSource.objects.all(): + source.signature_algorithm = signature_translation_map.get( + source.signature_algorithm, constants.RSA_SHA256 + ) + source.digest_algorithm = digest_translation_map.get( + source.digest_algorithm, constants.SHA256 + ) + source.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_saml", "0007_auto_20201112_1055"), + ] + + operations = [ + migrations.AlterField( + model_name="samlsource", + name="signature_algorithm", + field=models.CharField( + choices=[ + (constants.RSA_SHA1, "RSA-SHA1"), + (constants.RSA_SHA256, "RSA-SHA256"), + (constants.RSA_SHA384, "RSA-SHA384"), + (constants.RSA_SHA512, "RSA-SHA512"), + (constants.DSA_SHA1, "DSA-SHA1"), + ], + default=constants.RSA_SHA256, + max_length=50, + ), + ), + migrations.AlterField( + model_name="samlsource", + name="digest_algorithm", + field=models.CharField( + choices=[ + (constants.SHA1, "SHA1"), + (constants.SHA256, "SHA256"), + (constants.SHA384, "SHA384"), + (constants.SHA512, "SHA512"), + ], + default=constants.SHA256, + max_length=50, + ), + ), + migrations.RunPython(update_algorithms), + ] diff --git a/passbook/sources/saml/migrations/__init__.py b/authentik/sources/saml/migrations/__init__.py similarity index 100% rename from passbook/sources/saml/migrations/__init__.py rename to authentik/sources/saml/migrations/__init__.py diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py new file mode 100644 index 00000000..a68c8cb9 --- /dev/null +++ b/authentik/sources/saml/models.py @@ -0,0 +1,181 @@ +"""saml sp models""" +from typing import Type + +from django.db import models +from django.forms import ModelForm +from django.http import HttpRequest +from django.shortcuts import reverse +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from authentik.core.models import Source +from authentik.core.types import UILoginButton +from authentik.crypto.models import CertificateKeyPair +from authentik.lib.utils.time import timedelta_string_validator +from authentik.sources.saml.processors.constants import ( + DSA_SHA1, + RSA_SHA1, + RSA_SHA256, + RSA_SHA384, + RSA_SHA512, + SAML_NAME_ID_FORMAT_EMAIL, + SAML_NAME_ID_FORMAT_PERSISTENT, + SAML_NAME_ID_FORMAT_TRANSIENT, + SAML_NAME_ID_FORMAT_WINDOWS, + SAML_NAME_ID_FORMAT_X509, + SHA1, + SHA256, + SHA384, + SHA512, +) + + +class SAMLBindingTypes(models.TextChoices): + """SAML Binding types""" + + Redirect = "REDIRECT", _("Redirect Binding") + POST = "POST", _("POST Binding") + POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation") + + +class SAMLNameIDPolicy(models.TextChoices): + """SAML NameID Policies""" + + EMAIL = SAML_NAME_ID_FORMAT_EMAIL + PERSISTENT = SAML_NAME_ID_FORMAT_PERSISTENT + X509 = SAML_NAME_ID_FORMAT_X509 + WINDOWS = SAML_NAME_ID_FORMAT_WINDOWS + TRANSIENT = SAML_NAME_ID_FORMAT_TRANSIENT + + +class SAMLSource(Source): + """Authenticate using an external SAML Identity Provider.""" + + issuer = models.TextField( + blank=True, + default=None, + verbose_name=_("Issuer"), + help_text=_("Also known as Entity ID. Defaults the Metadata URL."), + ) + + sso_url = models.URLField( + verbose_name=_("SSO URL"), + help_text=_("URL that the initial Login request is sent to."), + ) + slo_url = models.URLField( + default=None, + blank=True, + null=True, + verbose_name=_("SLO URL"), + help_text=_("Optional URL if your IDP supports Single-Logout."), + ) + + allow_idp_initiated = models.BooleanField( + default=False, + help_text=_( + "Allows authentication flows initiated by the IdP. This can be a security risk, " + "as no validation of the request ID is done." + ), + ) + name_id_policy = models.TextField( + choices=SAMLNameIDPolicy.choices, + default=SAMLNameIDPolicy.TRANSIENT, + help_text=_( + "NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent." + ), + ) + binding_type = models.CharField( + max_length=100, + choices=SAMLBindingTypes.choices, + default=SAMLBindingTypes.Redirect, + ) + + temporary_user_delete_after = models.TextField( + default="days=1", + verbose_name=_("Delete temporary users after"), + validators=[timedelta_string_validator], + help_text=_( + ( + "Time offset when temporary users should be deleted. This only applies if your IDP " + "uses the NameID Format 'transient', and the user doesn't log out manually. " + "(Format: hours=1;minutes=2;seconds=3)." + ) + ), + ) + + signing_kp = models.ForeignKey( + CertificateKeyPair, + default=None, + blank=True, + null=True, + verbose_name=_("Singing Keypair"), + help_text=_( + "Keypair which is used to sign outgoing requests. Leave empty to disable signing." + ), + on_delete=models.SET_DEFAULT, + ) + + digest_algorithm = models.CharField( + max_length=50, + choices=( + (SHA1, _("SHA1")), + (SHA256, _("SHA256")), + (SHA384, _("SHA384")), + (SHA512, _("SHA512")), + ), + default=SHA256, + ) + signature_algorithm = models.CharField( + max_length=50, + choices=( + (RSA_SHA1, _("RSA-SHA1")), + (RSA_SHA256, _("RSA-SHA256")), + (RSA_SHA384, _("RSA-SHA384")), + (RSA_SHA512, _("RSA-SHA512")), + (DSA_SHA1, _("DSA-SHA1")), + ), + default=RSA_SHA256, + ) + + @property + def form(self) -> Type[ModelForm]: + from authentik.sources.saml.forms import SAMLSourceForm + + return SAMLSourceForm + + def get_issuer(self, request: HttpRequest) -> str: + """Get Source's Issuer, falling back to our Metadata URL if none is set""" + if self.issuer is None: + return self.build_full_url(request, view="metadata") + return self.issuer + + def build_full_url(self, request: HttpRequest, view: str = "acs") -> str: + """Build Full ACS URL to be used in IDP""" + return request.build_absolute_uri( + reverse(f"authentik_sources_saml:{view}", kwargs={"source_slug": self.slug}) + ) + + @property + def ui_login_button(self) -> UILoginButton: + return UILoginButton( + name=self.name, + url=reverse_lazy( + "authentik_sources_saml:login", kwargs={"source_slug": self.slug} + ), + icon_path="", + ) + + @property + def ui_additional_info(self) -> str: + metadata_url = reverse_lazy( + "authentik_sources_saml:metadata", kwargs={"source_slug": self.slug} + ) + return f'Metadata Download' + + def __str__(self): + return f"SAML Source {self.name}" + + class Meta: + + verbose_name = _("SAML Source") + verbose_name_plural = _("SAML Sources") diff --git a/passbook/sources/saml/processors/__init__.py b/authentik/sources/saml/processors/__init__.py similarity index 100% rename from passbook/sources/saml/processors/__init__.py rename to authentik/sources/saml/processors/__init__.py diff --git a/passbook/sources/saml/processors/constants.py b/authentik/sources/saml/processors/constants.py similarity index 100% rename from passbook/sources/saml/processors/constants.py rename to authentik/sources/saml/processors/constants.py diff --git a/authentik/sources/saml/processors/metadata.py b/authentik/sources/saml/processors/metadata.py new file mode 100644 index 00000000..b379db67 --- /dev/null +++ b/authentik/sources/saml/processors/metadata.py @@ -0,0 +1,93 @@ +"""SAML Service Provider Metadata Processor""" +from typing import Iterator, Optional + +from django.http import HttpRequest +from lxml.etree import Element, SubElement, tostring # nosec + +from authentik.providers.saml.utils.encoding import strip_pem_header +from authentik.sources.saml.models import SAMLSource +from authentik.sources.saml.processors.constants import ( + NS_MAP, + NS_SAML_METADATA, + NS_SIGNATURE, + SAML_BINDING_POST, + SAML_NAME_ID_FORMAT_EMAIL, + SAML_NAME_ID_FORMAT_PERSISTENT, + SAML_NAME_ID_FORMAT_TRANSIENT, + SAML_NAME_ID_FORMAT_WINDOWS, + SAML_NAME_ID_FORMAT_X509, +) + + +class MetadataProcessor: + """SAML Service Provider Metadata Processor""" + + source: SAMLSource + http_request: HttpRequest + + def __init__(self, source: SAMLSource, request: HttpRequest): + self.source = source + self.http_request = request + + def get_signing_key_descriptor(self) -> Optional[Element]: + """Get Singing KeyDescriptor, if enabled for the source""" + if self.source.signing_kp: + key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor") + key_descriptor.attrib["use"] = "signing" + key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo") + x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data") + x509_certificate = SubElement( + x509_data, f"{{{NS_SIGNATURE}}}X509Certificate" + ) + x509_certificate.text = strip_pem_header( + self.source.signing_kp.certificate_data.replace("\r", "") + ).replace("\n", "") + return key_descriptor + return None + + def get_name_id_formats(self) -> Iterator[Element]: + """Get compatible NameID Formats""" + formats = [ + SAML_NAME_ID_FORMAT_EMAIL, + SAML_NAME_ID_FORMAT_PERSISTENT, + SAML_NAME_ID_FORMAT_X509, + SAML_NAME_ID_FORMAT_WINDOWS, + SAML_NAME_ID_FORMAT_TRANSIENT, + ] + for name_id_format in formats: + element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat") + element.text = name_id_format + yield element + + def build_entity_descriptor(self) -> str: + """Build full EntityDescriptor""" + entity_descriptor = Element( + f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP + ) + entity_descriptor.attrib["entityID"] = self.source.get_issuer(self.http_request) + + sp_sso_descriptor = SubElement( + entity_descriptor, f"{{{NS_SAML_METADATA}}}SPSSODescriptor" + ) + sp_sso_descriptor.attrib[ + "protocolSupportEnumeration" + ] = "urn:oasis:names:tc:SAML:2.0:protocol" + + signing_descriptor = self.get_signing_key_descriptor() + if signing_descriptor is not None: + sp_sso_descriptor.append(signing_descriptor) + + for name_id_format in self.get_name_id_formats(): + sp_sso_descriptor.append(name_id_format) + + assertion_consumer_service = SubElement( + sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}AssertionConsumerService" + ) + assertion_consumer_service.attrib["isDefault"] = "true" + assertion_consumer_service.attrib["index"] = "0" + assertion_consumer_service.attrib["Binding"] = SAML_BINDING_POST + assertion_consumer_service.attrib["Location"] = self.source.build_full_url( + self.http_request + ) + + return tostring(entity_descriptor).decode() diff --git a/authentik/sources/saml/processors/request.py b/authentik/sources/saml/processors/request.py new file mode 100644 index 00000000..7c9cbd7f --- /dev/null +++ b/authentik/sources/saml/processors/request.py @@ -0,0 +1,172 @@ +"""SAML AuthnRequest Processor""" +from base64 import b64encode +from typing import Dict +from urllib.parse import quote_plus + +import xmlsec +from django.http import HttpRequest +from lxml import etree # nosec +from lxml.etree import Element # nosec + +from authentik.providers.saml.utils import get_random_id +from authentik.providers.saml.utils.encoding import deflate_and_base64_encode +from authentik.providers.saml.utils.time import get_time_string +from authentik.sources.saml.models import SAMLSource +from authentik.sources.saml.processors.constants import ( + DIGEST_ALGORITHM_TRANSLATION_MAP, + NS_MAP, + NS_SAML_ASSERTION, + NS_SAML_PROTOCOL, + SIGN_ALGORITHM_TRANSFORM_MAP, +) + +SESSION_REQUEST_ID = "authentik_source_saml_request_id" + + +class RequestProcessor: + """SAML AuthnRequest Processor""" + + source: SAMLSource + http_request: HttpRequest + + relay_state: str + + request_id: str + issue_instant: str + + def __init__(self, source: SAMLSource, request: HttpRequest, relay_state: str): + self.source = source + self.http_request = request + self.relay_state = relay_state + self.request_id = get_random_id() + self.http_request.session[SESSION_REQUEST_ID] = self.request_id + self.issue_instant = get_time_string() + + def get_issuer(self) -> Element: + """Get Issuer Element""" + issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer") + issuer.text = self.source.get_issuer(self.http_request) + return issuer + + def get_name_id_policy(self) -> Element: + """Get NameID Policy Element""" + name_id_policy = Element(f"{{{NS_SAML_PROTOCOL}}}NameIDPolicy") + name_id_policy.attrib["Format"] = self.source.name_id_policy + return name_id_policy + + def get_auth_n(self) -> Element: + """Get full AuthnRequest""" + auth_n_request = Element(f"{{{NS_SAML_PROTOCOL}}}AuthnRequest", nsmap=NS_MAP) + auth_n_request.attrib[ + "AssertionConsumerServiceURL" + ] = self.source.build_full_url(self.http_request) + auth_n_request.attrib["Destination"] = self.source.sso_url + auth_n_request.attrib["ID"] = self.request_id + auth_n_request.attrib["IssueInstant"] = self.issue_instant + auth_n_request.attrib["ProtocolBinding"] = self.source.binding_type + auth_n_request.attrib["Version"] = "2.0" + # Create issuer object + auth_n_request.append(self.get_issuer()) + + if self.source.signing_kp: + sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get( + self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1 + ) + signature = xmlsec.template.create( + auth_n_request, + xmlsec.constants.TransformExclC14N, + sign_algorithm_transform, + ns="ds", # type: ignore + ) + auth_n_request.append(signature) + + # Create NameID Policy Object + auth_n_request.append(self.get_name_id_policy()) + return auth_n_request + + def build_auth_n(self) -> str: + """Get Signed string representation of AuthN Request + (used for POST Bindings)""" + auth_n_request = self.get_auth_n() + + if self.source.signing_kp: + xmlsec.tree.add_ids(auth_n_request, ["ID"]) + + ctx = xmlsec.SignatureContext() + + key = xmlsec.Key.from_memory( + self.source.signing_kp.key_data, xmlsec.constants.KeyDataFormatPem, None + ) + key.load_cert_from_memory( + self.source.signing_kp.certificate_data, + xmlsec.constants.KeyDataFormatCertPem, + ) + ctx.key = key + + digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get( + self.source.digest_algorithm, xmlsec.constants.TransformSha1 + ) + + signature_node = xmlsec.tree.find_node( + auth_n_request, xmlsec.constants.NodeSignature + ) + + ref = xmlsec.template.add_reference( + signature_node, + digest_algorithm_transform, + uri="#" + auth_n_request.attrib["ID"], + ) + xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped) + xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N) + key_info = xmlsec.template.ensure_key_info(signature_node) + xmlsec.template.add_x509_data(key_info) + + ctx.sign(signature_node) + + return etree.tostring(auth_n_request).decode() + + def build_auth_n_detached(self) -> Dict[str, str]: + """Get Dict AuthN Request for Redirect bindings, with detached + Signature. See https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf""" + auth_n_request = self.get_auth_n() + + saml_request = deflate_and_base64_encode( + etree.tostring(auth_n_request).decode() + ) + + response_dict = { + "SAMLRequest": saml_request, + } + + if self.relay_state != "": + response_dict["RelayState"] = self.relay_state + + if self.source.signing_kp: + sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get( + self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1 + ) + + # Create the full querystring in the correct order to be signed + querystring = f"SAMLRequest={quote_plus(saml_request)}&" + if "RelayState" in response_dict: + querystring += f"RelayState={quote_plus(response_dict['RelayState'])}&" + querystring += f"SigAlg={quote_plus(self.source.signature_algorithm)}" + + ctx = xmlsec.SignatureContext() + + key = xmlsec.Key.from_memory( + self.source.signing_kp.key_data, xmlsec.constants.KeyDataFormatPem, None + ) + key.load_cert_from_memory( + self.source.signing_kp.certificate_data, + xmlsec.constants.KeyDataFormatPem, + ) + ctx.key = key + + signature = ctx.sign_binary( + querystring.encode("utf-8"), sign_algorithm_transform + ) + response_dict["Signature"] = b64encode(signature).decode() + response_dict["SigAlg"] = self.source.signature_algorithm + + return response_dict diff --git a/authentik/sources/saml/processors/response.py b/authentik/sources/saml/processors/response.py new file mode 100644 index 00000000..5aecf7df --- /dev/null +++ b/authentik/sources/saml/processors/response.py @@ -0,0 +1,215 @@ +"""authentik saml source processor""" +from base64 import b64decode +from typing import TYPE_CHECKING, Any, Dict + +import xmlsec +from defusedxml.lxml import fromstring +from django.core.cache import cache +from django.core.exceptions import SuspiciousOperation +from django.http import HttpRequest, HttpResponse +from structlog import get_logger + +from authentik.core.models import User +from authentik.flows.models import Flow +from authentik.flows.planner import ( + PLAN_CONTEXT_PENDING_USER, + PLAN_CONTEXT_SSO, + FlowPlanner, +) +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.lib.utils.urls import redirect_with_qs +from authentik.policies.utils import delete_none_keys +from authentik.sources.saml.exceptions import ( + InvalidSignature, + MismatchedRequestID, + MissingSAMLResponse, + UnsupportedNameIDFormat, +) +from authentik.sources.saml.models import SAMLSource +from authentik.sources.saml.processors.constants import ( + NS_MAP, + SAML_NAME_ID_FORMAT_EMAIL, + SAML_NAME_ID_FORMAT_PERSISTENT, + SAML_NAME_ID_FORMAT_TRANSIENT, + SAML_NAME_ID_FORMAT_WINDOWS, + SAML_NAME_ID_FORMAT_X509, +) +from authentik.sources.saml.processors.request import SESSION_REQUEST_ID +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +LOGGER = get_logger() +if TYPE_CHECKING: + from xml.etree.ElementTree import Element # nosec + +CACHE_SEEN_REQUEST_ID = "authentik_saml_seen_ids_%s" +DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend" + + +class ResponseProcessor: + """SAML Response Processor""" + + _source: SAMLSource + + _root: Any + _root_xml: str + + def __init__(self, source: SAMLSource): + self._source = source + + def parse(self, request: HttpRequest): + """Check if `request` contains SAML Response data, parse and validate it.""" + # First off, check if we have any SAML Data at all. + raw_response = request.POST.get("SAMLResponse", None) + if not raw_response: + raise MissingSAMLResponse("Request does not contain 'SAMLResponse'") + # Check if response is compressed, b64 decode it + self._root_xml = b64decode(raw_response.encode()).decode() + self._root = fromstring(self._root_xml) + + if self._source.signing_kp: + self._verify_signed() + self._verify_request_id(request) + + def _verify_signed(self): + """Verify SAML Response's Signature""" + signature_nodes = self._root.xpath( + "/samlp:Response/saml:Assertion/ds:Signature", namespaces=NS_MAP + ) + if len(signature_nodes) != 1: + raise InvalidSignature() + signature_node = signature_nodes[0] + xmlsec.tree.add_ids(self._root, ["ID"]) + + ctx = xmlsec.SignatureContext() + key = xmlsec.Key.from_memory( + self._source.signing_kp.certificate_data, + xmlsec.constants.KeyDataFormatCertPem, + ) + ctx.key = key + + ctx.set_enabled_key_data([xmlsec.constants.KeyDataX509]) + try: + ctx.verify(signature_node) + except (xmlsec.InternalError, xmlsec.VerificationError) as exc: + raise InvalidSignature from exc + LOGGER.debug("Successfully verified signautre") + + def _verify_request_id(self, request: HttpRequest): + if self._source.allow_idp_initiated: + # If IdP-initiated SSO flows are enabled, we want to cache the Response ID + # somewhat mitigate replay attacks + seen_ids = cache.get(CACHE_SEEN_REQUEST_ID % self._source.pk, []) + if self._root.attrib["ID"] in seen_ids: + raise SuspiciousOperation("Replay attack detected") + seen_ids.append(self._root.attrib["ID"]) + cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids) + return + if ( + SESSION_REQUEST_ID not in request.session + or "InResponseTo" not in self._root.attrib + ): + raise MismatchedRequestID( + "Missing InResponseTo and IdP-initiated Logins are not allowed" + ) + if request.session[SESSION_REQUEST_ID] != self._root.attrib["InResponseTo"]: + raise MismatchedRequestID("Mismatched request ID") + + def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse: + """Handle a NameID with the Format of Transient. This is a bit more complex than other + formats, as we need to create a temporary User that is used in the session. This + user has an attribute that refers to our Source for cleanup. The user is also deleted + on logout and periodically.""" + # Create a temporary User + name_id = self._get_name_id().text + user: User = User.objects.create( + username=name_id, + attributes={ + "saml": {"source": self._source.pk.hex, "delete_on_logout": True} + }, + ) + LOGGER.debug("Created temporary user for NameID Transient", username=name_id) + user.set_unusable_password() + user.save() + return self._flow_response( + request, + self._source.authentication_flow, + **{ + PLAN_CONTEXT_PENDING_USER: user, + PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND, + }, + ) + + def _get_name_id(self) -> "Element": + """Get NameID Element""" + assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion") + subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject") + name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID") + if name_id is None: + raise ValueError("NameID Element not found!") + return name_id + + def _get_name_id_filter(self) -> Dict[str, str]: + """Returns the subject's NameID as a Filter for the `User`""" + name_id_el = self._get_name_id() + name_id = name_id_el.text + if not name_id: + raise UnsupportedNameIDFormat("Subject's NameID is empty.") + _format = name_id_el.attrib["Format"] + if _format == SAML_NAME_ID_FORMAT_EMAIL: + return {"email": name_id} + if _format == SAML_NAME_ID_FORMAT_PERSISTENT: + return {"username": name_id} + if _format == SAML_NAME_ID_FORMAT_X509: + # This attribute is statically set by the LDAP source + return {"attributes__distinguishedName": name_id} + if _format == SAML_NAME_ID_FORMAT_WINDOWS: + if "\\" in name_id: + name_id = name_id.split("\\")[1] + return {"username": name_id} + raise UnsupportedNameIDFormat( + f"Assertion contains NameID with unsupported format {_format}." + ) + + def prepare_flow(self, request: HttpRequest) -> HttpResponse: + """Prepare flow plan depending on whether or not the user exists""" + name_id = self._get_name_id() + # Sanity check, show a warning if NameIDPolicy doesn't match what we go + if self._source.name_id_policy != name_id.attrib["Format"]: + LOGGER.warning( + "NameID from IdP doesn't match our policy", + expected=self._source.name_id_policy, + got=name_id.attrib["Format"], + ) + # transient NameIDs are handeled seperately as they don't have to go through flows. + if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: + return self._handle_name_id_transient(request) + + name_id_filter = self._get_name_id_filter() + matching_users = User.objects.filter(**name_id_filter) + if matching_users.exists(): + # User exists already, switch to authentication flow + return self._flow_response( + request, + self._source.authentication_flow, + **{ + PLAN_CONTEXT_PENDING_USER: matching_users.first(), + PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND, + }, + ) + return self._flow_response( + request, + self._source.enrollment_flow, + **{PLAN_CONTEXT_PROMPT: delete_none_keys(name_id_filter)}, + ) + + def _flow_response( + self, request: HttpRequest, flow: Flow, **kwargs + ) -> HttpResponse: + kwargs[PLAN_CONTEXT_SSO] = True + request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs) + return redirect_with_qs( + "authentik_flows:flow-executor-shell", + request.GET, + flow_slug=flow.slug, + ) diff --git a/authentik/sources/saml/settings.py b/authentik/sources/saml/settings.py new file mode 100644 index 00000000..3e3dc45c --- /dev/null +++ b/authentik/sources/saml/settings.py @@ -0,0 +1,10 @@ +"""saml source settings""" +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + "saml_source_cleanup": { + "task": "authentik.sources.saml.tasks.clean_temporary_users", + "schedule": crontab(minute="*/5"), + "options": {"queue": "authentik_scheduled"}, + } +} diff --git a/authentik/sources/saml/signals.py b/authentik/sources/saml/signals.py new file mode 100644 index 00000000..b4629f89 --- /dev/null +++ b/authentik/sources/saml/signals.py @@ -0,0 +1,22 @@ +"""authentik saml source signal listener""" +from django.contrib.auth.signals import user_logged_out +from django.dispatch import receiver +from django.http import HttpRequest +from structlog import get_logger + +from authentik.core.models import User + +LOGGER = get_logger() + + +@receiver(user_logged_out) +# pylint: disable=unused-argument +def on_user_logged_out(sender, request: HttpRequest, user: User, **_): + """Delete temporary user if the `delete_on_logout` flag is enabled""" + if not user: + return + if "saml" in user.attributes: + if "delete_on_logout" in user.attributes["saml"]: + if user.attributes["saml"]["delete_on_logout"]: + LOGGER.debug("Deleted temporary user", user=user) + user.delete() diff --git a/authentik/sources/saml/tasks.py b/authentik/sources/saml/tasks.py new file mode 100644 index 00000000..a2ed2a0a --- /dev/null +++ b/authentik/sources/saml/tasks.py @@ -0,0 +1,42 @@ +"""authentik saml source tasks""" +from django.utils.timezone import now +from structlog import get_logger + +from authentik.core.models import User +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.lib.utils.time import timedelta_from_string +from authentik.root.celery import CELERY_APP +from authentik.sources.saml.models import SAMLSource + +LOGGER = get_logger() + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def clean_temporary_users(self: MonitoredTask): + """Remove temporary users created by SAML Sources""" + _now = now() + messages = [] + deleted_users = 0 + for user in User.objects.filter(attributes__saml__isnull=False): + sources = SAMLSource.objects.filter( + pk=user.attributes.get("saml", {}).get("source", "") + ) + if not sources.exists(): + LOGGER.warning( + "User has an invalid SAML Source and won't be deleted!", user=user + ) + messages.append( + f"User {user} has an invalid SAML Source and won't be deleted!" + ) + continue + source = sources.first() + source_delta = timedelta_from_string(source.temporary_user_delete_after) + if _now - user.last_login >= source_delta: + LOGGER.debug( + "User is expired and will be deleted.", user=user, delta=source_delta + ) + # TODO: Check if user is signed in anywhere? + user.delete() + deleted_users += 1 + messages.append(f"Successfully deleted {deleted_users} users.") + self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) diff --git a/authentik/sources/saml/templates/saml/sp/login.html b/authentik/sources/saml/templates/saml/sp/login.html new file mode 100644 index 00000000..5cf5c050 --- /dev/null +++ b/authentik/sources/saml/templates/saml/sp/login.html @@ -0,0 +1,26 @@ +{% extends "login/base_full.html" %} + +{% load authentik_utils %} +{% load i18n %} + +{% block title %} +{% trans 'Authorize Application' %} +{% endblock %} + +{% block card %} +
+ {% csrf_token %} + + + +
+ +
+
+{% endblock %} diff --git a/authentik/sources/saml/tests.py b/authentik/sources/saml/tests.py new file mode 100644 index 00000000..f01dc5fd --- /dev/null +++ b/authentik/sources/saml/tests.py @@ -0,0 +1,26 @@ +"""SAML Source tests""" +from defusedxml import ElementTree +from django.test import RequestFactory, TestCase + +from authentik.crypto.models import CertificateKeyPair +from authentik.sources.saml.models import SAMLSource +from authentik.sources.saml.processors.metadata import MetadataProcessor + + +class TestMetadataProcessor(TestCase): + """Test MetadataProcessor""" + + def setUp(self): + self.source = SAMLSource.objects.create( + slug="provider", + issuer="authentik", + signing_kp=CertificateKeyPair.objects.first(), + ) + self.factory = RequestFactory() + + def test_metadata(self): + """Test Metadata generation being valid""" + request = self.factory.get("/") + xml = MetadataProcessor(self.source, request).build_entity_descriptor() + metadata = ElementTree.fromstring(xml) + self.assertEqual(metadata.attrib["entityID"], "authentik") diff --git a/authentik/sources/saml/urls.py b/authentik/sources/saml/urls.py new file mode 100644 index 00000000..889f3f57 --- /dev/null +++ b/authentik/sources/saml/urls.py @@ -0,0 +1,11 @@ +"""saml sp urls""" +from django.urls import path + +from authentik.sources.saml.views import ACSView, InitiateView, MetadataView, SLOView + +urlpatterns = [ + path("/", InitiateView.as_view(), name="login"), + path("/acs/", ACSView.as_view(), name="acs"), + path("/slo/", SLOView.as_view(), name="slo"), + path("/metadata/", MetadataView.as_view(), name="metadata"), +] diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py new file mode 100644 index 00000000..c5618e7e --- /dev/null +++ b/authentik/sources/saml/views.py @@ -0,0 +1,108 @@ +"""saml sp views""" +from django.contrib.auth import logout +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.decorators import method_decorator +from django.utils.http import urlencode +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from xmlsec import VerificationError + +from authentik.lib.views import bad_request_message +from authentik.providers.saml.utils.encoding import nice64 +from authentik.sources.saml.exceptions import ( + MissingSAMLResponse, + UnsupportedNameIDFormat, +) +from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource +from authentik.sources.saml.processors.metadata import MetadataProcessor +from authentik.sources.saml.processors.request import RequestProcessor +from authentik.sources.saml.processors.response import ResponseProcessor + + +class InitiateView(View): + """Get the Form with SAML Request, which sends us to the IDP""" + + def get(self, request: HttpRequest, source_slug: str) -> HttpResponse: + """Replies with an XHTML SSO Request.""" + source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) + if not source.enabled: + raise Http404 + relay_state = request.GET.get("next", "") + auth_n_req = RequestProcessor(source, request, relay_state) + # If the source is configured for Redirect bindings, we can just redirect there + if source.binding_type == SAMLBindingTypes.Redirect: + url_args = urlencode(auth_n_req.build_auth_n_detached()) + return redirect(f"{source.sso_url}?{url_args}") + # As POST Binding we show a form + saml_request = nice64(auth_n_req.build_auth_n()) + if source.binding_type == SAMLBindingTypes.POST: + return render( + request, + "saml/sp/login.html", + { + "request_url": source.sso_url, + "request": saml_request, + "relay_state": relay_state, + "source": source, + }, + ) + # Or an auto-submit form + if source.binding_type == SAMLBindingTypes.POST_AUTO: + return render( + request, + "generic/autosubmit_form_full.html", + { + "title": _("Redirecting to %(app)s..." % {"app": source.name}), + "attrs": {"SAMLRequest": saml_request, "RelayState": relay_state}, + "url": source.sso_url, + }, + ) + raise Http404 + + +@method_decorator(csrf_exempt, name="dispatch") +class ACSView(View): + """AssertionConsumerService, consume assertion and log user in""" + + def post(self, request: HttpRequest, source_slug: str) -> HttpResponse: + """Handles a POSTed SSO Assertion and logs the user in.""" + source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) + if not source.enabled: + raise Http404 + processor = ResponseProcessor(source) + try: + processor.parse(request) + except MissingSAMLResponse as exc: + return bad_request_message(request, str(exc)) + except VerificationError as exc: + return bad_request_message(request, str(exc)) + + try: + return processor.prepare_flow(request) + except UnsupportedNameIDFormat as exc: + return bad_request_message(request, str(exc)) + + +class SLOView(LoginRequiredMixin, View): + """Single-Logout-View""" + + def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse: + """Log user out and redirect them to the IdP's SLO URL.""" + source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) + if not source.enabled: + raise Http404 + logout(request) + return redirect(source.slo_url) + + +class MetadataView(View): + """Return XML Metadata for IDP""" + + def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse: + """Replies with the XML Metadata SPSSODescriptor.""" + source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) + metadata = MetadataProcessor(source, request).build_entity_descriptor() + return HttpResponse(metadata, content_type="text/xml") diff --git a/passbook/stages/__init__.py b/authentik/stages/__init__.py similarity index 100% rename from passbook/stages/__init__.py rename to authentik/stages/__init__.py diff --git a/passbook/stages/captcha/__init__.py b/authentik/stages/captcha/__init__.py similarity index 100% rename from passbook/stages/captcha/__init__.py rename to authentik/stages/captcha/__init__.py diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py new file mode 100644 index 00000000..f3389a78 --- /dev/null +++ b/authentik/stages/captcha/api.py @@ -0,0 +1,21 @@ +"""CaptchaStage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.captcha.models import CaptchaStage + + +class CaptchaStageSerializer(ModelSerializer): + """CaptchaStage Serializer""" + + class Meta: + + model = CaptchaStage + fields = ["pk", "name", "public_key", "private_key"] + + +class CaptchaStageViewSet(ModelViewSet): + """CaptchaStage Viewset""" + + queryset = CaptchaStage.objects.all() + serializer_class = CaptchaStageSerializer diff --git a/authentik/stages/captcha/apps.py b/authentik/stages/captcha/apps.py new file mode 100644 index 00000000..69df384a --- /dev/null +++ b/authentik/stages/captcha/apps.py @@ -0,0 +1,10 @@ +"""authentik captcha app""" +from django.apps import AppConfig + + +class AuthentikStageCaptchaConfig(AppConfig): + """authentik captcha app""" + + name = "authentik.stages.captcha" + label = "authentik_stages_captcha" + verbose_name = "authentik Stages.Captcha" diff --git a/authentik/stages/captcha/forms.py b/authentik/stages/captcha/forms.py new file mode 100644 index 00000000..d2ba260a --- /dev/null +++ b/authentik/stages/captcha/forms.py @@ -0,0 +1,25 @@ +"""authentik captcha stage forms""" +from captcha.fields import ReCaptchaField +from django import forms + +from authentik.stages.captcha.models import CaptchaStage + + +class CaptchaForm(forms.Form): + """authentik captcha stage form""" + + captcha = ReCaptchaField() + + +class CaptchaStageForm(forms.ModelForm): + """Form to edit CaptchaStage Instance""" + + class Meta: + + model = CaptchaStage + fields = ["name", "public_key", "private_key"] + widgets = { + "name": forms.TextInput(), + "public_key": forms.TextInput(), + "private_key": forms.TextInput(), + } diff --git a/authentik/stages/captcha/migrations/0001_initial.py b/authentik/stages/captcha/migrations/0001_initial.py new file mode 100644 index 00000000..b1e1c3e3 --- /dev/null +++ b/authentik/stages/captcha/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="CaptchaStage", + 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", + ), + ), + ( + "public_key", + models.TextField( + help_text="Public key, acquired from https://www.google.com/recaptcha/intro/v3.html" + ), + ), + ( + "private_key", + models.TextField( + help_text="Private key, acquired from https://www.google.com/recaptcha/intro/v3.html" + ), + ), + ], + options={ + "verbose_name": "Captcha Stage", + "verbose_name_plural": "Captcha Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/passbook/stages/captcha/migrations/__init__.py b/authentik/stages/captcha/migrations/__init__.py similarity index 100% rename from passbook/stages/captcha/migrations/__init__.py rename to authentik/stages/captcha/migrations/__init__.py diff --git a/authentik/stages/captcha/models.py b/authentik/stages/captcha/models.py new file mode 100644 index 00000000..c6eeac1f --- /dev/null +++ b/authentik/stages/captcha/models.py @@ -0,0 +1,51 @@ +"""authentik captcha 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 Stage + + +class CaptchaStage(Stage): + """Verify the user is human using Google's reCaptcha.""" + + public_key = models.TextField( + help_text=_( + "Public key, acquired from https://www.google.com/recaptcha/intro/v3.html" + ) + ) + private_key = models.TextField( + help_text=_( + "Private key, acquired from https://www.google.com/recaptcha/intro/v3.html" + ) + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.captcha.api import CaptchaStageSerializer + + return CaptchaStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.captcha.stage import CaptchaStageView + + return CaptchaStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.captcha.forms import CaptchaStageForm + + return CaptchaStageForm + + def __str__(self): + return f"Captcha Stage {self.name}" + + class Meta: + + verbose_name = _("Captcha Stage") + verbose_name_plural = _("Captcha Stages") diff --git a/authentik/stages/captcha/settings.py b/authentik/stages/captcha/settings.py new file mode 100644 index 00000000..6b63a51d --- /dev/null +++ b/authentik/stages/captcha/settings.py @@ -0,0 +1,9 @@ +"""authentik captcha stage settings""" +# https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do +RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" +RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" + +NOCAPTCHA = True +INSTALLED_APPS = ["captcha"] + +SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"] diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py new file mode 100644 index 00000000..5f8c968c --- /dev/null +++ b/authentik/stages/captcha/stage.py @@ -0,0 +1,24 @@ +"""authentik captcha stage""" + +from django.views.generic import FormView + +from authentik.flows.stage import StageView +from authentik.stages.captcha.forms import CaptchaForm + + +class CaptchaStageView(FormView, StageView): + """Simple captcha checker, logic is handeled in django-captcha module""" + + form_class = CaptchaForm + + def form_valid(self, form): + return self.executor.stage_ok() + + def get_form(self, form_class=None): + form = CaptchaForm(**self.get_form_kwargs()) + form.fields["captcha"].public_key = self.executor.current_stage.public_key + form.fields["captcha"].private_key = self.executor.current_stage.private_key + form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[ + "captcha" + ].public_key + return form diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py new file mode 100644 index 00000000..b3664fa3 --- /dev/null +++ b/authentik/stages/captcha/tests.py @@ -0,0 +1,55 @@ +"""captcha tests""" +from django.conf import settings +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.stages.captcha.models import CaptchaStage + + +class TestCaptchaStage(TestCase): + """Captcha tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + username="unittest", email="test@beryju.org" + ) + self.client = Client() + + self.flow = Flow.objects.create( + name="test-captcha", + slug="test-captcha", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = CaptchaStage.objects.create( + name="captcha", + public_key=settings.RECAPTCHA_PUBLIC_KEY, + private_key=settings.RECAPTCHA_PRIVATE_KEY, + ) + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_valid(self): + """Test valid captcha""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + {"g-recaptcha-response": "PASSED"}, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) diff --git a/passbook/stages/consent/__init__.py b/authentik/stages/consent/__init__.py similarity index 100% rename from passbook/stages/consent/__init__.py rename to authentik/stages/consent/__init__.py diff --git a/authentik/stages/consent/api.py b/authentik/stages/consent/api.py new file mode 100644 index 00000000..3c7f1415 --- /dev/null +++ b/authentik/stages/consent/api.py @@ -0,0 +1,21 @@ +"""ConsentStage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.consent.models import ConsentStage + + +class ConsentStageSerializer(ModelSerializer): + """ConsentStage Serializer""" + + class Meta: + + model = ConsentStage + fields = ["pk", "name", "mode", "consent_expire_in"] + + +class ConsentStageViewSet(ModelViewSet): + """ConsentStage Viewset""" + + queryset = ConsentStage.objects.all() + serializer_class = ConsentStageSerializer diff --git a/authentik/stages/consent/apps.py b/authentik/stages/consent/apps.py new file mode 100644 index 00000000..ba4b04e6 --- /dev/null +++ b/authentik/stages/consent/apps.py @@ -0,0 +1,10 @@ +"""authentik consent app""" +from django.apps import AppConfig + + +class AuthentikStageConsentConfig(AppConfig): + """authentik consent app""" + + name = "authentik.stages.consent" + label = "authentik_stages_consent" + verbose_name = "authentik Stages.Consent" diff --git a/authentik/stages/consent/forms.py b/authentik/stages/consent/forms.py new file mode 100644 index 00000000..21d6e457 --- /dev/null +++ b/authentik/stages/consent/forms.py @@ -0,0 +1,20 @@ +"""authentik consent stage forms""" +from django import forms + +from authentik.stages.consent.models import ConsentStage + + +class ConsentForm(forms.Form): + """authentik consent stage form""" + + +class ConsentStageForm(forms.ModelForm): + """Form to edit ConsentStage Instance""" + + class Meta: + + model = ConsentStage + fields = ["name", "mode", "consent_expire_in"] + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/consent/migrations/0001_initial.py b/authentik/stages/consent/migrations/0001_initial.py new file mode 100644 index 00000000..af038a03 --- /dev/null +++ b/authentik/stages/consent/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.6 on 2020-05-24 11:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0007_auto_20200703_2059"), + ] + + operations = [ + migrations.CreateModel( + name="ConsentStage", + 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", + ), + ), + ], + options={ + "verbose_name": "Consent Stage", + "verbose_name_plural": "Consent Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/consent/migrations/0002_auto_20200720_0941.py b/authentik/stages/consent/migrations/0002_auto_20200720_0941.py new file mode 100644 index 00000000..6521b4c0 --- /dev/null +++ b/authentik/stages/consent/migrations/0002_auto_20200720_0941.py @@ -0,0 +1,83 @@ +# Generated by Django 3.0.8 on 2020-07-20 09:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import authentik.core.models +import authentik.lib.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0006_auto_20200709_1608"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_stages_consent", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="consentstage", + name="consent_expire_in", + field=models.TextField( + default="weeks=4", + help_text="Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3).", + validators=[authentik.lib.utils.time.timedelta_string_validator], + verbose_name="Consent expires in", + ), + ), + migrations.AddField( + model_name="consentstage", + name="mode", + field=models.TextField( + choices=[ + ("always_require", "Always Require"), + ("permanent", "Permanent"), + ("expiring", "Expiring"), + ], + default="always_require", + ), + ), + migrations.CreateModel( + name="UserConsent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "expires", + models.DateTimeField( + default=authentik.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_core.Application", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ak_consent", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "User Consent", + "verbose_name_plural": "User Consents", + "unique_together": {("user", "application")}, + }, + ), + ] diff --git a/authentik/stages/consent/migrations/0003_auto_20200924_1403.py b/authentik/stages/consent/migrations/0003_auto_20200924_1403.py new file mode 100644 index 00000000..aef024dc --- /dev/null +++ b/authentik/stages/consent/migrations/0003_auto_20200924_1403.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.1 on 2020-09-24 14:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_stages_consent", "0002_auto_20200720_0941"), + ] + + operations = [ + migrations.AlterField( + model_name="userconsent", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/passbook/stages/consent/migrations/__init__.py b/authentik/stages/consent/migrations/__init__.py similarity index 100% rename from passbook/stages/consent/migrations/__init__.py rename to authentik/stages/consent/migrations/__init__.py diff --git a/authentik/stages/consent/models.py b/authentik/stages/consent/models.py new file mode 100644 index 00000000..5598da54 --- /dev/null +++ b/authentik/stages/consent/models.py @@ -0,0 +1,81 @@ +"""authentik consent 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.core.models import Application, ExpiringModel, User +from authentik.flows.models import Stage +from authentik.lib.utils.time import timedelta_string_validator + + +class ConsentMode(models.TextChoices): + """Modes a Consent Stage can operate in""" + + ALWAYS_REQUIRE = "always_require" + PERMANENT = "permanent" + EXPIRING = "expiring" + + +class ConsentStage(Stage): + """Prompt the user for confirmation.""" + + mode = models.TextField( + choices=ConsentMode.choices, default=ConsentMode.ALWAYS_REQUIRE + ) + consent_expire_in = models.TextField( + validators=[timedelta_string_validator], + default="weeks=4", + verbose_name="Consent expires in", + help_text=_( + ( + "Offset after which consent expires. " + "(Format: hours=1;minutes=2;seconds=3)." + ) + ), + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.consent.api import ConsentStageSerializer + + return ConsentStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.consent.stage import ConsentStageView + + return ConsentStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.consent.forms import ConsentStageForm + + return ConsentStageForm + + def __str__(self): + return f"Consent Stage {self.name}" + + class Meta: + + verbose_name = _("Consent Stage") + verbose_name_plural = _("Consent Stages") + + +class UserConsent(ExpiringModel): + """Consent given by a user for an application""" + + user = models.ForeignKey(User, on_delete=models.CASCADE) + application = models.ForeignKey(Application, on_delete=models.CASCADE) + + def __str__(self): + return f"User Consent {self.application} by {self.user}" + + class Meta: + + unique_together = (("user", "application"),) + verbose_name = _("User Consent") + verbose_name_plural = _("User Consents") diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py new file mode 100644 index 00000000..c4f2feea --- /dev/null +++ b/authentik/stages/consent/stage.py @@ -0,0 +1,73 @@ +"""authentik consent stage""" +from typing import Any, Dict, List + +from django.http import HttpRequest, HttpResponse +from django.utils.timezone import now +from django.views.generic import FormView + +from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.lib.utils.time import timedelta_from_string +from authentik.stages.consent.forms import ConsentForm +from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent + +PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template" + + +class ConsentStageView(FormView, StageView): + """Simple consent checker.""" + + form_class = ConsentForm + + def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + kwargs["current_stage"] = self.executor.current_stage + kwargs["context"] = self.executor.plan.context + return kwargs + + def get_template_names(self) -> List[str]: + # PLAN_CONTEXT_CONSENT_TEMPLATE has to be set by a template that calls this stage + if PLAN_CONTEXT_CONSENT_TEMPLATE in self.executor.plan.context: + template_name = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TEMPLATE] + return [template_name] + return ["stages/consent/fallback.html"] + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + current_stage: ConsentStage = self.executor.current_stage + # For always require, we always show the form + if current_stage.mode == ConsentMode.ALWAYS_REQUIRE: + return super().get(request, *args, **kwargs) + # at this point we need to check consent from database + if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: + # No application in this plan, hence we can't check DB and require user consent + return super().get(request, *args, **kwargs) + + application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + + user = self.request.user + if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: + user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + + if UserConsent.filter_not_expired(user=user, application=application).exists(): + return self.executor.stage_ok() + + # No consent found, show form + return super().get(request, *args, **kwargs) + + def form_valid(self, form: ConsentForm) -> HttpResponse: + current_stage: ConsentStage = self.executor.current_stage + if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: + return self.executor.stage_ok() + application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + # Since we only get here when no consent exists, we can create it without update + if current_stage.mode == ConsentMode.PERMANENT: + UserConsent.objects.create( + user=self.request.user, application=application, expiring=False + ) + if current_stage.mode == ConsentMode.EXPIRING: + UserConsent.objects.create( + user=self.request.user, + application=application, + expires=now() + timedelta_from_string(current_stage.consent_expire_in), + ) + return self.executor.stage_ok() diff --git a/passbook/stages/consent/templates/stages/consent/fallback.html b/authentik/stages/consent/templates/stages/consent/fallback.html similarity index 100% rename from passbook/stages/consent/templates/stages/consent/fallback.html rename to authentik/stages/consent/templates/stages/consent/fallback.html diff --git a/authentik/stages/consent/tests.py b/authentik/stages/consent/tests.py new file mode 100644 index 00000000..7ab6dbf6 --- /dev/null +++ b/authentik/stages/consent/tests.py @@ -0,0 +1,135 @@ +"""consent tests""" +from time import sleep + +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import Application, User +from authentik.core.tasks import clean_expired_models +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent + + +class TestConsentStage(TestCase): + """Consent tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + username="unittest", email="test@beryju.org" + ) + self.application = Application.objects.create( + name="test-application", + slug="test-application", + ) + self.client = Client() + + def test_always_required(self): + """Test always required consent""" + flow = Flow.objects.create( + name="test-consent", + slug="test-consent", + designation=FlowDesignation.AUTHENTICATION, + ) + stage = ConsentStage.objects.create( + name="consent", mode=ConsentMode.ALWAYS_REQUIRE + ) + FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + plan = FlowPlan(flow_pk=flow.pk.hex, stages=[stage], markers=[StageMarker()]) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + response = self.client.post( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) + + def test_permanent(self): + """Test permanent consent from user""" + self.client.force_login(self.user) + flow = Flow.objects.create( + name="test-consent", + slug="test-consent", + designation=FlowDesignation.AUTHENTICATION, + ) + stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) + FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + plan = FlowPlan( + flow_pk=flow.pk.hex, + stages=[stage], + markers=[StageMarker()], + context={PLAN_CONTEXT_APPLICATION: self.application}, + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + response = self.client.post( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + self.assertTrue( + UserConsent.objects.filter( + user=self.user, application=self.application + ).exists() + ) + + def test_expire(self): + """Test expiring consent from user""" + self.client.force_login(self.user) + flow = Flow.objects.create( + name="test-consent", + slug="test-consent", + designation=FlowDesignation.AUTHENTICATION, + ) + stage = ConsentStage.objects.create( + name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" + ) + FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + plan = FlowPlan( + flow_pk=flow.pk.hex, + stages=[stage], + markers=[StageMarker()], + context={PLAN_CONTEXT_APPLICATION: self.application}, + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + response = self.client.post( + reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + self.assertTrue( + UserConsent.objects.filter( + user=self.user, application=self.application + ).exists() + ) + sleep(1) + clean_expired_models.delay().get() + self.assertFalse( + UserConsent.objects.filter( + user=self.user, application=self.application + ).exists() + ) diff --git a/passbook/stages/dummy/__init__.py b/authentik/stages/dummy/__init__.py similarity index 100% rename from passbook/stages/dummy/__init__.py rename to authentik/stages/dummy/__init__.py diff --git a/authentik/stages/dummy/api.py b/authentik/stages/dummy/api.py new file mode 100644 index 00000000..fa875a9f --- /dev/null +++ b/authentik/stages/dummy/api.py @@ -0,0 +1,21 @@ +"""DummyStage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.dummy.models import DummyStage + + +class DummyStageSerializer(ModelSerializer): + """DummyStage Serializer""" + + class Meta: + + model = DummyStage + fields = ["pk", "name"] + + +class DummyStageViewSet(ModelViewSet): + """DummyStage Viewset""" + + queryset = DummyStage.objects.all() + serializer_class = DummyStageSerializer diff --git a/authentik/stages/dummy/apps.py b/authentik/stages/dummy/apps.py new file mode 100644 index 00000000..35f93e88 --- /dev/null +++ b/authentik/stages/dummy/apps.py @@ -0,0 +1,11 @@ +"""authentik dummy stage config""" + +from django.apps import AppConfig + + +class AuthentikStageDummyConfig(AppConfig): + """authentik dummy stage config""" + + name = "authentik.stages.dummy" + label = "authentik_stages_dummy" + verbose_name = "authentik Stages.Dummy" diff --git a/authentik/stages/dummy/forms.py b/authentik/stages/dummy/forms.py new file mode 100644 index 00000000..92420cdd --- /dev/null +++ b/authentik/stages/dummy/forms.py @@ -0,0 +1,16 @@ +"""authentik administration forms""" +from django import forms + +from authentik.stages.dummy.models import DummyStage + + +class DummyStageForm(forms.ModelForm): + """Form to create/edit Dummy Stage""" + + class Meta: + + model = DummyStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/dummy/migrations/0001_initial.py b/authentik/stages/dummy/migrations/0001_initial.py new file mode 100644 index 00000000..180a0994 --- /dev/null +++ b/authentik/stages/dummy/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="DummyStage", + 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", + ), + ), + ], + options={ + "verbose_name": "Dummy Stage", + "verbose_name_plural": "Dummy Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/passbook/stages/dummy/migrations/__init__.py b/authentik/stages/dummy/migrations/__init__.py similarity index 100% rename from passbook/stages/dummy/migrations/__init__.py rename to authentik/stages/dummy/migrations/__init__.py diff --git a/authentik/stages/dummy/models.py b/authentik/stages/dummy/models.py new file mode 100644 index 00000000..3dfb768b --- /dev/null +++ b/authentik/stages/dummy/models.py @@ -0,0 +1,41 @@ +"""dummy stage models""" +from typing import Type + +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import Stage + + +class DummyStage(Stage): + """Used for debugging.""" + + __debug_only__ = True + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.dummy.api import DummyStageSerializer + + return DummyStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.dummy.stage import DummyStageView + + return DummyStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.dummy.forms import DummyStageForm + + return DummyStageForm + + def __str__(self): + return f"Dummy Stage {self.name}" + + class Meta: + + verbose_name = _("Dummy Stage") + verbose_name_plural = _("Dummy Stages") diff --git a/authentik/stages/dummy/stage.py b/authentik/stages/dummy/stage.py new file mode 100644 index 00000000..9cedfa47 --- /dev/null +++ b/authentik/stages/dummy/stage.py @@ -0,0 +1,19 @@ +"""authentik multi-stage authentication engine""" +from typing import Any, Dict + +from django.http import HttpRequest + +from authentik.flows.stage import StageView + + +class DummyStageView(StageView): + """Dummy stage for testing with multiple stages""" + + def post(self, request: HttpRequest): + """Just redirect to next stage""" + return self.executor.stage_ok() + + def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: + kwargs = super().get_context_data(**kwargs) + kwargs["title"] = self.executor.current_stage.name + return kwargs diff --git a/authentik/stages/dummy/tests.py b/authentik/stages/dummy/tests.py new file mode 100644 index 00000000..61493ead --- /dev/null +++ b/authentik/stages/dummy/tests.py @@ -0,0 +1,58 @@ +"""dummy tests""" +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.stages.dummy.forms import DummyStageForm +from authentik.stages.dummy.models import DummyStage + + +class TestDummyStage(TestCase): + """Dummy tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create(username="unittest", email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-dummy", + slug="test-dummy", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = DummyStage.objects.create( + name="dummy", + ) + FlowStageBinding.objects.create( + target=self.flow, + stage=self.stage, + order=0, + ) + + def test_valid_render(self): + """Test that View renders correctly""" + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 200) + + def test_post(self): + """Test with valid email, check that URL redirects back to itself""" + url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + response = self.client.post(url, {}) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + def test_form(self): + """Test Form""" + data = {"name": "test"} + self.assertEqual(DummyStageForm(data).is_valid(), True) diff --git a/passbook/stages/email/__init__.py b/authentik/stages/email/__init__.py similarity index 100% rename from passbook/stages/email/__init__.py rename to authentik/stages/email/__init__.py diff --git a/authentik/stages/email/api.py b/authentik/stages/email/api.py new file mode 100644 index 00000000..b3a4860f --- /dev/null +++ b/authentik/stages/email/api.py @@ -0,0 +1,36 @@ +"""EmailStage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.email.models import EmailStage + + +class EmailStageSerializer(ModelSerializer): + """EmailStage Serializer""" + + class Meta: + + model = EmailStage + fields = [ + "pk", + "name", + "host", + "port", + "username", + "password", + "use_tls", + "use_ssl", + "timeout", + "from_address", + "token_expiry", + "subject", + "template", + ] + extra_kwargs = {"password": {"write_only": True}} + + +class EmailStageViewSet(ModelViewSet): + """EmailStage Viewset""" + + queryset = EmailStage.objects.all() + serializer_class = EmailStageSerializer diff --git a/authentik/stages/email/apps.py b/authentik/stages/email/apps.py new file mode 100644 index 00000000..e7fe9211 --- /dev/null +++ b/authentik/stages/email/apps.py @@ -0,0 +1,15 @@ +"""authentik email stage config""" +from importlib import import_module + +from django.apps import AppConfig + + +class AuthentikStageEmailConfig(AppConfig): + """authentik email stage config""" + + name = "authentik.stages.email" + label = "authentik_stages_email" + verbose_name = "authentik Stages.Email" + + def ready(self): + import_module("authentik.stages.email.tasks") diff --git a/authentik/stages/email/forms.py b/authentik/stages/email/forms.py new file mode 100644 index 00000000..6c3ea986 --- /dev/null +++ b/authentik/stages/email/forms.py @@ -0,0 +1,44 @@ +"""authentik administration forms""" +from django import forms +from django.utils.translation import gettext_lazy as _ + +from authentik.stages.email.models import EmailStage + + +class EmailStageSendForm(forms.Form): + """Form used when sending the email to prevent multiple emails being sent""" + + invalid = forms.CharField(widget=forms.HiddenInput, required=True) + + +class EmailStageForm(forms.ModelForm): + """Form to create/edit Email Stage""" + + class Meta: + + model = EmailStage + fields = [ + "name", + "host", + "port", + "username", + "password", + "use_tls", + "use_ssl", + "timeout", + "from_address", + "token_expiry", + "subject", + "template", + ] + widgets = { + "name": forms.TextInput(), + "host": forms.TextInput(), + "subject": forms.TextInput(), + "username": forms.TextInput(), + "password": forms.TextInput(), + } + labels = { + "use_tls": _("Use TLS"), + "use_ssl": _("Use SSL"), + } diff --git a/authentik/stages/email/migrations/0001_initial.py b/authentik/stages/email/migrations/0001_initial.py new file mode 100644 index 00000000..b3412e76 --- /dev/null +++ b/authentik/stages/email/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="EmailStage", + 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", + ), + ), + ("host", models.TextField(default="localhost")), + ("port", models.IntegerField(default=25)), + ("username", models.TextField(blank=True, default="")), + ("password", models.TextField(blank=True, default="")), + ("use_tls", models.BooleanField(default=False)), + ("use_ssl", models.BooleanField(default=False)), + ("timeout", models.IntegerField(default=10)), + ( + "from_address", + models.EmailField(default="system@authentik.local", max_length=254), + ), + ( + "token_expiry", + models.IntegerField( + default=30, help_text="Time in minutes the token sent is valid." + ), + ), + ("subject", models.TextField(default="authentik")), + ( + "template", + models.TextField( + choices=[ + ( + "stages/email/for_email/password_reset.html", + "Password Reset", + ), + ( + "stages/email/for_email/account_confirmation.html", + "Account Confirmation", + ), + ], + default="stages/email/for_email/password_reset.html", + ), + ), + ], + options={ + "verbose_name": "Email Stage", + "verbose_name_plural": "Email Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/passbook/stages/email/migrations/__init__.py b/authentik/stages/email/migrations/__init__.py similarity index 100% rename from passbook/stages/email/migrations/__init__.py rename to authentik/stages/email/migrations/__init__.py diff --git a/authentik/stages/email/models.py b/authentik/stages/email/models.py new file mode 100644 index 00000000..448e0f97 --- /dev/null +++ b/authentik/stages/email/models.py @@ -0,0 +1,85 @@ +"""email stage models""" +from typing import Type + +from django.core.mail import get_connection +from django.core.mail.backends.base import BaseEmailBackend +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import Stage + + +class EmailTemplates(models.TextChoices): + """Templates used for rendering the Email""" + + PASSWORD_RESET = ( + "stages/email/for_email/password_reset.html", + _("Password Reset"), + ) # nosec + ACCOUNT_CONFIRM = ( + "stages/email/for_email/account_confirmation.html", + _("Account Confirmation"), + ) + + +class EmailStage(Stage): + """Sends an Email to the user with a token to confirm their Email address.""" + + host = models.TextField(default="localhost") + port = models.IntegerField(default=25) + username = models.TextField(default="", blank=True) + password = models.TextField(default="", blank=True) + use_tls = models.BooleanField(default=False) + use_ssl = models.BooleanField(default=False) + timeout = models.IntegerField(default=10) + from_address = models.EmailField(default="system@authentik.local") + + token_expiry = models.IntegerField( + default=30, help_text=_("Time in minutes the token sent is valid.") + ) + subject = models.TextField(default="authentik") + template = models.TextField( + choices=EmailTemplates.choices, default=EmailTemplates.PASSWORD_RESET + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.email.api import EmailStageSerializer + + return EmailStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.email.stage import EmailStageView + + return EmailStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.email.forms import EmailStageForm + + return EmailStageForm + + @property + def backend(self) -> BaseEmailBackend: + """Get fully configured EMail Backend instance""" + return get_connection( + host=self.host, + port=self.port, + username=self.username, + password=self.password, + use_tls=self.use_tls, + use_ssl=self.use_ssl, + timeout=self.timeout, + ) + + def __str__(self): + return f"Email Stage {self.name}" + + class Meta: + + verbose_name = _("Email Stage") + verbose_name_plural = _("Email Stages") diff --git a/authentik/stages/email/stage.py b/authentik/stages/email/stage.py new file mode 100644 index 00000000..4273859a --- /dev/null +++ b/authentik/stages/email/stage.py @@ -0,0 +1,90 @@ +"""authentik multi-stage authentication engine""" +from datetime import timedelta + +from django.contrib import messages +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, reverse +from django.utils.http import urlencode +from django.utils.timezone import now +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from structlog import get_logger + +from authentik.core.models import Token +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.flows.views import SESSION_KEY_GET +from authentik.stages.email.forms import EmailStageSendForm +from authentik.stages.email.models import EmailStage +from authentik.stages.email.tasks import send_mails +from authentik.stages.email.utils import TemplateEmailMessage + +LOGGER = get_logger() +QS_KEY_TOKEN = "token" +PLAN_CONTEXT_EMAIL_SENT = "email_sent" + + +class EmailStageView(FormView, StageView): + """Email stage which sends Email for verification""" + + form_class = EmailStageSendForm + template_name = "stages/email/waiting_message.html" + + def get_full_url(self, **kwargs) -> str: + """Get full URL to be used in template""" + base_url = reverse( + "authentik_flows:flow-executor-shell", + kwargs={"flow_slug": self.executor.flow.slug}, + ) + relative_url = f"{base_url}?{urlencode(kwargs)}" + return self.request.build_absolute_uri(relative_url) + + def send_email(self): + """Helper function that sends the actual email. Implies that you've + already checked that there is a pending user.""" + pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + current_stage: EmailStage = self.executor.current_stage + valid_delta = timedelta( + minutes=current_stage.token_expiry + 1 + ) # + 1 because django timesince always rounds down + token = Token.objects.create(user=pending_user, expires=now() + valid_delta) + # Send mail to user + message = TemplateEmailMessage( + subject=_(current_stage.subject), + template_name=current_stage.template, + to=[pending_user.email], + template_context={ + "url": self.get_full_url(**{QS_KEY_TOKEN: token.pk.hex}), + "user": pending_user, + "expires": token.expires, + }, + ) + send_mails(current_stage, message) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + # Check if the user came back from the email link to verify + if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}): + token = get_object_or_404( + Token, pk=request.session[SESSION_KEY_GET][QS_KEY_TOKEN] + ) + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user + token.delete() + messages.success(request, _("Successfully verified Email.")) + return self.executor.stage_ok() + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + messages.error(self.request, _("No pending user.")) + return self.executor.stage_invalid() + # Check if we've already sent the initial e-mail + if PLAN_CONTEXT_EMAIL_SENT not in self.executor.plan.context: + self.send_email() + self.executor.plan.context[PLAN_CONTEXT_EMAIL_SENT] = True + return super().get(request, *args, **kwargs) + + def form_invalid(self, form: EmailStageSendForm) -> HttpResponse: + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + messages.error(self.request, _("No pending user.")) + return super().form_invalid(form) + self.send_email() + # We can't call stage_ok yet, as we're still waiting + # for the user to click the link in the email + return super().form_invalid(form) diff --git a/passbook/stages/email/static/stages/email/css/base.css b/authentik/stages/email/static/stages/email/css/base.css similarity index 100% rename from passbook/stages/email/static/stages/email/css/base.css rename to authentik/stages/email/static/stages/email/css/base.css diff --git a/authentik/stages/email/tasks.py b/authentik/stages/email/tasks.py new file mode 100644 index 00000000..28131cd3 --- /dev/null +++ b/authentik/stages/email/tasks.py @@ -0,0 +1,65 @@ +"""email stage tasks""" +from email.utils import make_msgid +from smtplib import SMTPException +from typing import Any, Dict, List + +from celery import group +from django.core.mail import EmailMultiAlternatives +from django.core.mail.utils import DNS_NAME +from structlog import get_logger + +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.root.celery import CELERY_APP +from authentik.stages.email.models import EmailStage + +LOGGER = get_logger() + + +def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]): + """Wrapper to convert EmailMessage to dict and send it from worker""" + tasks = [] + for message in messages: + tasks.append(send_mail.s(stage.pk, message.__dict__)) + lazy_group = group(*tasks) + promise = lazy_group() + return promise + + +@CELERY_APP.task( + bind=True, + autoretry_for=( + SMTPException, + ConnectionError, + ), + retry_backoff=True, + base=MonitoredTask, +) +def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]): + """Send Email for Email Stage. Retries are scheduled automatically.""" + self.save_on_success = False + message_id = make_msgid(domain=DNS_NAME) + self.set_uid(message_id) + try: + stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk) + backend = stage.backend + backend.open() + # Since django's EmailMessage objects are not JSON serialisable, + # we need to rebuild them from a dict + message_object = EmailMultiAlternatives() + for key, value in message.items(): + setattr(message_object, key, value) + message_object.from_email = stage.from_address + # Because we use the Message-ID as UID for the task, manually assign it + message_object.extra_headers["Message-ID"] = message_id + + LOGGER.debug("Sending mail", to=message_object.to) + stage.backend.send_messages([message_object]) + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, + messages=["Successfully sent Mail."], + ) + ) + except (SMTPException, ConnectionError) as exc: + self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) + raise exc diff --git a/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html b/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html new file mode 100644 index 00000000..8c2b353e --- /dev/null +++ b/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html @@ -0,0 +1,38 @@ +{% extends 'stages/email/for_email/base.html' %} + +{% load authentik_stages_email %} +{% load i18n %} + +{% block content %} + +

+ {% trans 'Welcome!' %} +

+

+ {% trans "We're excited to have you get started. First, you need to confirm your account. Just press the button below."%} +

+ + + + + + + +

+ {% blocktrans with url=url %} + If that doesn't work, copy and paste the following link in your browser: {{ url }} + {% endblocktrans %} +

+

+ {% trans "If you have any questions, just reply to this email—we're always happy to help out." %} +

+ +{% endblock %} diff --git a/authentik/stages/email/templates/stages/email/for_email/base.html b/authentik/stages/email/templates/stages/email/for_email/base.html new file mode 100644 index 00000000..1261007a --- /dev/null +++ b/authentik/stages/email/templates/stages/email/for_email/base.html @@ -0,0 +1,65 @@ +{% load authentik_stages_email %} +{% load authentik_utils %} +{% load static %} +{% load i18n %} + + + + + + + + + + + + + + {% block pre_header %} + {% endblock %} + + + + + + + + + + + diff --git a/passbook/stages/email/templates/stages/email/for_email/generic_email.html b/authentik/stages/email/templates/stages/email/for_email/generic_email.html similarity index 100% rename from passbook/stages/email/templates/stages/email/for_email/generic_email.html rename to authentik/stages/email/templates/stages/email/for_email/generic_email.html diff --git a/authentik/stages/email/templates/stages/email/for_email/password_reset.html b/authentik/stages/email/templates/stages/email/for_email/password_reset.html new file mode 100644 index 00000000..d6818daf --- /dev/null +++ b/authentik/stages/email/templates/stages/email/for_email/password_reset.html @@ -0,0 +1,40 @@ +{% extends "stages/email/for_email/base.html" %} + +{% load authentik_utils %} +{% load i18n %} +{% load humanize %} + +{% block content %} + +

+ {% blocktrans with username=user.username %} + Hi {{ username }}, + {% endblocktrans %} +

+

+ {% blocktrans %} + You recently requested to change your password for you authentik account. Use the button below to set a new password. + {% endblocktrans %} +

+ + + + + + + +

+ {% blocktrans with expires=expires|naturaltime %} + If you did not request a password change, please ignore this Email. The link above is valid for {{ expires }}. + {% endblocktrans %} +

+ +{% endblock %} diff --git a/passbook/stages/email/templates/stages/email/waiting_message.html b/authentik/stages/email/templates/stages/email/waiting_message.html similarity index 100% rename from passbook/stages/email/templates/stages/email/waiting_message.html rename to authentik/stages/email/templates/stages/email/waiting_message.html diff --git a/passbook/stages/email/templatetags/__init__.py b/authentik/stages/email/templatetags/__init__.py similarity index 100% rename from passbook/stages/email/templatetags/__init__.py rename to authentik/stages/email/templatetags/__init__.py diff --git a/authentik/stages/email/templatetags/authentik_stages_email.py b/authentik/stages/email/templatetags/authentik_stages_email.py new file mode 100644 index 00000000..1f994843 --- /dev/null +++ b/authentik/stages/email/templatetags/authentik_stages_email.py @@ -0,0 +1,31 @@ +"""authentik core inlining template tags""" +from base64 import b64encode +from pathlib import Path + +from django import template +from django.contrib.staticfiles import finders + +register = template.Library() + + +@register.simple_tag() +def inline_static_ascii(path: str) -> str: + """Inline static asset. Doesn't check file contents, plain text is assumed. + If no file could be found, original path is returned""" + result = Path(finders.find(path)) + if result: + with open(result) as _file: + return _file.read() + return path + + +@register.simple_tag() +def inline_static_binary(path: str) -> str: + """Inline static asset. Uses file extension for base64 block. If no file could be found, + path is returned.""" + result = Path(finders.find(path)) + if result and result.is_file(): + with open(result) as _file: + b64content = b64encode(_file.read().encode()) + return f"data:image/{result.suffix};base64,{b64content.decode('utf-8')}" + return path diff --git a/authentik/stages/email/tests.py b/authentik/stages/email/tests.py new file mode 100644 index 00000000..c71a0b2d --- /dev/null +++ b/authentik/stages/email/tests.py @@ -0,0 +1,126 @@ +"""email tests""" +from unittest.mock import MagicMock, patch + +from django.core import mail +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import Token, User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.stages.email.models import EmailStage +from authentik.stages.email.stage import QS_KEY_TOKEN + + +class TestEmailStage(TestCase): + """Email tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + username="unittest", email="test@beryju.org" + ) + self.client = Client() + + self.flow = Flow.objects.create( + name="test-email", + slug="test-email", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = EmailStage.objects.create( + name="email", + ) + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_rendering(self): + """Test with pending user""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_without_user(self): + """Test without pending user""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_pending_user(self): + """Test with pending user""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + with self.settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend" + ): + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "authentik") + + def test_token(self): + """Test with token""" + # Make sure token exists + self.test_pending_user() + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): + # Call the executor shell to preseed the session + url = reverse( + "authentik_flows:flow-executor-shell", + kwargs={"flow_slug": self.flow.slug}, + ) + token = Token.objects.get(user=self.user) + url += f"?{QS_KEY_TOKEN}={token.pk.hex}" + self.client.get(url) + # Call the actual executor to get the JSON Response + response = self.client.get( + reverse( + "authentik_flows:flow-executor", + kwargs={"flow_slug": self.flow.slug}, + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + session = self.client.session + plan: FlowPlan = session[SESSION_KEY_PLAN] + self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER], self.user) diff --git a/passbook/stages/email/utils.py b/authentik/stages/email/utils.py similarity index 100% rename from passbook/stages/email/utils.py rename to authentik/stages/email/utils.py diff --git a/passbook/stages/identification/__init__.py b/authentik/stages/identification/__init__.py similarity index 100% rename from passbook/stages/identification/__init__.py rename to authentik/stages/identification/__init__.py diff --git a/authentik/stages/identification/api.py b/authentik/stages/identification/api.py new file mode 100644 index 00000000..7c463fa2 --- /dev/null +++ b/authentik/stages/identification/api.py @@ -0,0 +1,29 @@ +"""Identification Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.identification.models import IdentificationStage + + +class IdentificationStageSerializer(ModelSerializer): + """IdentificationStage Serializer""" + + class Meta: + + model = IdentificationStage + fields = [ + "pk", + "name", + "user_fields", + "case_insensitive_matching", + "template", + "enrollment_flow", + "recovery_flow", + ] + + +class IdentificationStageViewSet(ModelViewSet): + """IdentificationStage Viewset""" + + queryset = IdentificationStage.objects.all() + serializer_class = IdentificationStageSerializer diff --git a/authentik/stages/identification/apps.py b/authentik/stages/identification/apps.py new file mode 100644 index 00000000..e44ebc15 --- /dev/null +++ b/authentik/stages/identification/apps.py @@ -0,0 +1,10 @@ +"""authentik identification stage app config""" +from django.apps import AppConfig + + +class AuthentikStageIdentificationConfig(AppConfig): + """authentik identification stage config""" + + name = "authentik.stages.identification" + label = "authentik_stages_identification" + verbose_name = "authentik Stages.Identification" diff --git a/authentik/stages/identification/forms.py b/authentik/stages/identification/forms.py new file mode 100644 index 00000000..d371545f --- /dev/null +++ b/authentik/stages/identification/forms.py @@ -0,0 +1,73 @@ +"""authentik flows identification forms""" +from django import forms +from django.core.validators import validate_email +from django.utils.translation import gettext_lazy as _ +from structlog import get_logger + +from authentik.admin.fields import ArrayFieldSelectMultiple +from authentik.flows.models import Flow, FlowDesignation +from authentik.lib.utils.ui import human_list +from authentik.stages.identification.models import IdentificationStage, UserFields + +LOGGER = get_logger() + + +class IdentificationStageForm(forms.ModelForm): + """Form to create/edit IdentificationStage instances""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["enrollment_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.ENROLLMENT + ) + self.fields["recovery_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.RECOVERY + ) + + class Meta: + + model = IdentificationStage + fields = [ + "name", + "user_fields", + "case_insensitive_matching", + "template", + "enrollment_flow", + "recovery_flow", + ] + widgets = { + "name": forms.TextInput(), + "user_fields": ArrayFieldSelectMultiple(choices=UserFields.choices), + } + + +class IdentificationForm(forms.Form): + """Allow users to login""" + + stage: IdentificationStage + + title = _("Log in to your account") + uid_field = forms.CharField(label=_("")) + + def __init__(self, *args, **kwargs): + self.stage = kwargs.pop("stage") + super().__init__(*args, **kwargs) + if self.stage.user_fields == [UserFields.E_MAIL]: + self.fields["uid_field"] = forms.EmailField() + label = human_list([x.title() for x in self.stage.user_fields]) + self.fields["uid_field"].label = label + self.fields["uid_field"].widget.attrs.update( + { + "placeholder": _(label), + "autofocus": "autofocus", + # Autocomplete according to + # https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands + "autocomplete": "username", + } + ) + + def clean_uid_field(self): + """Validate uid_field after EmailValidator if 'email' is the only selected uid_fields""" + if self.stage.user_fields == [UserFields.E_MAIL]: + validate_email(self.cleaned_data.get("uid_field")) + return self.cleaned_data.get("uid_field") diff --git a/authentik/stages/identification/migrations/0001_initial.py b/authentik/stages/identification/migrations/0001_initial.py new file mode 100644 index 00000000..e28eb3ad --- /dev/null +++ b/authentik/stages/identification/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="IdentificationStage", + 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", + ), + ), + ( + "user_fields", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("email", "E Mail"), ("username", "Username")], + max_length=100, + ), + help_text="Fields of the user object to match against.", + size=None, + ), + ), + ( + "template", + models.TextField( + choices=[ + ("stages/identification/login.html", "Default Login"), + ("stages/identification/recovery.html", "Default Recovery"), + ] + ), + ), + ], + options={ + "verbose_name": "Identification Stage", + "verbose_name_plural": "Identification Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/identification/migrations/0002_auto_20200530_2204.py b/authentik/stages/identification/migrations/0002_auto_20200530_2204.py new file mode 100644 index 00000000..7b5c6610 --- /dev/null +++ b/authentik/stages/identification/migrations/0002_auto_20200530_2204.py @@ -0,0 +1,41 @@ +# Generated by Django 3.0.6 on 2020-05-30 22:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0003_auto_20200523_1133"), + ("authentik_stages_identification", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="identificationstage", + name="enrollment_flow", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Optional enrollment flow, which is linked at the bottom of the page.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_flows.Flow", + ), + ), + migrations.AddField( + model_name="identificationstage", + name="recovery_flow", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Optional enrollment flow, which is linked at the bottom of the page.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_flows.Flow", + ), + ), + ] diff --git a/authentik/stages/identification/migrations/0003_auto_20200615_1641.py b/authentik/stages/identification/migrations/0003_auto_20200615_1641.py new file mode 100644 index 00000000..4955dc35 --- /dev/null +++ b/authentik/stages/identification/migrations/0003_auto_20200615_1641.py @@ -0,0 +1,28 @@ +# Generated by Django 3.0.7 on 2020-06-15 16:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0007_auto_20200703_2059"), + ("authentik_stages_identification", "0002_auto_20200530_2204"), + ] + + operations = [ + migrations.AlterField( + model_name="identificationstage", + name="recovery_flow", + field=models.ForeignKey( + blank=True, + default=None, + help_text="Optional recovery flow, which is linked at the bottom of the page.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_flows.Flow", + ), + ), + ] diff --git a/authentik/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py b/authentik/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py new file mode 100644 index 00000000..aae31537 --- /dev/null +++ b/authentik/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.1 on 2020-09-30 21:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_identification", "0003_auto_20200615_1641"), + ] + + operations = [ + migrations.AddField( + model_name="identificationstage", + name="case_insensitive_matching", + field=models.BooleanField( + default=True, + help_text="When enabled, user fields are matched regardless of their casing.", + ), + ), + ] diff --git a/authentik/stages/identification/migrations/0005_auto_20201003_1734.py b/authentik/stages/identification/migrations/0005_auto_20201003_1734.py new file mode 100644 index 00000000..30f6d6b7 --- /dev/null +++ b/authentik/stages/identification/migrations/0005_auto_20201003_1734.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.2 on 2020-10-03 17:34 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_stages_identification", + "0004_identificationstage_case_insensitive_matching", + ), + ] + + operations = [ + migrations.AlterField( + model_name="identificationstage", + name="user_fields", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("email", "E Mail"), ("username", "Username")], + max_length=100, + ), + help_text="Fields of the user object to match against. (Hold shift to select multiple options)", + size=None, + ), + ), + ] diff --git a/passbook/stages/identification/migrations/__init__.py b/authentik/stages/identification/migrations/__init__.py similarity index 100% rename from passbook/stages/identification/migrations/__init__.py rename to authentik/stages/identification/migrations/__init__.py diff --git a/authentik/stages/identification/models.py b/authentik/stages/identification/models.py new file mode 100644 index 00000000..94e28cbe --- /dev/null +++ b/authentik/stages/identification/models.py @@ -0,0 +1,96 @@ +"""identification stage models""" +from typing import Type + +from django.contrib.postgres.fields 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 Flow, Stage + + +class UserFields(models.TextChoices): + """Fields which the user can identify themselves with""" + + E_MAIL = "email" + USERNAME = "username" + + +class Templates(models.TextChoices): + """Templates to be used for the stage""" + + DEFAULT_LOGIN = "stages/identification/login.html" + DEFAULT_RECOVERY = "stages/identification/recovery.html" + + +class IdentificationStage(Stage): + """Allows the user to identify themselves for authentication.""" + + user_fields = ArrayField( + models.CharField(max_length=100, choices=UserFields.choices), + help_text=_( + ( + "Fields of the user object to match against. " + "(Hold shift to select multiple options)" + ) + ), + ) + template = models.TextField(choices=Templates.choices) + + case_insensitive_matching = models.BooleanField( + default=True, + help_text=_( + "When enabled, user fields are matched regardless of their casing." + ), + ) + + enrollment_flow = models.ForeignKey( + Flow, + on_delete=models.SET_DEFAULT, + null=True, + blank=True, + related_name="+", + default=None, + help_text=_( + "Optional enrollment flow, which is linked at the bottom of the page." + ), + ) + recovery_flow = models.ForeignKey( + Flow, + on_delete=models.SET_DEFAULT, + null=True, + blank=True, + related_name="+", + default=None, + help_text=_( + "Optional recovery flow, which is linked at the bottom of the page." + ), + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.identification.api import IdentificationStageSerializer + + return IdentificationStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.identification.stage import IdentificationStageView + + return IdentificationStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.identification.forms import IdentificationStageForm + + return IdentificationStageForm + + def __str__(self): + return f"Identification Stage {self.name}" + + class Meta: + + verbose_name = _("Identification Stage") + verbose_name_plural = _("Identification Stages") diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py new file mode 100644 index 00000000..41a8a895 --- /dev/null +++ b/authentik/stages/identification/stage.py @@ -0,0 +1,93 @@ +"""Identification stage logic""" +from typing import List, Optional + +from django.contrib import messages +from django.db.models import Q +from django.http import HttpResponse +from django.shortcuts import reverse +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from structlog import get_logger + +from authentik.core.models import Source, User +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.flows.views import SESSION_KEY_APPLICATION_PRE +from authentik.stages.identification.forms import IdentificationForm +from authentik.stages.identification.models import IdentificationStage + +LOGGER = get_logger() + + +class IdentificationStageView(FormView, StageView): + """Form to identify the user""" + + form_class = IdentificationForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["stage"] = self.executor.current_stage + return kwargs + + def get_template_names(self) -> List[str]: + current_stage: IdentificationStage = self.executor.current_stage + return [current_stage.template] + + def get_context_data(self, **kwargs): + current_stage: IdentificationStage = self.executor.current_stage + # If the user has been redirected to us whilst trying to access an + # application, SESSION_KEY_APPLICATION_PRE is set in the session + if SESSION_KEY_APPLICATION_PRE in self.request.session: + kwargs["application_pre"] = self.request.session[ + SESSION_KEY_APPLICATION_PRE + ] + # Check for related enrollment and recovery flow, add URL to view + if current_stage.enrollment_flow: + kwargs["enroll_url"] = reverse( + "authentik_flows:flow-executor-shell", + kwargs={"flow_slug": current_stage.enrollment_flow.slug}, + ) + if current_stage.recovery_flow: + kwargs["recovery_url"] = reverse( + "authentik_flows:flow-executor-shell", + kwargs={"flow_slug": current_stage.recovery_flow.slug}, + ) + kwargs["primary_action"] = _("Log in") + + # Check all enabled source, add them if they have a UI Login button. + kwargs["sources"] = [] + sources: List[Source] = ( + Source.objects.filter(enabled=True).order_by("name").select_subclasses() + ) + for source in sources: + ui_login_button = source.ui_login_button + if ui_login_button: + kwargs["sources"].append(ui_login_button) + return super().get_context_data(**kwargs) + + def get_user(self, uid_value: str) -> Optional[User]: + """Find user instance. Returns None if no user was found.""" + current_stage: IdentificationStage = self.executor.current_stage + query = Q() + for search_field in current_stage.user_fields: + model_field = search_field + if current_stage.case_insensitive_matching: + model_field += "__iexact" + else: + model_field += "__exact" + query |= Q(**{model_field: uid_value}) + users = User.objects.filter(query) + if users.exists(): + LOGGER.debug("Found user", user=users.first(), query=query) + return users.first() + return None + + def form_valid(self, form: IdentificationForm) -> HttpResponse: + """Form data is valid""" + pre_user = self.get_user(form.cleaned_data.get("uid_field")) + if not pre_user: + LOGGER.debug("invalid_login") + messages.error(self.request, _("Failed to authenticate.")) + return self.form_invalid(form) + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = pre_user + return self.executor.stage_ok() diff --git a/passbook/stages/identification/templates/stages/identification/login.html b/authentik/stages/identification/templates/stages/identification/login.html similarity index 100% rename from passbook/stages/identification/templates/stages/identification/login.html rename to authentik/stages/identification/templates/stages/identification/login.html diff --git a/passbook/stages/identification/templates/stages/identification/recovery.html b/authentik/stages/identification/templates/stages/identification/recovery.html similarity index 100% rename from passbook/stages/identification/templates/stages/identification/recovery.html rename to authentik/stages/identification/templates/stages/identification/recovery.html diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py new file mode 100644 index 00000000..326256e4 --- /dev/null +++ b/authentik/stages/identification/tests.py @@ -0,0 +1,131 @@ +"""identification tests""" +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.sources.oauth.models import OAuthSource +from authentik.stages.identification.models import ( + IdentificationStage, + Templates, + UserFields, +) + + +class TestIdentificationStage(TestCase): + """Identification tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create(username="unittest", email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-identification", + slug="test-identification", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = IdentificationStage.objects.create( + name="identification", + user_fields=[UserFields.E_MAIL], + template=Templates.DEFAULT_LOGIN, + ) + FlowStageBinding.objects.create( + target=self.flow, + stage=self.stage, + order=0, + ) + + # OAuthSource for the login view + OAuthSource.objects.create(name="test", slug="test") + + def test_valid_render(self): + """Test that View renders correctly""" + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 200) + + def test_valid_with_email(self): + """Test with valid email, check that URL redirects back to itself""" + form_data = {"uid_field": self.user.email} + url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + response = self.client.post(url, form_data) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + def test_invalid_with_username(self): + """Test invalid with username (user exists but stage only allows email)""" + form_data = {"uid_field": self.user.username} + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + form_data, + ) + self.assertEqual(response.status_code, 200) + + def test_invalid_with_invalid_email(self): + """Test with invalid email (user doesn't exist) -> Will return to login form""" + form_data = {"uid_field": self.user.email + "test"} + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + form_data, + ) + self.assertEqual(response.status_code, 200) + + def test_enrollment_flow(self): + """Test that enrollment flow is linked correctly""" + flow = Flow.objects.create( + name="enroll-test", + slug="unique-enrollment-string", + designation=FlowDesignation.ENROLLMENT, + ) + self.stage.enrollment_flow = flow + self.stage.save() + FlowStageBinding.objects.create( + target=flow, + stage=self.stage, + order=0, + ) + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + ) + self.assertEqual(response.status_code, 200) + self.assertIn(flow.slug, force_str(response.content)) + + def test_recovery_flow(self): + """Test that recovery flow is linked correctly""" + flow = Flow.objects.create( + name="recovery-test", + slug="unique-recovery-string", + designation=FlowDesignation.RECOVERY, + ) + self.stage.recovery_flow = flow + self.stage.save() + FlowStageBinding.objects.create( + target=flow, + stage=self.stage, + order=0, + ) + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + ) + self.assertEqual(response.status_code, 200) + self.assertIn(flow.slug, force_str(response.content)) diff --git a/passbook/stages/invitation/__init__.py b/authentik/stages/invitation/__init__.py similarity index 100% rename from passbook/stages/invitation/__init__.py rename to authentik/stages/invitation/__init__.py diff --git a/authentik/stages/invitation/api.py b/authentik/stages/invitation/api.py new file mode 100644 index 00000000..f1390abe --- /dev/null +++ b/authentik/stages/invitation/api.py @@ -0,0 +1,45 @@ +"""Invitation Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.invitation.models import Invitation, InvitationStage + + +class InvitationStageSerializer(ModelSerializer): + """InvitationStage Serializer""" + + class Meta: + + model = InvitationStage + fields = [ + "pk", + "name", + "continue_flow_without_invitation", + ] + + +class InvitationStageViewSet(ModelViewSet): + """InvitationStage Viewset""" + + queryset = InvitationStage.objects.all() + serializer_class = InvitationStageSerializer + + +class InvitationSerializer(ModelSerializer): + """Invitation Serializer""" + + class Meta: + + model = Invitation + fields = [ + "pk", + "expires", + "fixed_data", + ] + + +class InvitationViewSet(ModelViewSet): + """Invitation Viewset""" + + queryset = Invitation.objects.all() + serializer_class = InvitationSerializer diff --git a/authentik/stages/invitation/apps.py b/authentik/stages/invitation/apps.py new file mode 100644 index 00000000..efd79942 --- /dev/null +++ b/authentik/stages/invitation/apps.py @@ -0,0 +1,10 @@ +"""authentik invitation stage app config""" +from django.apps import AppConfig + + +class AuthentikStageUserInvitationConfig(AppConfig): + """authentik invitation stage config""" + + name = "authentik.stages.invitation" + label = "authentik_stages_invitation" + verbose_name = "authentik Stages.User Invitation" diff --git a/authentik/stages/invitation/forms.py b/authentik/stages/invitation/forms.py new file mode 100644 index 00000000..0e3b2094 --- /dev/null +++ b/authentik/stages/invitation/forms.py @@ -0,0 +1,32 @@ +"""authentik flows invitation forms""" +from django import forms +from django.utils.translation import gettext as _ + +from authentik.admin.fields import CodeMirrorWidget, YAMLField +from authentik.stages.invitation.models import Invitation, InvitationStage + + +class InvitationStageForm(forms.ModelForm): + """Form to create/edit InvitationStage instances""" + + class Meta: + + model = InvitationStage + fields = ["name", "continue_flow_without_invitation"] + widgets = { + "name": forms.TextInput(), + } + + +class InvitationForm(forms.ModelForm): + """InvitationForm""" + + class Meta: + + model = Invitation + fields = ["expires", "fixed_data"] + labels = { + "fixed_data": _("Optional fixed data to enforce on user enrollment."), + } + widgets = {"fixed_data": CodeMirrorWidget()} + field_classes = {"fixed_data": YAMLField} diff --git a/authentik/stages/invitation/migrations/0001_initial.py b/authentik/stages/invitation/migrations/0001_initial.py new file mode 100644 index 00000000..162ba81b --- /dev/null +++ b/authentik/stages/invitation/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import uuid + +import django.db.models.deletion +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", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="InvitationStage", + 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", + ), + ), + ( + "continue_flow_without_invitation", + models.BooleanField( + default=False, + help_text="If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given.", + ), + ), + ], + options={ + "verbose_name": "Invitation Stage", + "verbose_name_plural": "Invitation Stages", + }, + bases=("authentik_flows.stage",), + ), + migrations.CreateModel( + name="Invitation", + fields=[ + ( + "invite_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("expires", models.DateTimeField(blank=True, default=None, null=True)), + ( + "fixed_data", + models.JSONField(default=dict), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Invitation", + "verbose_name_plural": "Invitations", + }, + ), + ] diff --git a/passbook/stages/invitation/migrations/__init__.py b/authentik/stages/invitation/migrations/__init__.py similarity index 100% rename from passbook/stages/invitation/migrations/__init__.py rename to authentik/stages/invitation/migrations/__init__.py diff --git a/authentik/stages/invitation/models.py b/authentik/stages/invitation/models.py new file mode 100644 index 00000000..f5f3bf51 --- /dev/null +++ b/authentik/stages/invitation/models.py @@ -0,0 +1,72 @@ +"""invitation stage models""" +from typing import Type +from uuid import uuid4 + +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.core.models import User +from authentik.flows.models import Stage + + +class InvitationStage(Stage): + """Simplify enrollment; allow users to use a single + link to create their user with pre-defined parameters.""" + + continue_flow_without_invitation = models.BooleanField( + default=False, + help_text=_( + ( + "If this flag is set, this Stage will jump to the next Stage when " + "no Invitation is given. By default this Stage will cancel the " + "Flow when no invitation is given." + ) + ), + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.invitation.api import InvitationStageSerializer + + return InvitationStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.invitation.stage import InvitationStageView + + return InvitationStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.invitation.forms import InvitationStageForm + + return InvitationStageForm + + def __str__(self): + return f"Invitation Stage {self.name}" + + class Meta: + + verbose_name = _("Invitation Stage") + verbose_name_plural = _("Invitation Stages") + + +class Invitation(models.Model): + """Single-use invitation link""" + + invite_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + expires = models.DateTimeField(default=None, blank=True, null=True) + fixed_data = models.JSONField(default=dict) + + def __str__(self): + return f"Invitation {self.invite_uuid.hex} created by {self.created_by}" + + class Meta: + + verbose_name = _("Invitation") + verbose_name_plural = _("Invitations") diff --git a/authentik/stages/invitation/signals.py b/authentik/stages/invitation/signals.py new file mode 100644 index 00000000..e5402758 --- /dev/null +++ b/authentik/stages/invitation/signals.py @@ -0,0 +1,7 @@ +"""authentik invitation signals""" +from django.core.signals import Signal + +# Arguments: request: HttpRequest, invitation: Invitation +invitation_created = Signal() +# Arguments: request: HttpRequest, invitation: Invitation +invitation_used = Signal() diff --git a/authentik/stages/invitation/stage.py b/authentik/stages/invitation/stage.py new file mode 100644 index 00000000..e3800c12 --- /dev/null +++ b/authentik/stages/invitation/stage.py @@ -0,0 +1,30 @@ +"""invitation stage logic""" +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 + +from authentik.flows.stage import StageView +from authentik.stages.invitation.models import Invitation, InvitationStage +from authentik.stages.invitation.signals import invitation_used +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +INVITATION_TOKEN_KEY = "token" +INVITATION_IN_EFFECT = "invitation_in_effect" + + +class InvitationStageView(StageView): + """Finalise Authentication flow by logging the user in""" + + def get(self, request: HttpRequest) -> HttpResponse: + stage: InvitationStage = self.executor.current_stage + if INVITATION_TOKEN_KEY not in request.GET: + # No Invitation was given, raise error or continue + if stage.continue_flow_without_invitation: + return self.executor.stage_ok() + return self.executor.stage_invalid() + + token = request.GET[INVITATION_TOKEN_KEY] + invite: Invitation = get_object_or_404(Invitation, pk=token) + self.executor.plan.context[PLAN_CONTEXT_PROMPT] = invite.fixed_data + self.executor.plan.context[INVITATION_IN_EFFECT] = True + invitation_used.send(sender=self, request=request, invitation=invite) + return self.executor.stage_ok() diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py new file mode 100644 index 00000000..94f6d3d6 --- /dev/null +++ b/authentik/stages/invitation/tests.py @@ -0,0 +1,132 @@ +"""invitation tests""" +from unittest.mock import MagicMock, patch + +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str +from guardian.shortcuts import get_anonymous_user + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.policies.http import AccessDeniedResponse +from authentik.stages.invitation.forms import InvitationStageForm +from authentik.stages.invitation.models import Invitation, InvitationStage +from authentik.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND + + +class TestUserLoginStage(TestCase): + """Login tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create(username="unittest", email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-invitation", + slug="test-invitation", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = InvitationStage.objects.create(name="invitation") + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_form(self): + """Test Form""" + data = {"name": "test"} + self.assertEqual(InvitationStageForm(data).is_valid(), True) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_without_invitation_fail(self): + """Test without any invitation, continue_flow_without_invitation not set.""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = "django.contrib.auth.backends.ModelBackend" + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + + def test_without_invitation_continue(self): + """Test without any invitation, continue_flow_without_invitation is set.""" + self.stage.continue_flow_without_invitation = True + self.stage.save() + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = "django.contrib.auth.backends.ModelBackend" + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + self.stage.continue_flow_without_invitation = False + self.stage.save() + + def test_with_invitation(self): + """Test with invitation, check data in session""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = "django.contrib.auth.backends.ModelBackend" + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + data = {"foo": "bar"} + invite = Invitation.objects.create( + created_by=get_anonymous_user(), fixed_data=data + ) + + with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): + base_url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + response = self.client.get( + base_url + f"?{INVITATION_TOKEN_KEY}={invite.pk.hex}" + ) + + session = self.client.session + plan: FlowPlan = session[SESSION_KEY_PLAN] + self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) diff --git a/passbook/stages/otp_static/__init__.py b/authentik/stages/otp_static/__init__.py similarity index 100% rename from passbook/stages/otp_static/__init__.py rename to authentik/stages/otp_static/__init__.py diff --git a/authentik/stages/otp_static/api.py b/authentik/stages/otp_static/api.py new file mode 100644 index 00000000..120ab2f1 --- /dev/null +++ b/authentik/stages/otp_static/api.py @@ -0,0 +1,21 @@ +"""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 new file mode 100644 index 00000000..6792aae6 --- /dev/null +++ b/authentik/stages/otp_static/apps.py @@ -0,0 +1,11 @@ +"""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 OTP.Static" + mountpoint = "-/user/otp/static/" diff --git a/authentik/stages/otp_static/forms.py b/authentik/stages/otp_static/forms.py new file mode 100644 index 00000000..2864dfae --- /dev/null +++ b/authentik/stages/otp_static/forms.py @@ -0,0 +1,39 @@ +"""OTP Static forms""" +from django import forms +from django.utils.safestring import mark_safe + +from authentik.stages.otp_static.models import OTPStaticStage + + +class StaticTokenWidget(forms.widgets.Widget): + """Widget to render tokens as multiple labels""" + + def render(self, name, value, attrs=None, renderer=None): + final_string = '
    ' + for token in value: + final_string += f"
  • {token.token}
  • " + final_string += "
" + return mark_safe(final_string) # nosec + + +class SetupForm(forms.Form): + """Form to setup Static OTP""" + + tokens = forms.CharField(widget=StaticTokenWidget, disabled=True, required=False) + + def __init__(self, tokens, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["tokens"].initial = tokens + + +class OTPStaticStageForm(forms.ModelForm): + """OTP Static Stage setup form""" + + class Meta: + + model = OTPStaticStage + fields = ["name", "configure_flow", "token_count"] + + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/otp_static/migrations/0001_initial.py b/authentik/stages/otp_static/migrations/0001_initial.py new file mode 100644 index 00000000..4494d16a --- /dev/null +++ b/authentik/stages/otp_static/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.7 on 2020-06-30 11:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0006_auto_20200629_0857"), + ] + + operations = [ + migrations.CreateModel( + name="OTPStaticStage", + 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", + ), + ), + ("token_count", models.IntegerField(default=6)), + ], + options={ + "verbose_name": "OTP Static Setup Stage", + "verbose_name_plural": "OTP Static Setup Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py b/authentik/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py new file mode 100644 index 00000000..e6f27b20 --- /dev/null +++ b/authentik/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1.1 on 2020-09-24 20:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0013_auto_20200924_1605"), + ("authentik_stages_otp_static", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="otpstaticstage", + name="configure_flow", + field=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", + ), + ), + ] diff --git a/authentik/stages/otp_static/migrations/0003_default_setup_flow.py b/authentik/stages/otp_static/migrations/0003_default_setup_flow.py new file mode 100644 index 00000000..4bf6dac4 --- /dev/null +++ b/authentik/stages/otp_static/migrations/0003_default_setup_flow.py @@ -0,0 +1,48 @@ +# Generated by Django 3.1.1 on 2020-09-25 14:32 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation + + +def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + + OTPStaticStage = apps.get_model("authentik_stages_otp_static", "OTPStaticStage") + + db_alias = schema_editor.connection.alias + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-otp-static-configure", + designation=FlowDesignation.STAGE_CONFIGURATION, + defaults={ + "name": "default-otp-static-configure", + "title": "Setup Static OTP Tokens", + }, + ) + + stage, _ = OTPStaticStage.objects.using(db_alias).update_or_create( + name="default-otp-static-configure", defaults={"token_count": 6} + ) + + 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): + stage.configure_flow = flow + stage.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_otp_static", "0002_otpstaticstage_configure_flow"), + ] + + operations = [ + migrations.RunPython(create_default_setup_flow), + ] diff --git a/passbook/stages/otp_static/migrations/__init__.py b/authentik/stages/otp_static/migrations/__init__.py similarity index 100% rename from passbook/stages/otp_static/migrations/__init__.py rename to authentik/stages/otp_static/migrations/__init__.py diff --git a/authentik/stages/otp_static/models.py b/authentik/stages/otp_static/models.py new file mode 100644 index 00000000..c91ba317 --- /dev/null +++ b/authentik/stages/otp_static/models.py @@ -0,0 +1,50 @@ +"""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/passbook/stages/otp_static/settings.py b/authentik/stages/otp_static/settings.py similarity index 100% rename from passbook/stages/otp_static/settings.py rename to authentik/stages/otp_static/settings.py diff --git a/authentik/stages/otp_static/stage.py b/authentik/stages/otp_static/stage.py new file mode 100644 index 00000000..70124448 --- /dev/null +++ b/authentik/stages/otp_static/stage.py @@ -0,0 +1,62 @@ +"""Static OTP Setup stage""" +from typing import Any, Dict + +from django.http import HttpRequest, HttpResponse +from django.views.generic import FormView +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken +from structlog import get_logger + +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.stages.otp_static.forms import SetupForm +from authentik.stages.otp_static.models import OTPStaticStage + +LOGGER = get_logger() +SESSION_STATIC_DEVICE = "static_device" +SESSION_STATIC_TOKENS = "static_device_tokens" + + +class OTPStaticStageView(FormView, StageView): + """Static OTP Setup stage""" + + form_class = SetupForm + + def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: + kwargs = super().get_form_kwargs(**kwargs) + tokens = self.request.session[SESSION_STATIC_TOKENS] + kwargs["tokens"] = tokens + return kwargs + + def get(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() + + # Currently, this stage only supports one device per user. If the user already + # has a device, just skip to the next stage + if StaticDevice.objects.filter(user=user).exists(): + return self.executor.stage_ok() + + stage: OTPStaticStage = self.executor.current_stage + + if SESSION_STATIC_DEVICE not in self.request.session: + device = StaticDevice(user=user, confirmed=True) + tokens = [] + for _ in range(0, stage.token_count): + tokens.append( + StaticToken(device=device, token=StaticToken.random_token()) + ) + self.request.session[SESSION_STATIC_DEVICE] = device + self.request.session[SESSION_STATIC_TOKENS] = tokens + return super().get(request, *args, **kwargs) + + def form_valid(self, form: SetupForm) -> HttpResponse: + """Verify OTP Token""" + device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE] + device.save() + for token in self.request.session[SESSION_STATIC_TOKENS]: + token.save() + del self.request.session[SESSION_STATIC_DEVICE] + del self.request.session[SESSION_STATIC_TOKENS] + return self.executor.stage_ok() diff --git a/authentik/stages/otp_static/templates/stages/otp_static/user_settings.html b/authentik/stages/otp_static/templates/stages/otp_static/user_settings.html new file mode 100644 index 00000000..6953ebb5 --- /dev/null +++ b/authentik/stages/otp_static/templates/stages/otp_static/user_settings.html @@ -0,0 +1,31 @@ +{% load i18n %} + +
+
+ {% trans "Static One-Time Passwords" %} +
+
+

+ {% blocktrans with state=state|yesno:"Enabled,Disabled" %} + Status: {{ state }} + {% endblocktrans %} + {% if state %} + + {% else %} + + {% endif %} +

+
    + {% for token in tokens %} +
  • {{ token.token }}
  • + {% endfor %} +
+ {% if not state %} + {% if stage.configure_flow %} + {% trans "Enable Static Tokens" %} + {% endif %} + {% else %} + {% trans "Disable Static Tokens" %} + {% endif %} +
+
diff --git a/authentik/stages/otp_static/urls.py b/authentik/stages/otp_static/urls.py new file mode 100644 index 00000000..55ee7807 --- /dev/null +++ b/authentik/stages/otp_static/urls.py @@ -0,0 +1,11 @@ +"""OTP static urls""" +from django.urls import path + +from authentik.stages.otp_static.views import DisableView, UserSettingsView + +urlpatterns = [ + path( + "/settings/", UserSettingsView.as_view(), name="user-settings" + ), + path("/disable/", DisableView.as_view(), name="disable"), +] diff --git a/authentik/stages/otp_static/views.py b/authentik/stages/otp_static/views.py new file mode 100644 index 00000000..0f561d69 --- /dev/null +++ b/authentik/stages/otp_static/views.py @@ -0,0 +1,44 @@ +"""otp Static view Tokens""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.views import View +from django.views.generic import TemplateView +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken + +from authentik.audit.models import Event +from authentik.stages.otp_static.models import OTPStaticStage + + +class UserSettingsView(LoginRequiredMixin, TemplateView): + """View for user settings to control OTP""" + + template_name = "stages/otp_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"]) + kwargs["stage"] = stage + static_devices = StaticDevice.objects.filter( + user=self.request.user, confirmed=True + ) + kwargs["state"] = static_devices.exists() + if static_devices.exists(): + kwargs["tokens"] = StaticToken.objects.filter(device=static_devices.first()) + return kwargs + + +class DisableView(LoginRequiredMixin, View): + """Disable Static Tokens for user""" + + def get(self, request: HttpRequest) -> HttpResponse: + """Delete all the devices for user""" + devices = StaticDevice.objects.filter(user=request.user, confirmed=True) + devices.delete() + messages.success(request, "Successfully disabled Static OTP Tokens") + # Create event with email notification + Event.new( + "static_otp_disable", message="User disabled Static OTP Tokens." + ).from_http(request) + return redirect("authentik_stages_otp:otp-user-settings") diff --git a/passbook/stages/otp_time/__init__.py b/authentik/stages/otp_time/__init__.py similarity index 100% rename from passbook/stages/otp_time/__init__.py rename to authentik/stages/otp_time/__init__.py diff --git a/authentik/stages/otp_time/api.py b/authentik/stages/otp_time/api.py new file mode 100644 index 00000000..7670477d --- /dev/null +++ b/authentik/stages/otp_time/api.py @@ -0,0 +1,21 @@ +"""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 new file mode 100644 index 00000000..2e1056bb --- /dev/null +++ b/authentik/stages/otp_time/apps.py @@ -0,0 +1,11 @@ +"""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 OTP.Time" + mountpoint = "-/user/otp/time/" diff --git a/authentik/stages/otp_time/forms.py b/authentik/stages/otp_time/forms.py new file mode 100644 index 00000000..eb40521b --- /dev/null +++ b/authentik/stages/otp_time/forms.py @@ -0,0 +1,62 @@ +"""OTP Time forms""" +from django import forms +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from django_otp.models import Device + +from authentik.stages.otp_time.models import OTPTimeStage + + +class PictureWidget(forms.widgets.Widget): + """Widget to render value as img-tag""" + + def render(self, name, value, attrs=None, renderer=None): + return mark_safe(f"
{value}") # nosec + + +class SetupForm(forms.Form): + """Form to setup Time-based OTP""" + + device: Device = None + + qr_code = forms.CharField( + widget=PictureWidget, + disabled=True, + required=False, + label=_("Scan this Code with your OTP App."), + ) + code = forms.CharField( + label=_("Please enter the Token on your device."), + widget=forms.TextInput( + attrs={ + "autocomplete": "off", + "placeholder": "Code", + "autofocus": "autofocus", + } + ), + ) + + def __init__(self, device, qr_code, *args, **kwargs): + super().__init__(*args, **kwargs) + self.device = device + self.fields["qr_code"].initial = qr_code + + def clean_code(self): + """Check code with new otp device""" + if self.device is not None: + if not self.device.verify_token(self.cleaned_data.get("code")): + raise forms.ValidationError(_("OTP Code does not match")) + return self.cleaned_data.get("code") + + +class OTPTimeStageForm(forms.ModelForm): + """OTP Time-based Stage setup form""" + + class Meta: + + model = OTPTimeStage + fields = ["name", "configure_flow", "digits"] + + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/otp_time/migrations/0001_initial.py b/authentik/stages/otp_time/migrations/0001_initial.py new file mode 100644 index 00000000..d6a3aa73 --- /dev/null +++ b/authentik/stages/otp_time/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.0.7 on 2020-06-13 15:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0007_auto_20200703_2059"), + ] + + operations = [ + migrations.CreateModel( + name="OTPTimeStage", + 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", + ), + ), + ("digits", models.IntegerField(choices=[(6, "Six"), (8, "Eight")])), + ], + options={ + "verbose_name": "OTP Time (TOTP) Setup Stage", + "verbose_name_plural": "OTP Time (TOTP) Setup Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py b/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py new file mode 100644 index 00000000..9dca4752 --- /dev/null +++ b/authentik/stages/otp_time/migrations/0002_auto_20200701_1900.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-07-01 19:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_otp_time", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="otptimestage", + name="digits", + field=models.IntegerField( + choices=[ + (6, "6 digits, widely compatible"), + (8, "8 digits, not compatible with apps like Google Authenticator"), + ] + ), + ), + ] diff --git a/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py b/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py new file mode 100644 index 00000000..64d09b8b --- /dev/null +++ b/authentik/stages/otp_time/migrations/0003_otptimestage_configure_flow.py @@ -0,0 +1,26 @@ +# Generated by Django 3.1.1 on 2020-09-25 10:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0013_auto_20200924_1605"), + ("authentik_stages_otp_time", "0002_auto_20200701_1900"), + ] + + operations = [ + migrations.AddField( + model_name="otptimestage", + name="configure_flow", + field=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", + ), + ), + ] diff --git a/authentik/stages/otp_time/migrations/0004_default_setup_flow.py b/authentik/stages/otp_time/migrations/0004_default_setup_flow.py new file mode 100644 index 00000000..9b5df159 --- /dev/null +++ b/authentik/stages/otp_time/migrations/0004_default_setup_flow.py @@ -0,0 +1,49 @@ +# Generated by Django 3.1.1 on 2020-09-25 15:36 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation +from authentik.stages.otp_time.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") + + db_alias = schema_editor.connection.alias + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-otp-time-configure", + designation=FlowDesignation.STAGE_CONFIGURATION, + defaults={ + "name": "default-otp-time-configure", + "title": "Setup Two-Factor authentication", + }, + ) + + stage, _ = OTPTimeStage.objects.using(db_alias).update_or_create( + name="default-otp-time-configure", 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): + stage.configure_flow = flow + stage.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_otp_time", "0003_otptimestage_configure_flow"), + ] + + operations = [ + migrations.RunPython(create_default_setup_flow), + ] diff --git a/passbook/stages/otp_time/migrations/__init__.py b/authentik/stages/otp_time/migrations/__init__.py similarity index 100% rename from passbook/stages/otp_time/migrations/__init__.py rename to authentik/stages/otp_time/migrations/__init__.py diff --git a/authentik/stages/otp_time/models.py b/authentik/stages/otp_time/models.py new file mode 100644 index 00000000..08a54e39 --- /dev/null +++ b/authentik/stages/otp_time/models.py @@ -0,0 +1,57 @@ +"""OTP Time-based 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 TOTPDigits(models.IntegerChoices): + """OTP Time Digits""" + + SIX = 6, _("6 digits, widely compatible") + EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator") + + +class OTPTimeStage(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 + + return OTPTimeStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.otp_time.stage import OTPTimeStageView + + return OTPTimeStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.otp_time.forms import OTPTimeStageForm + + return OTPTimeStageForm + + @property + def ui_user_settings(self) -> Optional[str]: + return reverse( + "authentik_stages_otp_time:user-settings", + kwargs={"stage_uuid": self.stage_uuid}, + ) + + def __str__(self) -> str: + return f"OTP Time (TOTP) Stage {self.name}" + + class Meta: + + verbose_name = _("OTP Time (TOTP) Setup Stage") + verbose_name_plural = _("OTP Time (TOTP) Setup Stages") diff --git a/authentik/stages/otp_time/settings.py b/authentik/stages/otp_time/settings.py new file mode 100644 index 00000000..1ec213c6 --- /dev/null +++ b/authentik/stages/otp_time/settings.py @@ -0,0 +1,6 @@ +"""OTP Time""" + +INSTALLED_APPS = [ + "django_otp.plugins.otp_totp", +] +OTP_TOTP_ISSUER = "authentik" diff --git a/authentik/stages/otp_time/stage.py b/authentik/stages/otp_time/stage.py new file mode 100644 index 00000000..ebdb5efd --- /dev/null +++ b/authentik/stages/otp_time/stage.py @@ -0,0 +1,66 @@ +"""TOTP Setup stage""" +from typing import Any, Dict + +from django.http import HttpRequest, HttpResponse +from django.utils.encoding import force_str +from django.views.generic import FormView +from django_otp.plugins.otp_totp.models import TOTPDevice +from lxml.etree import tostring # nosec +from qrcode import QRCode +from qrcode.image.svg import SvgFillImage +from structlog 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 + +LOGGER = get_logger() +SESSION_TOTP_DEVICE = "totp_device" + + +class OTPTimeStageView(FormView, StageView): + """OTP totp Setup stage""" + + form_class = SetupForm + + def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: + kwargs = super().get_form_kwargs(**kwargs) + device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] + kwargs["device"] = device + kwargs["qr_code"] = self._get_qr_code(device) + return kwargs + + def _get_qr_code(self, device: TOTPDevice) -> str: + """Get QR Code SVG as string based on `device`""" + qr_code = QRCode(image_factory=SvgFillImage) + qr_code.add_data(device.config_url) + svg_image = tostring(qr_code.make_image().get_image()) + sr_wrapper = f'
{force_str(svg_image)}
' + return sr_wrapper + + 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() + + # Currently, this stage only supports one device per user. If the user already + # has a device, just skip to the next stage + if TOTPDevice.objects.filter(user=user).exists(): + return self.executor.stage_ok() + + stage: OTPTimeStage = self.executor.current_stage + + if SESSION_TOTP_DEVICE not in self.request.session: + device = TOTPDevice(user=user, confirmed=True, digits=stage.digits) + + self.request.session[SESSION_TOTP_DEVICE] = device + return super().get(request, *args, **kwargs) + + def form_valid(self, form: SetupForm) -> HttpResponse: + """Verify OTP Token""" + device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] + device.save() + del self.request.session[SESSION_TOTP_DEVICE] + return self.executor.stage_ok() diff --git a/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html b/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html new file mode 100644 index 00000000..5a351f6a --- /dev/null +++ b/authentik/stages/otp_time/templates/stages/otp_time/user_settings.html @@ -0,0 +1,28 @@ +{% load i18n %} + +
+
+ {% trans "Time-based One-Time Passwords" %} +
+
+

+ {% blocktrans with state=state|yesno:"Enabled,Disabled" %} + Status: {{ state }} + {% endblocktrans %} + {% if state %} + + {% else %} + + {% endif %} +

+

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

+
+
diff --git a/authentik/stages/otp_time/urls.py b/authentik/stages/otp_time/urls.py new file mode 100644 index 00000000..3570fc8d --- /dev/null +++ b/authentik/stages/otp_time/urls.py @@ -0,0 +1,11 @@ +"""OTP Time urls""" +from django.urls import path + +from authentik.stages.otp_time.views import DisableView, UserSettingsView + +urlpatterns = [ + path( + "/settings/", UserSettingsView.as_view(), name="user-settings" + ), + path("/disable/", DisableView.as_view(), name="disable"), +] diff --git a/authentik/stages/otp_time/views.py b/authentik/stages/otp_time/views.py new file mode 100644 index 00000000..813f7650 --- /dev/null +++ b/authentik/stages/otp_time/views.py @@ -0,0 +1,41 @@ +"""otp time-based view""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.views import View +from django.views.generic import TemplateView +from django_otp.plugins.otp_totp.models import TOTPDevice + +from authentik.audit.models import Event +from authentik.stages.otp_time.models import OTPTimeStage + + +class UserSettingsView(LoginRequiredMixin, TemplateView): + """View for user settings to control OTP""" + + template_name = "stages/otp_time/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"]) + kwargs["stage"] = stage + + totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True) + kwargs["state"] = totp_devices.exists() + return kwargs + + +class DisableView(LoginRequiredMixin, View): + """Disable TOTP for user""" + + def get(self, request: HttpRequest) -> HttpResponse: + """Delete all the devices for user""" + totp = TOTPDevice.objects.filter(user=request.user, confirmed=True) + totp.delete() + messages.success(request, "Successfully disabled Time-based OTP") + # Create event with email notification + Event.new("totp_disable", message="User disabled Time-based OTP.").from_http( + request + ) + return redirect("authentik_stages_otp:otp-user-settings") diff --git a/passbook/stages/otp_validate/__init__.py b/authentik/stages/otp_validate/__init__.py similarity index 100% rename from passbook/stages/otp_validate/__init__.py rename to authentik/stages/otp_validate/__init__.py diff --git a/authentik/stages/otp_validate/api.py b/authentik/stages/otp_validate/api.py new file mode 100644 index 00000000..6d922293 --- /dev/null +++ b/authentik/stages/otp_validate/api.py @@ -0,0 +1,24 @@ +"""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 new file mode 100644 index 00000000..05847379 --- /dev/null +++ b/authentik/stages/otp_validate/apps.py @@ -0,0 +1,10 @@ +"""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 OTP.Validate" diff --git a/authentik/stages/otp_validate/forms.py b/authentik/stages/otp_validate/forms.py new file mode 100644 index 00000000..a8d1f04b --- /dev/null +++ b/authentik/stages/otp_validate/forms.py @@ -0,0 +1,49 @@ +"""OTP Validate stage forms""" +from django import forms +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 + + +class ValidationForm(forms.Form): + """OTP Validate stage forms""" + + user: User + + code = forms.CharField( + label=_("Please enter the token from your device."), + widget=forms.TextInput( + attrs={ + "autocomplete": "off", + "placeholder": "123456", + "autofocus": "autofocus", + } + ), + ) + + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + + def clean_code(self): + """Validate code against all confirmed devices""" + code = self.cleaned_data.get("code") + device = match_token(self.user, code) + if not device: + raise forms.ValidationError(_("Invalid Token")) + return code + + +class OTPValidateStageForm(forms.ModelForm): + """OTP Validate stage forms""" + + class Meta: + + model = OTPValidateStage + fields = ["name"] + + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/otp_validate/migrations/0001_initial.py b/authentik/stages/otp_validate/migrations/0001_initial.py new file mode 100644 index 00000000..140b208c --- /dev/null +++ b/authentik/stages/otp_validate/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 3.0.7 on 2020-06-13 15:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0007_auto_20200703_2059"), + ] + + operations = [ + migrations.CreateModel( + name="OTPValidateStage", + 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", + ), + ), + ( + "not_configured_action", + models.TextField(choices=[("skip", "Skip")], default="skip"), + ), + ], + options={ + "verbose_name": "OTP Validation Stage", + "verbose_name_plural": "OTP Validation Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/passbook/stages/otp_validate/migrations/__init__.py b/authentik/stages/otp_validate/migrations/__init__.py similarity index 100% rename from passbook/stages/otp_validate/migrations/__init__.py rename to authentik/stages/otp_validate/migrations/__init__.py diff --git a/authentik/stages/otp_validate/models.py b/authentik/stages/otp_validate/models.py new file mode 100644 index 00000000..33e58988 --- /dev/null +++ b/authentik/stages/otp_validate/models.py @@ -0,0 +1,44 @@ +"""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/passbook/stages/otp_validate/settings.py b/authentik/stages/otp_validate/settings.py similarity index 100% rename from passbook/stages/otp_validate/settings.py rename to authentik/stages/otp_validate/settings.py diff --git a/authentik/stages/otp_validate/stage.py b/authentik/stages/otp_validate/stage.py new file mode 100644 index 00000000..c4dba0a0 --- /dev/null +++ b/authentik/stages/otp_validate/stage.py @@ -0,0 +1,46 @@ +"""OTP Validation""" +from typing import Any, Dict + +from django.http import HttpRequest, HttpResponse +from django.views.generic import FormView +from django_otp import user_has_device +from structlog 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 + +LOGGER = get_logger() + + +class OTPValidateStageView(FormView, StageView): + """OTP Validation""" + + form_class = ValidationForm + + def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: + kwargs = super().get_form_kwargs(**kwargs) + kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) + return kwargs + + 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() + has_devices = user_has_device(user) + stage: OTPValidateStage = self.executor.current_stage + + if not has_devices: + if stage.not_configured_action == NotConfiguredAction.SKIP: + LOGGER.debug("OTP not configured, skipping stage") + return self.executor.stage_ok() + return super().get(request, *args, **kwargs) + + def form_valid(self, form: ValidationForm) -> HttpResponse: + """Verify OTP Token""" + # Since we do token checking in the form, we know the token is valid here + # so we can just continue + return self.executor.stage_ok() diff --git a/passbook/stages/password/__init__.py b/authentik/stages/password/__init__.py similarity index 100% rename from passbook/stages/password/__init__.py rename to authentik/stages/password/__init__.py diff --git a/authentik/stages/password/api.py b/authentik/stages/password/api.py new file mode 100644 index 00000000..edce69f3 --- /dev/null +++ b/authentik/stages/password/api.py @@ -0,0 +1,27 @@ +"""PasswordStage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.password.models import PasswordStage + + +class PasswordStageSerializer(ModelSerializer): + """PasswordStage Serializer""" + + class Meta: + + model = PasswordStage + fields = [ + "pk", + "name", + "backends", + "configure_flow", + "failed_attempts_before_cancel", + ] + + +class PasswordStageViewSet(ModelViewSet): + """PasswordStage Viewset""" + + queryset = PasswordStage.objects.all() + serializer_class = PasswordStageSerializer diff --git a/authentik/stages/password/apps.py b/authentik/stages/password/apps.py new file mode 100644 index 00000000..2e933bc5 --- /dev/null +++ b/authentik/stages/password/apps.py @@ -0,0 +1,11 @@ +"""authentik core app config""" +from django.apps import AppConfig + + +class AuthentikStagePasswordConfig(AppConfig): + """authentik password stage config""" + + name = "authentik.stages.password" + label = "authentik_stages_password" + verbose_name = "authentik Stages.Password" + mountpoint = "-/user/password/" diff --git a/authentik/stages/password/forms.py b/authentik/stages/password/forms.py new file mode 100644 index 00000000..9d8902cf --- /dev/null +++ b/authentik/stages/password/forms.py @@ -0,0 +1,57 @@ +"""authentik administration forms""" +from django import forms +from django.utils.translation import gettext_lazy as _ + +from authentik.flows.models import Flow, FlowDesignation +from authentik.stages.password.models import PasswordStage + + +def get_authentication_backends(): + """Return all available authentication backends as tuple set""" + return [ + ( + "django.contrib.auth.backends.ModelBackend", + _("authentik-internal Userdatabase"), + ), + ( + "authentik.sources.ldap.auth.LDAPBackend", + _("authentik LDAP"), + ), + ] + + +class PasswordForm(forms.Form): + """Password authentication form""" + + username = forms.CharField( + widget=forms.HiddenInput(attrs={"autocomplete": "username"}), required=False + ) + password = forms.CharField( + label=_("Please enter your password."), + widget=forms.PasswordInput( + attrs={ + "placeholder": _("Password"), + "autofocus": "autofocus", + "autocomplete": "current-password", + } + ), + ) + + +class PasswordStageForm(forms.ModelForm): + """Form to create/edit Password Stages""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["configure_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.STAGE_CONFIGURATION + ) + + class Meta: + + model = PasswordStage + fields = ["name", "backends", "configure_flow", "failed_attempts_before_cancel"] + widgets = { + "name": forms.TextInput(), + "backends": forms.SelectMultiple(get_authentication_backends()), + } diff --git a/authentik/stages/password/migrations/0001_initial.py b/authentik/stages/password/migrations/0001_initial.py new file mode 100644 index 00000000..45b85272 --- /dev/null +++ b/authentik/stages/password/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PasswordStage", + 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", + ), + ), + ( + "backends", + django.contrib.postgres.fields.ArrayField( + base_field=models.TextField(), + help_text="Selection of backends to test the password against.", + size=None, + ), + ), + ], + options={ + "verbose_name": "Password Stage", + "verbose_name_plural": "Password Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/password/migrations/0002_passwordstage_change_flow.py b/authentik/stages/password/migrations/0002_passwordstage_change_flow.py new file mode 100644 index 00000000..025aa1d3 --- /dev/null +++ b/authentik/stages/password/migrations/0002_passwordstage_change_flow.py @@ -0,0 +1,109 @@ +# Generated by Django 3.0.7 on 2020-06-29 08:51 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation +from authentik.stages.prompt.models import FieldTypes + + +def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + + PromptStage = apps.get_model("authentik_stages_prompt", "PromptStage") + Prompt = apps.get_model("authentik_stages_prompt", "Prompt") + + UserWriteStage = apps.get_model("authentik_stages_user_write", "UserWriteStage") + + db_alias = schema_editor.connection.alias + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-password-change", + designation=FlowDesignation.STAGE_CONFIGURATION, + defaults={"name": "Change Password"}, + ) + + prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( + name="Change your password", + ) + password_prompt, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="password", + defaults={ + "label": "Password", + "type": FieldTypes.PASSWORD, + "required": True, + "placeholder": "Password", + "order": 0, + }, + ) + password_rep_prompt, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="password_repeat", + defaults={ + "label": "Password (repeat)", + "type": FieldTypes.PASSWORD, + "required": True, + "placeholder": "Password (repeat)", + "order": 1, + }, + ) + + prompt_stage.fields.add(password_prompt) + prompt_stage.fields.add(password_rep_prompt) + prompt_stage.save() + + user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( + name="default-password-change-write" + ) + + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, stage=prompt_stage, defaults={"order": 0} + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, stage=user_write, defaults={"order": 1} + ) + + +def update_default_stage_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + PasswordStage = apps.get_model("authentik_stages_password", "PasswordStage") + Flow = apps.get_model("authentik_flows", "Flow") + + flow = Flow.objects.get( + slug="default-password-change", + designation=FlowDesignation.STAGE_CONFIGURATION, + ) + + stages = PasswordStage.objects.filter(name="default-authentication-password") + if not stages.exists(): + return + stage = stages.first() + stage.change_flow = flow + stage.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0006_auto_20200629_0857"), + ("authentik_stages_password", "0001_initial"), + ("authentik_stages_prompt", "0001_initial"), + ("authentik_stages_user_write", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="passwordstage", + name="change_flow", + field=models.ForeignKey( + blank=True, + help_text="Flow used by an authenticated user to change their password. If empty, user will be unable to change their password.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_flows.Flow", + ), + ), + migrations.RunPython(create_default_password_change), + migrations.RunPython(update_default_stage_change), + ] diff --git a/authentik/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py b/authentik/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py new file mode 100644 index 00000000..c5321f99 --- /dev/null +++ b/authentik/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.1 on 2020-09-18 23:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_password", "0002_passwordstage_change_flow"), + ] + + operations = [ + migrations.AddField( + model_name="passwordstage", + name="failed_attempts_before_cancel", + field=models.IntegerField( + default=5, + help_text="How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage.", + ), + ), + ] diff --git a/authentik/stages/password/migrations/0004_auto_20200925_1057.py b/authentik/stages/password/migrations/0004_auto_20200925_1057.py new file mode 100644 index 00000000..2ac29f24 --- /dev/null +++ b/authentik/stages/password/migrations/0004_auto_20200925_1057.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.1 on 2020-09-25 10:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0013_auto_20200924_1605"), + ( + "authentik_stages_password", + "0003_passwordstage_failed_attempts_before_cancel", + ), + ] + + operations = [ + migrations.RenameField( + model_name="passwordstage", + old_name="change_flow", + new_name="configure_flow", + ), + migrations.AlterField( + model_name="passwordstage", + name="configure_flow", + field=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", + ), + ), + ] diff --git a/passbook/stages/password/migrations/__init__.py b/authentik/stages/password/migrations/__init__.py similarity index 100% rename from passbook/stages/password/migrations/__init__.py rename to authentik/stages/password/migrations/__init__.py diff --git a/authentik/stages/password/models.py b/authentik/stages/password/models.py new file mode 100644 index 00000000..43e4a627 --- /dev/null +++ b/authentik/stages/password/models.py @@ -0,0 +1,64 @@ +"""password stage models""" +from typing import Optional, Type + +from django.contrib.postgres.fields import ArrayField +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 PasswordStage(ConfigurableStage, Stage): + """Prompts the user for their password, and validates it against the configured backends.""" + + backends = ArrayField( + models.TextField(), + help_text=_("Selection of backends to test the password against."), + ) + failed_attempts_before_cancel = models.IntegerField( + default=5, + help_text=_( + ( + "How many attempts a user has before the flow is canceled. " + "To lock the user out, use a reputation policy and a user_write stage." + ) + ), + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.password.api import PasswordStageSerializer + + return PasswordStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.password.stage import PasswordStageView + + return PasswordStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.password.forms import PasswordStageForm + + return PasswordStageForm + + @property + def ui_user_settings(self) -> Optional[str]: + if not self.configure_flow: + return None + return reverse( + "authentik_stages_password:user-settings", kwargs={"stage_uuid": self.pk} + ) + + def __str__(self): + return f"Password Stage {self.name}" + + class Meta: + + verbose_name = _("Password Stage") + verbose_name_plural = _("Password Stages") diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py new file mode 100644 index 00000000..315d274f --- /dev/null +++ b/authentik/stages/password/stage.py @@ -0,0 +1,123 @@ +"""authentik password stage""" +from typing import Any, Dict, List, Optional + +from django.contrib.auth import _clean_credentials +from django.contrib.auth.backends import BaseBackend +from django.contrib.auth.signals import user_login_failed +from django.core.exceptions import PermissionDenied +from django.forms.utils import ErrorList +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from structlog import get_logger + +from authentik.core.models import User +from authentik.flows.models import Flow, FlowDesignation +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.lib.utils.reflection import path_to_class +from authentik.stages.password.forms import PasswordForm +from authentik.stages.password.models import PasswordStage + +LOGGER = get_logger() +PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend" +SESSION_INVALID_TRIES = "user_invalid_tries" + + +def authenticate( + request: HttpRequest, backends: List[str], **credentials: Dict[str, Any] +) -> Optional[User]: + """If the given credentials are valid, return a User object. + + Customized version of django's authenticate, which accepts a list of backends""" + for backend_path in backends: + backend: BaseBackend = path_to_class(backend_path)() + LOGGER.debug("Attempting authentication...", backend=backend) + user = backend.authenticate(request, **credentials) + if user is None: + LOGGER.debug("Backend returned nothing, continuing") + continue + # Annotate the user object with the path of the backend. + user.backend = backend_path + LOGGER.debug("Successful authentication", user=user, backend=backend) + return user + + # The credentials supplied are invalid to all backends, fire signal + user_login_failed.send( + sender=__name__, credentials=_clean_credentials(credentials), request=request + ) + + +class PasswordStageView(FormView, StageView): + """Authentication stage which authenticates against django's AuthBackend""" + + form_class = PasswordForm + template_name = "stages/password/flow-form.html" + + def get_form(self, form_class=None) -> PasswordForm: + form = super().get_form(form_class=form_class) + + # If there's a pending user, update the `username` field + # this field is only used by password managers. + # If there's no user set, an error is raised later. + if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: + pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + form.fields["username"].initial = pending_user.username + + return form + + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY) + if recovery_flow.exists(): + kwargs["recovery_flow"] = recovery_flow.first() + return kwargs + + def form_invalid(self, form: PasswordForm) -> HttpResponse: + if SESSION_INVALID_TRIES not in self.request.session: + self.request.session[SESSION_INVALID_TRIES] = 0 + self.request.session[SESSION_INVALID_TRIES] += 1 + current_stage: PasswordStage = self.executor.current_stage + if ( + self.request.session[SESSION_INVALID_TRIES] + > current_stage.failed_attempts_before_cancel + ): + LOGGER.debug("User has exceeded maximum tries") + del self.request.session[SESSION_INVALID_TRIES] + return self.executor.stage_invalid() + return super().form_invalid(form) + + def form_valid(self, form: PasswordForm) -> HttpResponse: + """Authenticate against django's authentication backend""" + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + return self.executor.stage_invalid() + # Get the pending user's username, which is used as + # an Identifier by most authentication backends + pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + auth_kwargs = { + "password": form.cleaned_data.get("password", None), + "username": pending_user.username, + } + try: + user = authenticate( + self.request, self.executor.current_stage.backends, **auth_kwargs + ) + except PermissionDenied: + del auth_kwargs["password"] + # User was found, but permission was denied (i.e. user is not active) + LOGGER.debug("Denied access", **auth_kwargs) + return self.executor.stage_invalid() + else: + if not user: + # No user was found -> invalid credentials + LOGGER.debug("Invalid credentials") + # Manually inject error into form + errors = form._errors.setdefault("password", ErrorList()) + errors.append(_("Invalid password")) + return self.form_invalid(form) + # User instance returned from authenticate() has .backend property set + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user + self.executor.plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = user.backend + return self.executor.stage_ok() diff --git a/authentik/stages/password/templates/stages/password/flow-form.html b/authentik/stages/password/templates/stages/password/flow-form.html new file mode 100644 index 00000000..b0cece8d --- /dev/null +++ b/authentik/stages/password/templates/stages/password/flow-form.html @@ -0,0 +1,10 @@ +{% extends 'login/form_with_user.html' %} + +{% load i18n %} +{% load authentik_utils %} + +{% block beneath_form %} +{% if recovery_flow %} +{% trans 'Forgot password?' %} +{% endif %} +{% endblock %} diff --git a/authentik/stages/password/templates/stages/password/user-settings-card.html b/authentik/stages/password/templates/stages/password/user-settings-card.html new file mode 100644 index 00000000..85815bf1 --- /dev/null +++ b/authentik/stages/password/templates/stages/password/user-settings-card.html @@ -0,0 +1,17 @@ +{% extends "base/page.html" %} + +{% load i18n %} +{% load authentik_utils %} + +{% block body %} +
+
+ {% trans 'Reset your password' %} +
+ +
+{% endblock %} diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py new file mode 100644 index 00000000..ed604f45 --- /dev/null +++ b/authentik/stages/password/tests.py @@ -0,0 +1,195 @@ +"""password tests""" +import string +from random import SystemRandom +from unittest.mock import MagicMock, patch + +from django.core.exceptions import PermissionDenied +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.policies.http import AccessDeniedResponse +from authentik.stages.password.models import PasswordStage + +MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test")) + + +class TestPasswordStage(TestCase): + """Password tests""" + + def setUp(self): + super().setUp() + self.password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + self.user = User.objects.create_user( + username="unittest", email="test@beryju.org", password=self.password + ) + self.client = Client() + + self.flow = Flow.objects.create( + name="test-password", + slug="test-password", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = PasswordStage.objects.create( + name="password", backends=["django.contrib.auth.backends.ModelBackend"] + ) + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_without_user(self): + """Test without user""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + # Still have to send the password so the form is valid + {"password": self.password}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + + def test_recovery_flow_link(self): + """Test link to the default recovery flow""" + flow = Flow.objects.create( + designation=FlowDesignation.RECOVERY, slug="qewrqerqr" + ) + + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + ) + self.assertEqual(response.status_code, 200) + self.assertIn(flow.slug, force_str(response.content)) + + def test_valid_password(self): + """Test with a valid pending user and valid password""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + # Form data + {"password": self.password}, + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + def test_invalid_password(self): + """Test with a valid pending user and invalid password""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + # Form data + {"password": self.password + "test"}, + ) + self.assertEqual(response.status_code, 200) + + def test_invalid_password_lockout(self): + """Test with a valid pending user and invalid password (trigger logout counter)""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + for _ in range(self.stage.failed_attempts_before_cancel): + response = self.client.post( + reverse( + "authentik_flows:flow-executor", + kwargs={"flow_slug": self.flow.slug}, + ), + # Form data + {"password": self.password + "test"}, + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + # Form data + {"password": self.password + "test"}, + ) + self.assertEqual(response.status_code, 200) + # To ensure the plan has been cancelled, check SESSION_KEY_PLAN + self.assertNotIn(SESSION_KEY_PLAN, self.client.session) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + @patch( + "django.contrib.auth.backends.ModelBackend.authenticate", + MOCK_BACKEND_AUTHENTICATE, + ) + def test_permission_denied(self): + """Test with a valid pending user and valid password. + Backend is patched to return PermissionError""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + # Form data + {"password": self.password + "test"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) diff --git a/authentik/stages/password/urls.py b/authentik/stages/password/urls.py new file mode 100644 index 00000000..f6c254e9 --- /dev/null +++ b/authentik/stages/password/urls.py @@ -0,0 +1,12 @@ +"""Password stage urls""" +from django.urls import path + +from authentik.stages.password.views import UserSettingsCardView + +urlpatterns = [ + path( + "/change-card/", + UserSettingsCardView.as_view(), + name="user-settings", + ), +] diff --git a/authentik/stages/password/views.py b/authentik/stages/password/views.py new file mode 100644 index 00000000..1808781a --- /dev/null +++ b/authentik/stages/password/views.py @@ -0,0 +1,26 @@ +"""password stage user settings card""" +from typing import Any + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.shortcuts import reverse +from django.utils.http import urlencode +from django.views.generic import TemplateView + +from authentik.flows.views import NEXT_ARG_NAME + + +class UserSettingsCardView(LoginRequiredMixin, TemplateView): + """Card shown on user settings page to allow user to change their password""" + + template_name = "stages/password/user-settings-card.html" + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + base_url = reverse( + "authentik_flows:configure", + kwargs={"stage_uuid": self.kwargs["stage_uuid"]}, + ) + args = urlencode({NEXT_ARG_NAME: reverse("authentik_core:user-settings")}) + + kwargs = super().get_context_data(**kwargs) + kwargs["url"] = f"{base_url}?{args}" + return kwargs diff --git a/passbook/stages/prompt/__init__.py b/authentik/stages/prompt/__init__.py similarity index 100% rename from passbook/stages/prompt/__init__.py rename to authentik/stages/prompt/__init__.py diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py new file mode 100644 index 00000000..d5618c9b --- /dev/null +++ b/authentik/stages/prompt/api.py @@ -0,0 +1,53 @@ +"""Prompt Stage API Views""" +from rest_framework.serializers import CharField, ModelSerializer +from rest_framework.validators import UniqueValidator +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.prompt.models import Prompt, PromptStage + + +class PromptStageSerializer(ModelSerializer): + """PromptStage Serializer""" + + name = CharField(validators=[UniqueValidator(queryset=PromptStage.objects.all())]) + + class Meta: + + model = PromptStage + fields = [ + "pk", + "name", + "fields", + "validation_policies", + ] + + +class PromptStageViewSet(ModelViewSet): + """PromptStage Viewset""" + + queryset = PromptStage.objects.all() + serializer_class = PromptStageSerializer + + +class PromptSerializer(ModelSerializer): + """Prompt Serializer""" + + class Meta: + + model = Prompt + fields = [ + "pk", + "field_key", + "label", + "type", + "required", + "placeholder", + "order", + ] + + +class PromptViewSet(ModelViewSet): + """Prompt Viewset""" + + queryset = Prompt.objects.all() + serializer_class = PromptSerializer diff --git a/authentik/stages/prompt/apps.py b/authentik/stages/prompt/apps.py new file mode 100644 index 00000000..9280f6ac --- /dev/null +++ b/authentik/stages/prompt/apps.py @@ -0,0 +1,10 @@ +"""authentik prompt stage app config""" +from django.apps import AppConfig + + +class AuthentikStagPromptConfig(AppConfig): + """authentik prompt stage config""" + + name = "authentik.stages.prompt" + label = "authentik_stages_prompt" + verbose_name = "authentik Stages.Prompt" diff --git a/authentik/stages/prompt/forms.py b/authentik/stages/prompt/forms.py new file mode 100644 index 00000000..58859915 --- /dev/null +++ b/authentik/stages/prompt/forms.py @@ -0,0 +1,157 @@ +"""Prompt forms""" +from email.policy import Policy +from types import MethodType +from typing import Any, Callable, Iterator, List + +from django import forms +from django.db.models.query import QuerySet +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ +from guardian.shortcuts import get_anonymous_user + +from authentik.core.models import User +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.policies.engine import PolicyEngine +from authentik.policies.models import PolicyBinding, PolicyBindingModel +from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage +from authentik.stages.prompt.signals import password_validate + + +class PromptStageForm(forms.ModelForm): + """Form to create/edit Prompt Stage instances""" + + class Meta: + + model = PromptStage + fields = ["name", "fields", "validation_policies"] + widgets = { + "name": forms.TextInput(), + } + + +class PromptAdminForm(forms.ModelForm): + """Form to edit Prompt instances for admins""" + + class Meta: + + model = Prompt + fields = [ + "field_key", + "label", + "type", + "required", + "placeholder", + "order", + ] + widgets = { + "label": forms.TextInput(), + "placeholder": forms.TextInput(), + } + + +class ListPolicyEngine(PolicyEngine): + """Slightly modified policy engine, which uses a list instead of a PolicyBindingModel""" + + __list: List[Policy] + + def __init__( + self, policies: List[Policy], user: User, request: HttpRequest = None + ) -> None: + super().__init__(PolicyBindingModel(), user, request) + self.__list = policies + self.use_cache = False + + def _iter_bindings(self) -> Iterator[PolicyBinding]: + for policy in self.__list: + yield PolicyBinding( + policy=policy, + ) + + +class PromptForm(forms.Form): + """Dynamically created form based on PromptStage""" + + stage: PromptStage + plan: FlowPlan + + def __init__(self, stage: PromptStage, plan: FlowPlan, *args, **kwargs): + self.stage = stage + self.plan = plan + super().__init__(*args, **kwargs) + # list() is called so we only load the fields once + fields = list(self.stage.fields.all()) + for field in fields: + field: Prompt + self.fields[field.field_key] = field.field + # Special handling for fields with username type + # these check for existing users with the same username + if field.type == FieldTypes.USERNAME: + setattr( + self, + f"clean_{field.field_key}", + MethodType(username_field_cleaner_factory(field), self), + ) + # Check if we have a password field, add a handler that sends a signal + # to validate it + if field.type == FieldTypes.PASSWORD: + setattr( + self, + f"clean_{field.field_key}", + MethodType(password_single_cleaner_factory(field), self), + ) + + self.field_order = sorted(fields, key=lambda x: x.order) + + def _clean_password_fields(self, *field_names): + """Check if the value of all password fields match by merging them into a set + and checking the length""" + all_passwords = {self.cleaned_data[x] for x in field_names} + if len(all_passwords) > 1: + raise forms.ValidationError(_("Passwords don't match.")) + + def clean(self): + cleaned_data = super().clean() + if cleaned_data == {}: + return {} + # Check if we have two password fields, and make sure they are the same + password_fields: QuerySet[Prompt] = self.stage.fields.filter( + type=FieldTypes.PASSWORD + ) + if password_fields.exists() and password_fields.count() == 2: + self._clean_password_fields(*[field.field_key for field in password_fields]) + + user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user()) + engine = ListPolicyEngine(self.stage.validation_policies.all(), user) + engine.request.context = cleaned_data + engine.build() + result = engine.result + if not result.passing: + raise forms.ValidationError(list(result.messages)) + return cleaned_data + + +def username_field_cleaner_factory(field: Prompt) -> Callable: + """Return a `clean_` method for `field`. Clean method checks if username is taken already.""" + + def username_field_cleaner(self: PromptForm) -> Any: + """Check for duplicate usernames""" + username = self.cleaned_data.get(field.field_key) + if User.objects.filter(username=username).exists(): + raise forms.ValidationError("Username is already taken.") + return username + + return username_field_cleaner + + +def password_single_cleaner_factory(field: Prompt) -> Callable[[PromptForm], Any]: + """Return a `clean_` method for `field`. Clean method checks if username is taken already.""" + + def password_single_clean(self: PromptForm) -> Any: + """Send password validation signals for e.g. LDAP Source""" + password = self.cleaned_data[field.field_key] + password_validate.send( + sender=self, password=password, plan_context=self.plan.context + ) + return password + + return password_single_clean diff --git a/authentik/stages/prompt/migrations/0001_initial.py b/authentik/stages/prompt/migrations/0001_initial.py new file mode 100644 index 00000000..91201c00 --- /dev/null +++ b/authentik/stages/prompt/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 3.1.1 on 2020-09-09 08:40 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0007_auto_20200703_2059"), + ("authentik_policies", "0003_auto_20200908_1542"), + ] + + operations = [ + migrations.CreateModel( + name="Prompt", + fields=[ + ( + "prompt_uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "field_key", + models.SlugField( + help_text="Name of the form field, also used to store the value" + ), + ), + ("label", models.TextField()), + ( + "type", + models.CharField( + choices=[ + ("text", "Text: Simple Text input"), + ( + "username", + "Username: Same as Text input, but checks for and prevents duplicate usernames.", + ), + ("email", "Email: Text field with Email type."), + ("password", "Password"), + ("number", "Number"), + ("checkbox", "Checkbox"), + ("data", "Date"), + ("data-time", "Date Time"), + ("separator", "Separator: Static Separator Line"), + ( + "hidden", + "Hidden: Hidden field, can be used to insert data into form.", + ), + ("static", "Static: Static value, displayed as-is."), + ], + max_length=100, + ), + ), + ("required", models.BooleanField(default=True)), + ("placeholder", models.TextField(blank=True)), + ("order", models.IntegerField(default=0)), + ], + options={ + "verbose_name": "Prompt", + "verbose_name_plural": "Prompts", + }, + ), + migrations.CreateModel( + name="PromptStage", + 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", + ), + ), + ("fields", models.ManyToManyField(to="authentik_stages_prompt.Prompt")), + ( + "validation_policies", + models.ManyToManyField(blank=True, to="authentik_policies.Policy"), + ), + ], + options={ + "verbose_name": "Prompt Stage", + "verbose_name_plural": "Prompt Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/prompt/migrations/0002_auto_20200920_1859.py b/authentik/stages/prompt/migrations/0002_auto_20200920_1859.py new file mode 100644 index 00000000..2ded1bbb --- /dev/null +++ b/authentik/stages/prompt/migrations/0002_auto_20200920_1859.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1.1 on 2020-09-20 18:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_prompt", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="prompt", + name="type", + field=models.CharField( + choices=[ + ("text", "Text: Simple Text input"), + ( + "username", + "Username: Same as Text input, but checks for and prevents duplicate usernames.", + ), + ("email", "Email: Text field with Email type."), + ( + "password", + "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.", + ), + ("number", "Number"), + ("checkbox", "Checkbox"), + ("data", "Date"), + ("data-time", "Date Time"), + ("separator", "Separator: Static Separator Line"), + ( + "hidden", + "Hidden: Hidden field, can be used to insert data into form.", + ), + ("static", "Static: Static value, displayed as-is."), + ], + max_length=100, + ), + ), + ] diff --git a/passbook/stages/prompt/migrations/__init__.py b/authentik/stages/prompt/migrations/__init__.py similarity index 100% rename from passbook/stages/prompt/migrations/__init__.py rename to authentik/stages/prompt/migrations/__init__.py diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py new file mode 100644 index 00000000..602e8b64 --- /dev/null +++ b/authentik/stages/prompt/models.py @@ -0,0 +1,166 @@ +"""prompt models""" +from typing import Type +from uuid import uuid4 + +from django import forms +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 Stage +from authentik.lib.models import SerializerModel +from authentik.policies.models import Policy +from authentik.stages.prompt.widgets import HorizontalRuleWidget, StaticTextWidget + + +class FieldTypes(models.TextChoices): + """Field types an Prompt can be""" + + # Simple text field + TEXT = "text", _("Text: Simple Text input") + # Same as text, but has autocomplete for password managers + USERNAME = ( + "username", + _( + ( + "Username: Same as Text input, but checks for " + "and prevents duplicate usernames." + ) + ), + ) + EMAIL = "email", _("Email: Text field with Email type.") + PASSWORD = ( + "password", # noqa # nosec + _( + ( + "Password: Masked input, password is validated against sources. Policies still " + "have to be applied to this Stage. If two of these are used in the same stage, " + "they are ensured to be identical." + ) + ), + ) + NUMBER = "number" + CHECKBOX = "checkbox" + DATE = "data" + DATE_TIME = "data-time" + + SEPARATOR = "separator", _("Separator: Static Separator Line") + HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.") + STATIC = "static", _("Static: Static value, displayed as-is.") + + +class Prompt(SerializerModel): + """Single Prompt, part of a prompt stage.""" + + prompt_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + field_key = models.SlugField( + help_text=_("Name of the form field, also used to store the value") + ) + label = models.TextField() + type = models.CharField(max_length=100, choices=FieldTypes.choices) + required = models.BooleanField(default=True) + placeholder = models.TextField(blank=True) + + order = models.IntegerField(default=0) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.prompt.api import PromptSerializer + + return PromptSerializer + + @property + def field(self): + """Return instantiated form input field""" + attrs = {"placeholder": _(self.placeholder)} + field_class = forms.CharField + widget = forms.TextInput(attrs=attrs) + kwargs = { + "label": _(self.label), + "required": self.required, + } + if self.type == FieldTypes.EMAIL: + field_class = forms.EmailField + if self.type == FieldTypes.USERNAME: + attrs["autocomplete"] = "username" + if self.type == FieldTypes.PASSWORD: + widget = forms.PasswordInput(attrs=attrs) + attrs["autocomplete"] = "new-password" + if self.type == FieldTypes.NUMBER: + field_class = forms.IntegerField + widget = forms.NumberInput(attrs=attrs) + if self.type == FieldTypes.HIDDEN: + widget = forms.HiddenInput(attrs=attrs) + kwargs["required"] = False + kwargs["initial"] = self.placeholder + if self.type == FieldTypes.CHECKBOX: + field_class = forms.BooleanField + kwargs["required"] = False + if self.type == FieldTypes.DATE: + attrs["type"] = "date" + widget = forms.DateInput(attrs=attrs) + if self.type == FieldTypes.DATE_TIME: + attrs["type"] = "datetime-local" + widget = forms.DateTimeInput(attrs=attrs) + if self.type == FieldTypes.STATIC: + widget = StaticTextWidget(attrs=attrs) + kwargs["initial"] = self.placeholder + kwargs["required"] = False + kwargs["label"] = "" + if self.type == FieldTypes.SEPARATOR: + widget = HorizontalRuleWidget(attrs=attrs) + kwargs["required"] = False + kwargs["label"] = "" + + kwargs["widget"] = widget + return field_class(**kwargs) + + def save(self, *args, **kwargs): + if self.type not in FieldTypes: + raise ValueError + return super().save(*args, **kwargs) + + def __str__(self): + return f"Prompt '{self.field_key}' type={self.type}" + + class Meta: + + verbose_name = _("Prompt") + verbose_name_plural = _("Prompts") + + +class PromptStage(Stage): + """Define arbitrary prompts for the user.""" + + fields = models.ManyToManyField(Prompt) + + validation_policies = models.ManyToManyField(Policy, blank=True) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.prompt.api import PromptStageSerializer + + return PromptStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.prompt.stage import PromptStageView + + return PromptStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.prompt.forms import PromptStageForm + + return PromptStageForm + + def __str__(self): + return f"Prompt Stage {self.name}" + + class Meta: + + verbose_name = _("Prompt Stage") + verbose_name_plural = _("Prompt Stages") diff --git a/authentik/stages/prompt/signals.py b/authentik/stages/prompt/signals.py new file mode 100644 index 00000000..24e4f332 --- /dev/null +++ b/authentik/stages/prompt/signals.py @@ -0,0 +1,5 @@ +"""authentik prompt stage signals""" +from django.core.signals import Signal + +# Arguments: password: str, plan_context: Dict[str, Any] +password_validate = Signal() diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py new file mode 100644 index 00000000..7b1db022 --- /dev/null +++ b/authentik/stages/prompt/stage.py @@ -0,0 +1,36 @@ +"""Prompt Stage Logic""" +from django.http import HttpResponse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView +from structlog import get_logger + +from authentik.flows.stage import StageView +from authentik.stages.prompt.forms import PromptForm + +LOGGER = get_logger() +PLAN_CONTEXT_PROMPT = "prompt_data" + + +class PromptStageView(FormView, StageView): + """Prompt Stage, save form data in plan context.""" + + template_name = "login/form.html" + form_class = PromptForm + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["title"] = _(self.executor.current_stage.name) + return ctx + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["stage"] = self.executor.current_stage + kwargs["plan"] = self.executor.plan + return kwargs + + def form_valid(self, form: PromptForm) -> HttpResponse: + """Form data is valid""" + if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: + self.executor.plan.context[PLAN_CONTEXT_PROMPT] = {} + self.executor.plan.context[PLAN_CONTEXT_PROMPT].update(form.cleaned_data) + return self.executor.stage_ok() diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py new file mode 100644 index 00000000..bbacd8c2 --- /dev/null +++ b/authentik/stages/prompt/tests.py @@ -0,0 +1,178 @@ +"""Prompt tests""" +from unittest.mock import MagicMock, patch + +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.policies.expression.models import ExpressionPolicy +from authentik.stages.prompt.forms import PromptForm +from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + + +class TestPromptStage(TestCase): + """Prompt tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create(username="unittest", email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-prompt", + slug="test-prompt", + designation=FlowDesignation.AUTHENTICATION, + ) + text_prompt = Prompt.objects.create( + field_key="text_prompt", + label="TEXT_LABEL", + type=FieldTypes.TEXT, + required=True, + placeholder="TEXT_PLACEHOLDER", + ) + email_prompt = Prompt.objects.create( + field_key="email_prompt", + label="EMAIL_LABEL", + type=FieldTypes.EMAIL, + required=True, + placeholder="EMAIL_PLACEHOLDER", + ) + password_prompt = Prompt.objects.create( + field_key="password_prompt", + label="PASSWORD_LABEL", + type=FieldTypes.PASSWORD, + required=True, + placeholder="PASSWORD_PLACEHOLDER", + ) + password2_prompt = Prompt.objects.create( + field_key="password2_prompt", + label="PASSWORD_LABEL", + type=FieldTypes.PASSWORD, + required=True, + placeholder="PASSWORD_PLACEHOLDER", + ) + number_prompt = Prompt.objects.create( + field_key="number_prompt", + label="NUMBER_LABEL", + type=FieldTypes.NUMBER, + required=True, + placeholder="NUMBER_PLACEHOLDER", + ) + hidden_prompt = Prompt.objects.create( + field_key="hidden_prompt", + type=FieldTypes.HIDDEN, + required=True, + placeholder="HIDDEN_PLACEHOLDER", + ) + self.stage = PromptStage.objects.create(name="prompt-stage") + self.stage.fields.set( + [ + text_prompt, + email_prompt, + password_prompt, + password2_prompt, + number_prompt, + hidden_prompt, + ] + ) + self.stage.save() + + self.prompt_data = { + text_prompt.field_key: "test-input", + email_prompt.field_key: "test@test.test", + password_prompt.field_key: "test", + password2_prompt.field_key: "test", + number_prompt.field_key: 3, + hidden_prompt.field_key: hidden_prompt.placeholder, + } + + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_render(self): + """Test render of form, check if all prompts are rendered correctly""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 200) + for prompt in self.stage.fields.all(): + self.assertIn(prompt.field_key, force_str(response.content)) + self.assertIn(prompt.label, force_str(response.content)) + self.assertIn(prompt.placeholder, force_str(response.content)) + + def test_valid_form_with_policy(self) -> PromptForm: + """Test form validation""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + expr = "return request.context['password_prompt'] == request.context['password2_prompt']" + expr_policy = ExpressionPolicy.objects.create( + name="validate-form", expression=expr + ) + self.stage.validation_policies.set([expr_policy]) + self.stage.save() + form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data) + self.assertEqual(form.is_valid(), True) + return form + + def test_invalid_form(self) -> PromptForm: + """Test form validation""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + expr = "False" + expr_policy = ExpressionPolicy.objects.create( + name="validate-form", expression=expr + ) + self.stage.validation_policies.set([expr_policy]) + self.stage.save() + form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data) + self.assertEqual(form.is_valid(), False) + return form + + def test_valid_form_request(self): + """Test a request with valid form data""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + form = self.test_valid_form_with_policy() + + with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): + response = self.client.post( + reverse( + "authentik_flows:flow-executor", + kwargs={"flow_slug": self.flow.slug}, + ), + form.cleaned_data, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + # Check that valid data has been saved + session = self.client.session + plan: FlowPlan = session[SESSION_KEY_PLAN] + data = plan.context[PLAN_CONTEXT_PROMPT] + for prompt in self.stage.fields.all(): + prompt: Prompt + self.assertEqual(data[prompt.field_key], self.prompt_data[prompt.field_key]) diff --git a/passbook/stages/prompt/widgets.py b/authentik/stages/prompt/widgets.py similarity index 100% rename from passbook/stages/prompt/widgets.py rename to authentik/stages/prompt/widgets.py diff --git a/passbook/stages/user_delete/__init__.py b/authentik/stages/user_delete/__init__.py similarity index 100% rename from passbook/stages/user_delete/__init__.py rename to authentik/stages/user_delete/__init__.py diff --git a/authentik/stages/user_delete/api.py b/authentik/stages/user_delete/api.py new file mode 100644 index 00000000..ef283ae3 --- /dev/null +++ b/authentik/stages/user_delete/api.py @@ -0,0 +1,24 @@ +"""User Delete Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.user_delete.models import UserDeleteStage + + +class UserDeleteStageSerializer(ModelSerializer): + """UserDeleteStage Serializer""" + + class Meta: + + model = UserDeleteStage + fields = [ + "pk", + "name", + ] + + +class UserDeleteStageViewSet(ModelViewSet): + """UserDeleteStage Viewset""" + + queryset = UserDeleteStage.objects.all() + serializer_class = UserDeleteStageSerializer diff --git a/authentik/stages/user_delete/apps.py b/authentik/stages/user_delete/apps.py new file mode 100644 index 00000000..b1ca8455 --- /dev/null +++ b/authentik/stages/user_delete/apps.py @@ -0,0 +1,10 @@ +"""authentik delete stage app config""" +from django.apps import AppConfig + + +class AuthentikStageUserDeleteConfig(AppConfig): + """authentik delete stage config""" + + name = "authentik.stages.user_delete" + label = "authentik_stages_user_delete" + verbose_name = "authentik Stages.User Delete" diff --git a/authentik/stages/user_delete/forms.py b/authentik/stages/user_delete/forms.py new file mode 100644 index 00000000..daf2e6fe --- /dev/null +++ b/authentik/stages/user_delete/forms.py @@ -0,0 +1,20 @@ +"""authentik flows delete forms""" +from django import forms + +from authentik.stages.user_delete.models import UserDeleteStage + + +class UserDeleteStageForm(forms.ModelForm): + """Form to delete/edit UserDeleteStage instances""" + + class Meta: + + model = UserDeleteStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } + + +class UserDeleteForm(forms.Form): + """Confirmation form to ensure user knows they are deleting their profile""" diff --git a/authentik/stages/user_delete/migrations/0001_initial.py b/authentik/stages/user_delete/migrations/0001_initial.py new file mode 100644 index 00000000..6ae6061a --- /dev/null +++ b/authentik/stages/user_delete/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="UserDeleteStage", + 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", + ), + ), + ], + options={ + "verbose_name": "User Delete Stage", + "verbose_name_plural": "User Delete Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/passbook/stages/user_delete/migrations/__init__.py b/authentik/stages/user_delete/migrations/__init__.py similarity index 100% rename from passbook/stages/user_delete/migrations/__init__.py rename to authentik/stages/user_delete/migrations/__init__.py diff --git a/authentik/stages/user_delete/models.py b/authentik/stages/user_delete/models.py new file mode 100644 index 00000000..f9ef53e0 --- /dev/null +++ b/authentik/stages/user_delete/models.py @@ -0,0 +1,40 @@ +"""delete stage models""" +from typing import Type + +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 Stage + + +class UserDeleteStage(Stage): + """Deletes the currently pending user without confirmation. + Use with caution.""" + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.user_delete.api import UserDeleteStageSerializer + + return UserDeleteStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.user_delete.stage import UserDeleteStageView + + return UserDeleteStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.user_delete.forms import UserDeleteStageForm + + return UserDeleteStageForm + + def __str__(self): + return f"User Delete Stage {self.name}" + + class Meta: + + verbose_name = _("User Delete Stage") + verbose_name_plural = _("User Delete Stages") diff --git a/authentik/stages/user_delete/stage.py b/authentik/stages/user_delete/stage.py new file mode 100644 index 00000000..7504c150 --- /dev/null +++ b/authentik/stages/user_delete/stage.py @@ -0,0 +1,34 @@ +"""Delete stage logic""" +from django.contrib import messages +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from django.views.generic import FormView +from structlog import get_logger + +from authentik.core.models import User +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.stages.user_delete.forms import UserDeleteForm + +LOGGER = get_logger() + + +class UserDeleteStageView(FormView, StageView): + """Finalise unenrollment flow by deleting the user object.""" + + form_class = UserDeleteForm + + def get(self, request: HttpRequest) -> HttpResponse: + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + message = _("No Pending User.") + messages.error(request, message) + LOGGER.debug(message) + return self.executor.stage_invalid() + return super().get(request) + + def form_valid(self, form: UserDeleteForm) -> HttpResponse: + user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + user.delete() + LOGGER.debug("Deleted user", user=user) + del self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + return self.executor.stage_ok() diff --git a/authentik/stages/user_delete/tests.py b/authentik/stages/user_delete/tests.py new file mode 100644 index 00000000..fb87118b --- /dev/null +++ b/authentik/stages/user_delete/tests.py @@ -0,0 +1,95 @@ +"""delete tests""" +from unittest.mock import patch + +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.policies.http import AccessDeniedResponse +from authentik.stages.user_delete.models import UserDeleteStage + + +class TestUserDeleteStage(TestCase): + """Delete tests""" + + def setUp(self): + super().setUp() + self.username = "qerqwerqrwqwerwq" + self.user = User.objects.create(username=self.username, email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-delete", + slug="test-delete", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserDeleteStage.objects.create(name="delete") + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_no_user(self): + """Test without user set""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + + def test_user_delete_get(self): + """Test Form render""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 200) + + def test_user_delete_post(self): + """Test User delete (actual)""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.post( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + self.assertFalse(User.objects.filter(username=self.username).exists()) diff --git a/passbook/stages/user_login/__init__.py b/authentik/stages/user_login/__init__.py similarity index 100% rename from passbook/stages/user_login/__init__.py rename to authentik/stages/user_login/__init__.py diff --git a/authentik/stages/user_login/api.py b/authentik/stages/user_login/api.py new file mode 100644 index 00000000..43e29f10 --- /dev/null +++ b/authentik/stages/user_login/api.py @@ -0,0 +1,25 @@ +"""Login Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.user_login.models import UserLoginStage + + +class UserLoginStageSerializer(ModelSerializer): + """UserLoginStage Serializer""" + + class Meta: + + model = UserLoginStage + fields = [ + "pk", + "name", + "session_duration", + ] + + +class UserLoginStageViewSet(ModelViewSet): + """UserLoginStage Viewset""" + + queryset = UserLoginStage.objects.all() + serializer_class = UserLoginStageSerializer diff --git a/authentik/stages/user_login/apps.py b/authentik/stages/user_login/apps.py new file mode 100644 index 00000000..1fcc87d4 --- /dev/null +++ b/authentik/stages/user_login/apps.py @@ -0,0 +1,10 @@ +"""authentik login stage app config""" +from django.apps import AppConfig + + +class AuthentikStageUserLoginConfig(AppConfig): + """authentik login stage config""" + + name = "authentik.stages.user_login" + label = "authentik_stages_user_login" + verbose_name = "authentik Stages.User Login" diff --git a/authentik/stages/user_login/forms.py b/authentik/stages/user_login/forms.py new file mode 100644 index 00000000..8693a789 --- /dev/null +++ b/authentik/stages/user_login/forms.py @@ -0,0 +1,17 @@ +"""authentik flows login forms""" +from django import forms + +from authentik.stages.user_login.models import UserLoginStage + + +class UserLoginStageForm(forms.ModelForm): + """Form to create/edit UserLoginStage instances""" + + class Meta: + + model = UserLoginStage + fields = ["name", "session_duration"] + widgets = { + "name": forms.TextInput(), + "session_duration": forms.TextInput(), + } diff --git a/authentik/stages/user_login/migrations/0001_initial.py b/authentik/stages/user_login/migrations/0001_initial.py new file mode 100644 index 00000000..ed82b2fe --- /dev/null +++ b/authentik/stages/user_login/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="UserLoginStage", + 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", + ), + ), + ], + options={ + "verbose_name": "User Login Stage", + "verbose_name_plural": "User Login Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/user_login/migrations/0002_userloginstage_session_duration.py b/authentik/stages/user_login/migrations/0002_userloginstage_session_duration.py new file mode 100644 index 00000000..53bc43fd --- /dev/null +++ b/authentik/stages/user_login/migrations/0002_userloginstage_session_duration.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.7 on 2020-07-04 13:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_user_login", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="userloginstage", + name="session_duration", + field=models.PositiveIntegerField( + default=0, + help_text="Determines how long a session lasts, in seconds. Default of 0 means that the sessions lasts until the browser is closed.", + ), + ), + ] diff --git a/authentik/stages/user_login/migrations/0003_session_duration_delta.py b/authentik/stages/user_login/migrations/0003_session_duration_delta.py new file mode 100644 index 00000000..ebc8f09f --- /dev/null +++ b/authentik/stages/user_login/migrations/0003_session_duration_delta.py @@ -0,0 +1,38 @@ +# Generated by Django 3.1.2 on 2020-10-26 20:21 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +import authentik.lib.utils.time + + +def update_duration(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + UserLoginStage = apps.get_model("authentik_stages_user_login", "userloginstage") + + db_alias = schema_editor.connection.alias + + for stage in UserLoginStage.objects.using(db_alias).all(): + if stage.session_duration.isdigit(): + stage.session_duration = f"seconds={stage.session_duration}" + stage.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_user_login", "0002_userloginstage_session_duration"), + ] + + operations = [ + migrations.AlterField( + model_name="userloginstage", + name="session_duration", + field=models.TextField( + default="seconds=0", + help_text="Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + migrations.RunPython(update_duration), + ] diff --git a/passbook/stages/user_login/migrations/__init__.py b/authentik/stages/user_login/migrations/__init__.py similarity index 100% rename from passbook/stages/user_login/migrations/__init__.py rename to authentik/stages/user_login/migrations/__init__.py diff --git a/authentik/stages/user_login/models.py b/authentik/stages/user_login/models.py new file mode 100644 index 00000000..d25018f7 --- /dev/null +++ b/authentik/stages/user_login/models.py @@ -0,0 +1,51 @@ +"""login stage models""" +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 Stage +from authentik.lib.utils.time import timedelta_string_validator + + +class UserLoginStage(Stage): + """Attaches the currently pending user to the current session.""" + + session_duration = models.TextField( + default="seconds=0", + validators=[timedelta_string_validator], + help_text=_( + "Determines how long a session lasts. Default of 0 means " + "that the sessions lasts until the browser is closed. " + "(Format: hours=-1;minutes=-2;seconds=-3)" + ), + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.user_login.api import UserLoginStageSerializer + + return UserLoginStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.user_login.stage import UserLoginStageView + + return UserLoginStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.user_login.forms import UserLoginStageForm + + return UserLoginStageForm + + def __str__(self): + return f"User Login Stage {self.name}" + + class Meta: + + verbose_name = _("User Login Stage") + verbose_name_plural = _("User Login Stages") diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py new file mode 100644 index 00000000..47df6990 --- /dev/null +++ b/authentik/stages/user_login/stage.py @@ -0,0 +1,48 @@ +"""Login stage logic""" +from django.contrib import messages +from django.contrib.auth import login +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from structlog import get_logger + +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.lib.utils.time import timedelta_from_string +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND + +LOGGER = get_logger() + + +class UserLoginStageView(StageView): + """Finalise Authentication flow by logging the user in""" + + def get(self, request: HttpRequest) -> HttpResponse: + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + message = _("No Pending user to login.") + messages.error(request, message) + LOGGER.debug(message) + return self.executor.stage_invalid() + if PLAN_CONTEXT_AUTHENTICATION_BACKEND not in self.executor.plan.context: + message = _("Pending user has no backend.") + messages.error(request, message) + LOGGER.debug(message) + return self.executor.stage_invalid() + backend = self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] + login( + self.request, + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], + backend=backend, + ) + delta = timedelta_from_string(self.executor.current_stage.session_duration) + if delta.seconds == 0: + self.request.session.set_expiry(0) + else: + self.request.session.set_expiry(delta) + LOGGER.debug( + "Logged in", + user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], + flow_slug=self.executor.flow.slug, + session_duration=self.executor.current_stage.session_duration, + ) + messages.success(self.request, _("Successfully logged in!")) + return self.executor.stage_ok() diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py new file mode 100644 index 00000000..059793f8 --- /dev/null +++ b/authentik/stages/user_login/tests.py @@ -0,0 +1,111 @@ +"""login tests""" +from unittest.mock import patch + +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.policies.http import AccessDeniedResponse +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.user_login.forms import UserLoginStageForm +from authentik.stages.user_login.models import UserLoginStage + + +class TestUserLoginStage(TestCase): + """Login tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create(username="unittest", email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-login", + slug="test-login", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserLoginStage.objects.create(name="login") + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_valid_password(self): + """Test with a valid pending user and backend""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = "django.contrib.auth.backends.ModelBackend" + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_without_user(self): + """Test a plan without any pending user, resulting in a denied""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_without_backend(self): + """Test a plan with pending user, without backend, resulting in a denied""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + + def test_form(self): + """Test Form""" + data = {"name": "test", "session_duration": "seconds=0"} + self.assertEqual(UserLoginStageForm(data).is_valid(), True) + data = {"name": "test", "session_duration": "123"} + self.assertEqual(UserLoginStageForm(data).is_valid(), False) diff --git a/passbook/stages/user_logout/__init__.py b/authentik/stages/user_logout/__init__.py similarity index 100% rename from passbook/stages/user_logout/__init__.py rename to authentik/stages/user_logout/__init__.py diff --git a/authentik/stages/user_logout/api.py b/authentik/stages/user_logout/api.py new file mode 100644 index 00000000..8467b7a7 --- /dev/null +++ b/authentik/stages/user_logout/api.py @@ -0,0 +1,24 @@ +"""Logout Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.user_logout.models import UserLogoutStage + + +class UserLogoutStageSerializer(ModelSerializer): + """UserLogoutStage Serializer""" + + class Meta: + + model = UserLogoutStage + fields = [ + "pk", + "name", + ] + + +class UserLogoutStageViewSet(ModelViewSet): + """UserLogoutStage Viewset""" + + queryset = UserLogoutStage.objects.all() + serializer_class = UserLogoutStageSerializer diff --git a/authentik/stages/user_logout/apps.py b/authentik/stages/user_logout/apps.py new file mode 100644 index 00000000..467b4a18 --- /dev/null +++ b/authentik/stages/user_logout/apps.py @@ -0,0 +1,10 @@ +"""authentik logout stage app config""" +from django.apps import AppConfig + + +class AuthentikStageUserLogoutConfig(AppConfig): + """authentik logout stage config""" + + name = "authentik.stages.user_logout" + label = "authentik_stages_user_logout" + verbose_name = "authentik Stages.User Logout" diff --git a/authentik/stages/user_logout/forms.py b/authentik/stages/user_logout/forms.py new file mode 100644 index 00000000..44d3cba4 --- /dev/null +++ b/authentik/stages/user_logout/forms.py @@ -0,0 +1,16 @@ +"""authentik flows logout forms""" +from django import forms + +from authentik.stages.user_logout.models import UserLogoutStage + + +class UserLogoutStageForm(forms.ModelForm): + """Form to create/edit UserLogoutStage instances""" + + class Meta: + + model = UserLogoutStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/user_logout/migrations/0001_initial.py b/authentik/stages/user_logout/migrations/0001_initial.py new file mode 100644 index 00000000..1b1ba286 --- /dev/null +++ b/authentik/stages/user_logout/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="UserLogoutStage", + 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", + ), + ), + ], + options={ + "verbose_name": "User Logout Stage", + "verbose_name_plural": "User Logout Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/passbook/stages/user_logout/migrations/__init__.py b/authentik/stages/user_logout/migrations/__init__.py similarity index 100% rename from passbook/stages/user_logout/migrations/__init__.py rename to authentik/stages/user_logout/migrations/__init__.py diff --git a/authentik/stages/user_logout/models.py b/authentik/stages/user_logout/models.py new file mode 100644 index 00000000..c59ff085 --- /dev/null +++ b/authentik/stages/user_logout/models.py @@ -0,0 +1,39 @@ +"""logout stage models""" +from typing import Type + +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 Stage + + +class UserLogoutStage(Stage): + """Resets the users current session.""" + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.user_logout.api import UserLogoutStageSerializer + + return UserLogoutStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.user_logout.stage import UserLogoutStageView + + return UserLogoutStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.user_logout.forms import UserLogoutStageForm + + return UserLogoutStageForm + + def __str__(self): + return f"User Logout Stage {self.name}" + + class Meta: + + verbose_name = _("User Logout Stage") + verbose_name_plural = _("User Logout Stages") diff --git a/authentik/stages/user_logout/stage.py b/authentik/stages/user_logout/stage.py new file mode 100644 index 00000000..d658f38c --- /dev/null +++ b/authentik/stages/user_logout/stage.py @@ -0,0 +1,21 @@ +"""Logout stage logic""" +from django.contrib.auth import logout +from django.http import HttpRequest, HttpResponse +from structlog import get_logger + +from authentik.flows.stage import StageView + +LOGGER = get_logger() + + +class UserLogoutStageView(StageView): + """Finalise Authentication flow by logging the user in""" + + def get(self, request: HttpRequest) -> HttpResponse: + LOGGER.debug( + "Logged out", + user=request.user, + flow_slug=self.executor.flow.slug, + ) + logout(self.request) + return self.executor.stage_ok() diff --git a/authentik/stages/user_logout/tests.py b/authentik/stages/user_logout/tests.py new file mode 100644 index 00000000..dd3b9367 --- /dev/null +++ b/authentik/stages/user_logout/tests.py @@ -0,0 +1,60 @@ +"""logout tests""" +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.user_logout.forms import UserLogoutStageForm +from authentik.stages.user_logout.models import UserLogoutStage + + +class TestUserLogoutStage(TestCase): + """Logout tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create(username="unittest", email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-logout", + slug="test-logout", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserLogoutStage.objects.create(name="logout") + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_valid_password(self): + """Test with a valid pending user and backend""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = "django.contrib.auth.backends.ModelBackend" + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + + def test_form(self): + """Test Form""" + data = {"name": "test"} + self.assertEqual(UserLogoutStageForm(data).is_valid(), True) diff --git a/passbook/stages/user_write/__init__.py b/authentik/stages/user_write/__init__.py similarity index 100% rename from passbook/stages/user_write/__init__.py rename to authentik/stages/user_write/__init__.py diff --git a/authentik/stages/user_write/api.py b/authentik/stages/user_write/api.py new file mode 100644 index 00000000..0e2833e0 --- /dev/null +++ b/authentik/stages/user_write/api.py @@ -0,0 +1,24 @@ +"""User Write Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.stages.user_write.models import UserWriteStage + + +class UserWriteStageSerializer(ModelSerializer): + """UserWriteStage Serializer""" + + class Meta: + + model = UserWriteStage + fields = [ + "pk", + "name", + ] + + +class UserWriteStageViewSet(ModelViewSet): + """UserWriteStage Viewset""" + + queryset = UserWriteStage.objects.all() + serializer_class = UserWriteStageSerializer diff --git a/authentik/stages/user_write/apps.py b/authentik/stages/user_write/apps.py new file mode 100644 index 00000000..62e933da --- /dev/null +++ b/authentik/stages/user_write/apps.py @@ -0,0 +1,10 @@ +"""authentik write stage app config""" +from django.apps import AppConfig + + +class AuthentikStageUserWriteConfig(AppConfig): + """authentik write stage config""" + + name = "authentik.stages.user_write" + label = "authentik_stages_user_write" + verbose_name = "authentik Stages.User Write" diff --git a/authentik/stages/user_write/forms.py b/authentik/stages/user_write/forms.py new file mode 100644 index 00000000..685afc57 --- /dev/null +++ b/authentik/stages/user_write/forms.py @@ -0,0 +1,16 @@ +"""authentik flows write forms""" +from django import forms + +from authentik.stages.user_write.models import UserWriteStage + + +class UserWriteStageForm(forms.ModelForm): + """Form to write/edit UserWriteStage instances""" + + class Meta: + + model = UserWriteStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/user_write/migrations/0001_initial.py b/authentik/stages/user_write/migrations/0001_initial.py new file mode 100644 index 00000000..94583d7e --- /dev/null +++ b/authentik/stages/user_write/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.6 on 2020-05-19 22:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="UserWriteStage", + 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", + ), + ), + ], + options={ + "verbose_name": "User Write Stage", + "verbose_name_plural": "User Write Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/user_write/migrations/0002_auto_20200918_1653.py b/authentik/stages/user_write/migrations/0002_auto_20200918_1653.py new file mode 100644 index 00000000..ddf6d96d --- /dev/null +++ b/authentik/stages/user_write/migrations/0002_auto_20200918_1653.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.1 on 2020-09-18 16:53 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def remove_unintended_attributes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + User = apps.get_model("authentik_core", "User") + for user in User.objects.using(db_alias).all(): + if "password_repeat" in user.attributes: + del user.attributes["password_repeat"] + if "password" in user.attributes: + del user.attributes["password"] + user.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_user_write", "0001_initial"), + ] + + operations = [ + migrations.RunPython(remove_unintended_attributes), + ] diff --git a/passbook/stages/user_write/migrations/__init__.py b/authentik/stages/user_write/migrations/__init__.py similarity index 100% rename from passbook/stages/user_write/migrations/__init__.py rename to authentik/stages/user_write/migrations/__init__.py diff --git a/authentik/stages/user_write/models.py b/authentik/stages/user_write/models.py new file mode 100644 index 00000000..820b5bd9 --- /dev/null +++ b/authentik/stages/user_write/models.py @@ -0,0 +1,40 @@ +"""write stage models""" +from typing import Type + +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 Stage + + +class UserWriteStage(Stage): + """Writes currently pending data into the pending user, or if no user exists, + creates a new user with the data.""" + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.user_write.api import UserWriteStageSerializer + + return UserWriteStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.user_write.stage import UserWriteStageView + + return UserWriteStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.user_write.forms import UserWriteStageForm + + return UserWriteStageForm + + def __str__(self): + return f"User Write Stage {self.name}" + + class Meta: + + verbose_name = _("User Write Stage") + verbose_name_plural = _("User Write Stages") diff --git a/authentik/stages/user_write/signals.py b/authentik/stages/user_write/signals.py new file mode 100644 index 00000000..7a4c3811 --- /dev/null +++ b/authentik/stages/user_write/signals.py @@ -0,0 +1,5 @@ +"""authentik user_write signals""" +from django.core.signals import Signal + +# Arguments: request: HttpRequest, user: User, data: Dict[str, Any], created: bool +user_write = Signal() diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py new file mode 100644 index 00000000..94595aed --- /dev/null +++ b/authentik/stages/user_write/stage.py @@ -0,0 +1,83 @@ +"""Write stage logic""" +from django.contrib import messages +from django.contrib.auth import update_session_auth_hash +from django.contrib.auth.backends import ModelBackend +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from structlog import get_logger + +from authentik.core.middleware import SESSION_IMPERSONATE_USER +from authentik.core.models import User +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import StageView +from authentik.lib.utils.reflection import class_to_path +from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT +from authentik.stages.user_write.signals import user_write + +LOGGER = get_logger() + + +class UserWriteStageView(StageView): + """Finalise Enrollment flow by creating a user object.""" + + def get(self, request: HttpRequest) -> HttpResponse: + if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: + message = _("No Pending data.") + messages.error(request, message) + LOGGER.debug(message) + return self.executor.stage_invalid() + data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] + user_created = False + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User() + self.executor.plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = class_to_path(ModelBackend) + LOGGER.debug( + "Created new user", + flow_slug=self.executor.flow.slug, + ) + user_created = True + user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + # Before we change anything, check if the user is the same as in the request + # and we're updating a password. In that case we need to update the session hash + # Also check that we're not currently impersonating, so we don't update the session + should_update_seesion = False + if ( + any(["password" in x for x in data.keys()]) + and self.request.user.pk == user.pk + and SESSION_IMPERSONATE_USER not in self.request.session + ): + should_update_seesion = True + for key, value in data.items(): + setter_name = f"set_{key}" + # Check if user has a setter for this key, like set_password + if hasattr(user, setter_name): + setter = getattr(user, setter_name) + if callable(setter): + setter(value) + # User has this key already + elif hasattr(user, key): + setattr(user, key, value) + # Otherwise we just save it as custom attribute, but only if the value is prefixed with + # `attribute_`, to prevent accidentally saving values + else: + if not key.startswith("attribute_"): + LOGGER.debug("discarding key", key=key) + continue + user.attributes[key.replace("attribute_", "", 1)] = value + user.save() + user_write.send( + sender=self, request=request, user=user, data=data, created=user_created + ) + # Check if the password has been updated, and update the session auth hash + if should_update_seesion: + update_session_auth_hash(self.request, user) + LOGGER.debug("Updated session hash", user=user) + LOGGER.debug( + "Updated existing user", + user=user, + flow_slug=self.executor.flow.slug, + ) + return self.executor.stage_ok() diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py new file mode 100644 index 00000000..43f1bd1f --- /dev/null +++ b/authentik/stages/user_write/tests.py @@ -0,0 +1,138 @@ +"""write tests""" +import string +from random import SystemRandom +from unittest.mock import patch + +from django.shortcuts import reverse +from django.test import Client, TestCase +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.policies.http import AccessDeniedResponse +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT +from authentik.stages.user_write.forms import UserWriteStageForm +from authentik.stages.user_write.models import UserWriteStage + + +class TestUserWriteStage(TestCase): + """Write tests""" + + def setUp(self): + super().setUp() + self.client = Client() + + self.flow = Flow.objects.create( + name="test-write", + slug="test-write", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserWriteStage.objects.create(name="write") + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_user_create(self): + """Test creation of user""" + password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PROMPT] = { + "username": "test-user", + "name": "name", + "email": "test@beryju.org", + "password": password, + } + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + user_qs = User.objects.filter( + username=plan.context[PLAN_CONTEXT_PROMPT]["username"] + ) + self.assertTrue(user_qs.exists()) + self.assertTrue(user_qs.first().check_password(password)) + + def test_user_update(self): + """Test update of existing user""" + new_password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create( + username="unittest", email="test@beryju.org" + ) + plan.context[PLAN_CONTEXT_PROMPT] = { + "username": "test-user-new", + "password": new_password, + "attribute_some-custom-attribute": "test", + } + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + {"type": "redirect", "to": reverse("authentik_core:shell")}, + ) + user_qs = User.objects.filter( + username=plan.context[PLAN_CONTEXT_PROMPT]["username"] + ) + self.assertTrue(user_qs.exists()) + self.assertTrue(user_qs.first().check_password(new_password)) + self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test") + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_without_data(self): + """Test without data results in error""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response, AccessDeniedResponse) + + def test_form(self): + """Test Form""" + data = {"name": "test"} + self.assertEqual(UserWriteStageForm(data).is_valid(), True) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e2f72918..4ffdd781 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -5,8 +5,8 @@ resources: - repo: self variables: - POSTGRES_DB: passbook - POSTGRES_USER: passbook + POSTGRES_DB: authentik + POSTGRES_USER: authentik POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77" ${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}: branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }} @@ -31,7 +31,7 @@ stages: pipenv install --dev - task: CmdLine@2 inputs: - script: pipenv run pylint passbook tests lifecycle + script: pipenv run pylint authentik tests lifecycle - job: black pool: vmImage: 'ubuntu-latest' @@ -47,7 +47,7 @@ stages: pipenv install --dev - task: CmdLine@2 inputs: - script: pipenv run black --check passbook tests lifecycle + script: pipenv run black --check authentik tests lifecycle - job: prospector pool: vmImage: 'ubuntu-latest' @@ -80,7 +80,7 @@ stages: pipenv install --dev - task: CmdLine@2 inputs: - script: pipenv run bandit -r passbook tests lifecycle + script: pipenv run bandit -r authentik tests lifecycle - job: pyright pool: vmImage: ubuntu-latest @@ -147,6 +147,8 @@ stages: displayName: Prepare Last tagged release inputs: script: | + # Copy current, latest config to local + cp authentik/lib/default.yml local.env.yml git checkout $(git describe --abbrev=0 --match 'version/*') sudo apt install -y libxmlsec1-dev pkg-config sudo pip install -U wheel pipenv @@ -154,7 +156,8 @@ stages: - task: CmdLine@2 displayName: Migrate to last tagged release inputs: - script: pipenv run ./manage.py migrate + script: + pipenv run ./manage.py migrate - task: CmdLine@2 displayName: Install current branch inputs: @@ -165,7 +168,9 @@ stages: - task: CmdLine@2 displayName: Migrate to current branch inputs: - script: pipenv run ./manage.py migrate + script: | + pipenv run python -m lifecycle.migrate + pipenv run ./manage.py migrate - job: coverage_unittest pool: vmImage: 'ubuntu-latest' @@ -367,7 +372,7 @@ stages: - task: Docker@2 inputs: containerRegistry: 'dockerhub' - repository: 'beryju/passbook' + repository: 'beryju/authentik' command: 'buildAndPush' Dockerfile: 'Dockerfile' tags: "gh-${{ variables.branchName }}" diff --git a/docker-compose.yml b/docker-compose.yml index 075b3ce4..860b1890 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: - internal environment: - POSTGRES_PASSWORD=${PG_PASS:-thisisnotagoodpassword} - - POSTGRES_USER=passbook - - POSTGRES_DB=passbook + - POSTGRES_USER=authentik + - POSTGRES_DB=authentik env_file: - .env redis: @@ -19,12 +19,12 @@ services: networks: - internal server: - image: beryju/passbook:${PASSBOOK_TAG:-0.12.11-stable} + image: beryju/authentik:${AUTHENTIK_TAG:-0.12.11-stable} command: server environment: - PASSBOOK_REDIS__HOST: redis - PASSBOOK_POSTGRESQL__HOST: postgresql - PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} volumes: - ./media:/media ports: @@ -37,26 +37,26 @@ services: traefik.http.routers.app-router.rule: PathPrefix(`/`) traefik.http.routers.app-router.service: app-service traefik.http.routers.app-router.tls: 'true' - traefik.http.services.app-service.loadbalancer.healthcheck.hostname: passbook-healthcheck-host + traefik.http.services.app-service.loadbalancer.healthcheck.hostname: authentik-healthcheck-host traefik.http.services.app-service.loadbalancer.server.port: '8000' env_file: - .env worker: - image: beryju/passbook:${PASSBOOK_TAG:-0.12.11-stable} + image: beryju/authentik:${AUTHENTIK_TAG:-0.12.11-stable} command: worker networks: - internal environment: - PASSBOOK_REDIS__HOST: redis - PASSBOOK_POSTGRESQL__HOST: postgresql - PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS} + AUTHENTIK_REDIS__HOST: redis + AUTHENTIK_POSTGRESQL__HOST: postgresql + AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} volumes: - ./backups:/backups - /var/run/docker.sock:/var/run/docker.sock env_file: - .env static: - image: beryju/passbook-static:${PASSBOOK_TAG:-0.12.11-stable} + image: beryju/authentik-static:${AUTHENTIK_TAG:-0.12.11-stable} networks: - internal labels: diff --git a/helm/Chart.yaml b/helm/Chart.yaml index a52c4332..136dce6d 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -1,11 +1,11 @@ apiVersion: v2 -description: passbook is an open-source Identity Provider focused on flexibility and versatility. You can use passbook in an existing environment to add support for new protocols. passbook is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it. -name: passbook -home: https://passbook.beryju.org +description: authentik is an open-source Identity Provider focused on flexibility and versatility. You can use authentik in an existing environment to add support for new protocols. authentik is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it. +name: authentik +home: https://goauthentik.io sources: - - https://github.com/BeryJu/passbook + - https://github.com/BeryJu/authentik version: "0.12.11-stable" -icon: https://raw.githubusercontent.com/BeryJu/passbook/master/website/static/img/logo.svg +icon: https://raw.githubusercontent.com/BeryJu/authentik/master/icons/icon.svg dependencies: - name: postgresql version: 9.4.1 diff --git a/helm/README.md b/helm/README.md index b7118770..68a2c7d2 100644 --- a/helm/README.md +++ b/helm/README.md @@ -1,28 +1,28 @@ -# passbook Helm Chart +# authentik Helm Chart | Name | Default | Description | |-----------------------------------|-------------------------|-------------| -| image.name | beryju/passbook | Image used to run the passbook server and worker | -| image.name_static | beryju/passbook-static | Image used to run the passbook static server (CSS and JS Files) | +| image.name | beryju/authentik | Image used to run the authentik server and worker | +| image.name_static | beryju/authentik-static | Image used to run the authentik static server (CSS and JS Files) | | image.tag | 0.12.5-stable | Image tag | | serverReplicas | 1 | Replicas for the Server deployment | | workerReplicas | 1 | Replicas for the Worker deployment | -| kubernetesIntegration | true | Enable/disable the Kubernetes integration for passbook. This will create a service account for passbook to create and update outposts in passbook | +| kubernetesIntegration | true | Enable/disable the Kubernetes integration for authentik. This will create a service account for authentik to create and update outposts in authentik | | config.secretKey | | Secret key used to sign session cookies, generate with `pwgen 50 1` for example. | | config.errorReporting.enabled | false | Enable/disable error reporting | | config.errorReporting.environment | customer | Environment sent with the error reporting | | config.errorReporting.sendPii | false | Whether to send Personally-identifiable data with the error reporting | -| config.logLevel | warning | Log level of passbook | +| config.logLevel | warning | Log level of authentik | | backup.accessKey | | Optionally enable S3 Backup, Access Key | | backup.secretKey | | Optionally enable S3 Backup, Secret Key | | backup.bucket | | Optionally enable S3 Backup, Bucket | | backup.region | | Optionally enable S3 Backup, Region | | backup.host | | Optionally enable S3 Backup, to custom Endpoint like minio | | ingress.annotations | {} | Annotations for the ingress object | -| ingress.hosts | [passbook.k8s.local] | Hosts which the ingress will match | +| ingress.hosts | [authentik.k8s.local] | Hosts which the ingress will match | | ingress.tls | [] | TLS Configuration, same as Ingress objects | | install.postgresql | true | Enables/disables the packaged PostgreSQL Chart | install.redis | true | Enables/disables the packaged Redis Chart | postgresql.postgresqlPassword | | Password used for PostgreSQL, generated automatically. -For more info, see https://passbook.beryju.org/ and https://passbook.beryju.org/docs/installation/kubernetes/ +For more info, see https://goauthentik.io/ and https://goauthentik.io/docs/installation/kubernetes/ diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt index 2af4fed6..5f47f8c6 100644 --- a/helm/templates/NOTES.txt +++ b/helm/templates/NOTES.txt @@ -1,5 +1,5 @@ -1. Access passbook using the following URL: +1. Access authentik using the following URL: {{- range .Values.ingress.hosts }} http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }} {{- end }} -2. Login to passbook using the user "pbadmin" and the password "pbadmin". +2. Login to authentik using the user "akadmin" and the password "akadmin". diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl index 51fc9e66..fcb35da0 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/templates/_helpers.tpl @@ -2,7 +2,7 @@ {{/* Expand the name of the chart. */}} -{{- define "passbook.name" -}} +{{- define "authentik.name" -}} {{- default .Chart.Name | trunc 63 | trimSuffix "-" -}} {{- end -}} @@ -11,7 +11,7 @@ Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} -{{- define "passbook.fullname" -}} +{{- define "authentik.fullname" -}} {{- $name := default .Chart.Name -}} {{- if contains $name .Release.Name -}} {{- .Release.Name | trunc 63 | trimSuffix "-" -}} @@ -23,6 +23,6 @@ If release name contains chart name it will be used as a full name. {{/* Create chart name and version as used by the chart label. */}} -{{- define "passbook.chart" -}} +{{- define "authentik.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} {{- end -}} diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index d5256546..61af2bf6 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: ConfigMap metadata: - name: {{ include "passbook.fullname" . }}-config + name: {{ include "authentik.fullname" . }}-config data: POSTGRESQL__HOST: "{{ .Release.Name }}-postgresql" POSTGRESQL__NAME: "{{ .Values.postgresql.postgresqlDatabase }}" diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml index eee9f3d0..94de6bb6 100644 --- a/helm/templates/ingress.yaml +++ b/helm/templates/ingress.yaml @@ -1,11 +1,11 @@ -{{- $fullName := include "passbook.fullname" . -}} +{{- $fullName := include "authentik.fullname" . -}} apiVersion: extensions/v1beta1 kind: Ingress metadata: name: {{ $fullName }} labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} - helm.sh/chart: {{ include "passbook.chart" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} + helm.sh/chart: {{ include "authentik.chart" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- with .Values.ingress.annotations }} diff --git a/helm/templates/pvc.yaml b/helm/templates/pvc.yaml index dd42cb5c..45c665ac 100644 --- a/helm/templates/pvc.yaml +++ b/helm/templates/pvc.yaml @@ -1,10 +1,10 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: {{ include "passbook.fullname" . }}-uploads + name: {{ include "authentik.fullname" . }}-uploads labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} - helm.sh/chart: {{ include "passbook.chart" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} + helm.sh/chart: {{ include "authentik.chart" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} spec: diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml index 819c764c..bbe9ff8d 100644 --- a/helm/templates/secret.yaml +++ b/helm/templates/secret.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Secret type: Opaque metadata: - name: {{ include "passbook.fullname" . }}-secret-key + name: {{ include "authentik.fullname" . }}-secret-key data: monitoring_username: bW9uaXRvcg== # monitor in base64 {{- if .Values.config.secretKey }} diff --git a/helm/templates/service-account.yaml b/helm/templates/service-account.yaml index 1c7f9d07..94737582 100644 --- a/helm/templates/service-account.yaml +++ b/helm/templates/service-account.yaml @@ -2,7 +2,7 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: {{ include "passbook.fullname" . }}-sa-role + name: {{ include "authentik.fullname" . }}-sa-role rules: - apiGroups: - "" @@ -47,18 +47,18 @@ rules: apiVersion: v1 kind: ServiceAccount metadata: - name: {{ include "passbook.fullname" . }}-sa + name: {{ include "authentik.fullname" . }}-sa --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "passbook.fullname" . }}-sa-role-binding + name: {{ include "authentik.fullname" . }}-sa-role-binding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: {{ include "passbook.fullname" . }}-sa-role + name: {{ include "authentik.fullname" . }}-sa-role subjects: - kind: ServiceAccount - name: {{ include "passbook.fullname" . }}-sa + name: {{ include "authentik.fullname" . }}-sa namespace: {{ .Release.Namespace }} {{- end }} diff --git a/helm/templates/static-deployment.yaml b/helm/templates/static-deployment.yaml index 08108b23..e35f268b 100644 --- a/helm/templates/static-deployment.yaml +++ b/helm/templates/static-deployment.yaml @@ -1,25 +1,25 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "passbook.fullname" . }}-static + name: {{ include "authentik.fullname" . }}-static labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} - helm.sh/chart: {{ include "passbook.chart" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} + helm.sh/chart: {{ include "authentik.chart" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} - k8s.passbook.beryju.org/component: static + k8s.goauthentik.io/component: static spec: selector: matchLabels: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: static + k8s.goauthentik.io/component: static template: metadata: labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: static + k8s.goauthentik.io/component: static spec: containers: - name: {{ .Chart.Name }}-static @@ -49,9 +49,9 @@ spec: cpu: 20m memory: 20M volumeMounts: - - name: passbook-uploads + - name: authentik-uploads mountPath: /usr/share/nginx/html/media volumes: - - name: passbook-uploads + - name: authentik-uploads persistentVolumeClaim: - claimName: {{ include "passbook.fullname" . }}-uploads + claimName: {{ include "authentik.fullname" . }}-uploads diff --git a/helm/templates/static-service.yaml b/helm/templates/static-service.yaml index 76c1d5b9..7e948261 100644 --- a/helm/templates/static-service.yaml +++ b/helm/templates/static-service.yaml @@ -1,13 +1,13 @@ apiVersion: v1 kind: Service metadata: - name: {{ include "passbook.fullname" . }}-static + name: {{ include "authentik.fullname" . }}-static labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} - helm.sh/chart: {{ include "passbook.chart" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} + helm.sh/chart: {{ include "authentik.chart" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} - k8s.passbook.beryju.org/component: static + k8s.goauthentik.io/component: static spec: type: ClusterIP ports: @@ -16,6 +16,6 @@ spec: protocol: TCP name: http selector: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: static + k8s.goauthentik.io/component: static diff --git a/helm/templates/web-deployment.yaml b/helm/templates/web-deployment.yaml index d87b2d1a..498b6a7a 100644 --- a/helm/templates/web-deployment.yaml +++ b/helm/templates/web-deployment.yaml @@ -1,26 +1,26 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "passbook.fullname" . }}-web + name: {{ include "authentik.fullname" . }}-web labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} - helm.sh/chart: {{ include "passbook.chart" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} + helm.sh/chart: {{ include "authentik.chart" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} - k8s.passbook.beryju.org/component: web + k8s.goauthentik.io/component: web spec: replicas: {{ .Values.serverReplicas }} selector: matchLabels: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: web + k8s.goauthentik.io/component: web template: metadata: labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: web + k8s.goauthentik.io/component: web spec: affinity: podAntiAffinity: @@ -32,36 +32,36 @@ spec: - key: app.kubernetes.io/name operator: In values: - - {{ include "passbook.name" . }} + - {{ include "authentik.name" . }} - key: app.kubernetes.io/instance operator: In values: - {{ .Release.Name }} - - key: k8s.passbook.beryju.org/component + - key: k8s.goauthentik.io/component operator: In values: - web topologyKey: "kubernetes.io/hostname" initContainers: - - name: passbook-database-migrations + - name: authentik-database-migrations image: "{{ .Values.image.name }}:{{ .Values.image.tag }}" args: [migrate] envFrom: - configMapRef: - name: {{ include "passbook.fullname" . }}-config - prefix: PASSBOOK_ + name: {{ include "authentik.fullname" . }}-config + prefix: AUTHENTIK_ env: - - name: PASSBOOK_SECRET_KEY + - name: AUTHENTIK_SECRET_KEY valueFrom: secretKeyRef: - name: {{ include "passbook.fullname" . }}-secret-key + name: {{ include "authentik.fullname" . }}-secret-key key: secret_key - - name: PASSBOOK_REDIS__PASSWORD + - name: AUTHENTIK_REDIS__PASSWORD valueFrom: secretKeyRef: name: "{{ .Release.Name }}-redis" key: redis-password - - name: PASSBOOK_POSTGRESQL__PASSWORD + - name: AUTHENTIK_POSTGRESQL__PASSWORD valueFrom: secretKeyRef: name: "{{ .Release.Name }}-postgresql" @@ -72,26 +72,26 @@ spec: args: [server] envFrom: - configMapRef: - name: {{ include "passbook.fullname" . }}-config - prefix: PASSBOOK_ + name: {{ include "authentik.fullname" . }}-config + prefix: AUTHENTIK_ env: - - name: PASSBOOK_SECRET_KEY + - name: AUTHENTIK_SECRET_KEY valueFrom: secretKeyRef: - name: "{{ include "passbook.fullname" . }}-secret-key" + name: "{{ include "authentik.fullname" . }}-secret-key" key: "secret_key" - - name: PASSBOOK_REDIS__PASSWORD + - name: AUTHENTIK_REDIS__PASSWORD valueFrom: secretKeyRef: name: "{{ .Release.Name }}-redis" key: "redis-password" - - name: PASSBOOK_POSTGRESQL__PASSWORD + - name: AUTHENTIK_POSTGRESQL__PASSWORD valueFrom: secretKeyRef: name: "{{ .Release.Name }}-postgresql" key: "postgresql-password" volumeMounts: - - name: passbook-uploads + - name: authentik-uploads mountPath: /media ports: - name: http @@ -103,14 +103,14 @@ spec: port: http httpHeaders: - name: Host - value: passbook-healthcheck-host + value: authentik-healthcheck-host readinessProbe: httpGet: path: / port: http httpHeaders: - name: Host - value: passbook-healthcheck-host + value: authentik-healthcheck-host resources: requests: cpu: 100m @@ -119,6 +119,6 @@ spec: cpu: 300m memory: 500M volumes: - - name: passbook-uploads + - name: authentik-uploads persistentVolumeClaim: - claimName: {{ include "passbook.fullname" . }}-uploads + claimName: {{ include "authentik.fullname" . }}-uploads diff --git a/helm/templates/web-service.yaml b/helm/templates/web-service.yaml index 6e35bf9e..0fcbbf9b 100644 --- a/helm/templates/web-service.yaml +++ b/helm/templates/web-service.yaml @@ -1,13 +1,13 @@ apiVersion: v1 kind: Service metadata: - name: {{ include "passbook.fullname" . }}-web + name: {{ include "authentik.fullname" . }}-web labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} - helm.sh/chart: {{ include "passbook.chart" . }} - k8s.passbook.beryju.org/component: web + helm.sh/chart: {{ include "authentik.chart" . }} + k8s.goauthentik.io/component: web spec: type: ClusterIP ports: @@ -16,6 +16,6 @@ spec: protocol: TCP name: http selector: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: web + k8s.goauthentik.io/component: web diff --git a/helm/templates/worker-deployment.yaml b/helm/templates/worker-deployment.yaml index b7bd4101..e5c2b659 100644 --- a/helm/templates/worker-deployment.yaml +++ b/helm/templates/worker-deployment.yaml @@ -1,29 +1,29 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "passbook.fullname" . }}-worker + name: {{ include "authentik.fullname" . }}-worker labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} - helm.sh/chart: {{ include "passbook.chart" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} + helm.sh/chart: {{ include "authentik.chart" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} - k8s.passbook.beryju.org/component: worker + k8s.goauthentik.io/component: worker spec: replicas: {{ .Values.workerReplicas }} selector: matchLabels: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: worker + k8s.goauthentik.io/component: worker template: metadata: labels: - app.kubernetes.io/name: {{ include "passbook.name" . }} + app.kubernetes.io/name: {{ include "authentik.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} - k8s.passbook.beryju.org/component: worker + k8s.goauthentik.io/component: worker spec: {{- if .Values.kubernetesIntegration }} - serviceAccountName: {{ include "passbook.fullname" . }}-sa + serviceAccountName: {{ include "authentik.fullname" . }}-sa {{- end }} affinity: podAntiAffinity: @@ -35,12 +35,12 @@ spec: - key: app.kubernetes.io/name operator: In values: - - {{ include "passbook.name" . }} + - {{ include "authentik.name" . }} - key: app.kubernetes.io/instance operator: In values: - {{ .Release.Name }} - - key: k8s.passbook.beryju.org/component + - key: k8s.goauthentik.io/component operator: In values: - worker @@ -52,20 +52,20 @@ spec: args: [worker] envFrom: - configMapRef: - name: "{{ include "passbook.fullname" . }}-config" - prefix: "PASSBOOK_" + name: "{{ include "authentik.fullname" . }}-config" + prefix: "AUTHENTIK_" env: - - name: PASSBOOK_SECRET_KEY + - name: AUTHENTIK_SECRET_KEY valueFrom: secretKeyRef: - name: "{{ include "passbook.fullname" . }}-secret-key" + name: "{{ include "authentik.fullname" . }}-secret-key" key: secret_key - - name: PASSBOOK_REDIS__PASSWORD + - name: AUTHENTIK_REDIS__PASSWORD valueFrom: secretKeyRef: name: "{{ .Release.Name }}-redis" key: "redis-password" - - name: PASSBOOK_POSTGRESQL__PASSWORD + - name: AUTHENTIK_POSTGRESQL__PASSWORD valueFrom: secretKeyRef: name: "{{ .Release.Name }}-postgresql" diff --git a/helm/values.test.yaml b/helm/values.test.yaml index e953b68b..7ea90c54 100644 --- a/helm/values.test.yaml +++ b/helm/values.test.yaml @@ -11,9 +11,9 @@ config: ingress: hosts: - - passbook.127.0.0.1.nip.io + - authentik.127.0.0.1.nip.io -# These values influence the bundled postgresql and redis charts, but are also used by passbook to connect +# These values influence the bundled postgresql and redis charts, but are also used by authentik to connect postgresql: postgresqlPassword: EK-5jnKfjrGRm<77 diff --git a/helm/values.yaml b/helm/values.yaml index 95bd92e2..825f13e5 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -1,16 +1,16 @@ ################################### -# Values directly affecting passbook +# Values directly affecting authentik ################################### image: - name: beryju/passbook - name_static: beryju/passbook-static - name_outposts: beryju/passbook # Prefix used for Outpost deployments, Outpost type and version is appended + name: beryju/authentik + name_static: beryju/authentik-static + name_outposts: beryju/authentik # Prefix used for Outpost deployments, Outpost type and version is appended tag: 0.12.11-stable serverReplicas: 1 workerReplicas: 1 -# Enable the Kubernetes integration which lets passbook deploy outposts into kubernetes +# Enable the Kubernetes integration which lets authentik deploy outposts into kubernetes kubernetesIntegration: true config: @@ -38,11 +38,11 @@ ingress: # kubernetes.io/ingress.class: nginx # kubernetes.io/tls-acme: "true" hosts: - - passbook.k8s.local + - authentik.k8s.local tls: [] # - secretName: chart-example-tls # hosts: - # - passbook.k8s.local + # - authentik.k8s.local ################################### # Values controlling dependencies @@ -52,9 +52,9 @@ install: postgresql: true redis: true -# These values influence the bundled postgresql and redis charts, but are also used by passbook to connect +# These values influence the bundled postgresql and redis charts, but are also used by authentik to connect postgresql: - postgresqlDatabase: passbook + postgresqlDatabase: authentik redis: cluster: diff --git a/icons/authentik-working.ai b/icons/authentik-working.ai new file mode 100644 index 00000000..5e803e4c --- /dev/null +++ b/icons/authentik-working.ai @@ -0,0 +1,1836 @@ +%PDF-1.6 %âãÏÓ +1 0 obj <>/OCGs[25 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + application/pdf + + + authentik-working + + + 2020-12-05T18:45:06+01:00 + 2020-12-05T18:45:06+01:00 + 2020-12-05T18:45:05+02:00 + Adobe Illustrator 25.0 (Windows) + + + + 256 + 212 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgA1AEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FWA+f/AMzY tDd9M0tVm1Sn72Rt44aivT9p6dug7+Ga3W6/w/TH6vuel7G7AOoAyZNsfTvl+ofjzePaprusatKZ dRvJbpqkgSMSq1/lX7K/QM0WTNOZuRt7vT6PFhFY4iPu/X1QOVuS7FXYq7FXYq7FXYq4Eg1BoR0O KGV+WfzJ8yaJKivO19Yige1uGLfCP99uasm3Tt7ZmYNfkxnnY7i6XX9hafUA0OCfeP0jr9/m9x0D XtO13TI9QsH5wvsynZ0cfaRx2YV/j0zosOaOSPFF871mjyafIccxv947wmOWuK7FXYq7FXYq7FXY q7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqlXmnWf0L5evtTAq9vGfSBFR6jkJHX25sK5TqMvhwMu5ze ztL+Yzwx/wA47+7mfsfNU00s80k0zmSaVi8jsalmY1JJ9znJEkmy+tQgIgAbALMWTsVdirsVdirs VdirsVdirsVZv+UnmCTTvMyWLvS01Mek6noJVBMTD3r8P05sOzc3Bk4eknnfaTRDLpzMfVj3+HX9 fwe7Z0b5w7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqwf845nj8mlV6S3MSP8hyb9 ajNd2of3Xxei9l4g6u+6J/U8KznX0d2KuxV2KuxV2KuxV2KuxV2KuxVNvKP/AClejf8AMdbf8nly 7Tf3sf6w+9we0v8AFsn9SX+5L6XzrXyR2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kux Vgf5z/8AKIJ/zFxf8RfNb2p/dfF6T2W/xo/1D+h4dnPPorsVdiqZ+V4YpvMukwzIskMl7bpJG4DK ytKoKsDsQRlunAOSIP8AOH3uH2hIx0+Qg0RCX3F6h+bWg6HY+VkmstOtrWY3UamWGGONuJV6jkoB ptm47SwwjjsADfueP9m9ZmyampzlIcJ5yJ7kf+W3l3y/eeStOuLvTLS4uH9bnNLBG7tSeQCrMpJo BTLNDghLCCYgnfp5uN27rs8NXOMZzjEcOwkQPpCV+XPy0vbXzfcXupWVrLo7tOYoGCSKA7Ex/uyK Cg+7KcGgIykyA4d3M13b0J6UQxykMvps7j37ov8ANPQNCsvKMs9nptrbTiaICWGGON6FtxyVQcs7 QwwjisRAPuaPZ7W5smqEZzlIUdjIl4tmgfQHYq7FU18pf8pXov8AzHW3/J5cu0397H+sPvcHtL/F sn/C5f7kvpjOtfJHYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYqknmrzdpXluxFxesXlkqLe2S nORh4V6KO5zH1GpjiFl2HZ3ZuTVz4YchzPQPJtT/ADh83XUpNm8VhFX4UjjWRqf5TShq/QBmkydp 5SdtnttP7MaWA9dzPma+6lXRPzj8yWtwv6U4ahbE/vPgWKUDxUoFX71yWLtTID6vUGGr9l9POP7u 4S+Y+3dkv5ma5puteQIL/T5fVge7jBHRlYI9VcdmGZevyxyYBKPK3U9g6TJp9cYZBRED943DxzNE 947FXYqmvlL/AJSvRf8AmOtv+Ty5dpv72P8AWH3uD2l/i2T/AIXL/cl9I3un2F9D6N7bRXUIIYRT Isi8h0PFgRXfOrnCMhRFvlGLNPGbhIxPkaXWtpaWdutvaQx29uleEMShEWpJNFUACpNcYxERQFBG TLKcuKRMpHqdyq5Jgwr83/8AlC5v+M0P/Es1/af9yfeHoPZn/Gx/VLwfOcfSXYq7FU18pf8AKV6L /wAx1t/yeXLtN/ex/rD73B7S/wAWyf8AC5f7kvpjOtfJGF335ueT7S7ltuc9wYmKmWGMNGSOvEsy 1+fTwzXz7SxRNbl6DD7NaqcRKoxvoTuoD85vKBIBW7APcxLt9z4P5UxebYfZbVf0Pn+xmlhf2eoW cN7Zyia1nXnFKtaEfI0I9wcz4TEhY5F0GbDPFMwmKkOavkmp2KuxVSjvLSSeS3jnje4ioZYVZS61 6clBqMiJAmr3ZyxSERIg8J5FbaahYXgc2dzFciJjHKYXVwrjqrcSaH2xjOMuRtOTDPHXHExvvFK+ Sa3Yq7FXYqh49QsJRMY7mJxb1+sFXUiOla86H4eh65ETib35NssExVxPq5bc/cpfpvRhYLqBvrcW Dmi3RlQRE1pTmTxrXbI+LDh4rFd7P8pl4/D4JcfdRv5IxHR0V0YMjAFWBqCDuCCMsBaCCDRfPHmD U73zZ5vYQgSm4nFtYKS1Fi5cU6HYftHOXzZDmy7dTQfUdFp4aLS77cMeKXv6/qD2jyz5H0HQbRI4 bdJrun768kUM7N3pyrxX2Gb7BpIYxsN+94HX9rZtTIkkiPSI5ftQPnX8vdL120eW1hjttVQExTKO KyH+STjSoPj1H4ZXqtFHILAqTkdldt5NNICRMsZ5ju8w8QDXVvLLpF2xsoXmVbxGDNweMleRWvVO R6Zz249B233fQiIyAyx9Z4fT5g+fmzxfyP1FlDLqsDKwqrCNiCD3G+bL+SZfzg84fa3GP8nL5t/8 qN1P/q6Q/wDIt/64/wAkS/nBH+i7H/qcvmHf8qN1P/q6Q/8AIt/64/yRL+cF/wBF2P8A1OXzCM0b 8m9Q0/WLG/fUoXW0uIp2QIwLCNw5ANe9MsxdlyjMS4uRaNV7UY8uKUBAjiiRz7xT1TNy8Y7FXYqw z83E5eSbk1+xLCf+HA/jmB2kP3J+Dv8A2aNayPuP3PBc5t9KdirsVTXyl/ylei/8x1t/yeXLtN/e x/rD73B7S/xbJ/wuX+5L3fWfNF1ZX89va2IuoLCBbrUpWlWJkiflT01YfGaRsTuPDOjy6gxkQBfC LL5xpez45ICUpcJnLhjtdnbn3cw8o8v/AJXeZL26tJL20MWmXCh2uBJFVUdKq3APz7jamaXD2fkk RY9Je11vtDp8cZCEryR6UeYPfVNfmX5Q0zy3c2Mdh6hS5SRpGkYMeSsNhQCgFcdfpo4iOHqvYPae TVxmZ16SOTOfKevwaB+VFpqcqep6ImEcQNC7tcyKq1+Z39s2OmzDHphI+f3vO9paI6ntOWMbXW/c OAJbefmB+YthoyaveaTaJY3XE2svxHiHoV5oJeVGXpWm/wB2VS1ueMOMxFH8d7l4uxdBly+FDJPj jzHu7jw9Pin+vec9U0+DyxJDFAx1poluuauQocRk+nRxT+8PWuZObVSiIVXr5/Y6zR9lY8pzgmX7 q65dL57eXki7jzTqEf5gW3l1Y4jZTWxnaQhvVDBXNAeXGnwD9nJy1EhnGPpTRDs+B0MtRZ4xKvLp 5efexfybL5jP5l6v9Zht1ZwP0nwLfAoT916VWO5bjyrXvmHpTk/MSuvP9FO47Ujp/wCT8fCZf0Pn vf20lHlvXvNum2uvtodjDPbWl1LdXtxOSaKP2VUMlfhQk9cowZssBPgAIBsudr9HpcssXjSkJSgI xA/Tse9m8v5hQw+RbfzNLb/vbn91HahqAzB2Qjlv8P7tm+WbE60DCMhHPp5vPR7EMtadODtHe/Kg f0gMcg/NPXbOa2uNXXTZ9PuWUPHYzq88IYVqyrJIdh1qPaozEHaE4kGXDwnuO4+12s/Z7DkEo4vE E4/z41E/7EJ55x86a/pXmLT9J0e0gvTfxBkWTlyLszKtGDqoUUBNR9OZOq1U4ZBGABt13ZfZWDNg nlyylDgP2beXNvyr5v8AMd7rGp6Bq9pBDq9lCZ4jFX0z9niG+Jtj6ikEHpjp9TklOUJgcQC9o9ma fHihnxSkcUzRvn18vIsV8jyavTzh9Zt7d7Vo7htRQlv7+klEWjA+mfjr398w9IZfvLAre/fu7nte OL/B+EyErjw8vp235c+X6lt9LFL+TMDxwR26m63ji5laiVhWsjO1T88EyDpBtW/6U4YkdrEEmXp6 13DuAZLoXnLVtX1W10vy/BC+m2UMQ1LUJ1cgEAArFxZN9qCvXr0GZeHVSnIRgBwgbkup1nZeLBjl kzk+JMnhiK+3Y/jzeY+TJU0nzvYfXf3Yt7kwzE9FY1i39gxzUaU8GYX0L1/akTm0c+DfijY+99F5 1L5W07pGjO7BEQFmZjQADckk4k0kAk0Hzt5z1OLzF5vuJtMhqs8iQW4QfFMy0RX+bnp7UzltVkGX KTEc/tfUuy9OdLpQMh5Ak+XWvg990SxksNHsbGV/UktYI4XfxKIFJ/DOlxQ4YAHoHzTV5hkyymBQ lIn5lG5Y47sVdirsVdirsVYd+bX/ACg95/xkh/5OrmD2l/cn4O99m/8AHI+6X3PA85p9MdirsVTX yl/ylei/8x1t/wAnly7Tf3sf6w+9we0v8Wyf8Ll/uS+h9S8u6JqdxFcX9nHcTQiiM4P2QeXFgDRl rvRqjOoyYITNyFvl2DXZsMTGEjEH8fD4PFdC8j69eeZG0bUZLmxCK5NxxdkPDoVJKghuxrnP4dJO WTglYfQNZ2tgx6fxsYjPltsivO/5b3WiW9pLbXU+qSTyGP0xESV2r+yX65PV6E4wCCZW09k9ux1E pCUY4wBfP+xm1l5Kvbz8r7fQLmltfhWmQP0SQzNKqvSvZqHwzYQ0plpxA7S/bbz+XtWGPtE54+qH L4cIG36Ei1PRfzU1by/HoV1Y26W1rwHreqnqTiLZAT6jD/KNQPvzHyYtTOHAQKH2ux0+q7Nw5zmj KRlK9qNRvn0+HVkHmryjrF95c0Q2AU6voohdIGK8XZVUMvIkLsyA7mmZOo005Y48P1Qp1nZ3aWLH qMvH/dZb37tz+tT8t6D5qvvN58zeYbeKxeCD0La2iZXrUEV+Fn2HI9TgwYcksviZBVBlrtZpsel/ L4CZ3KyT/YEx0LQNTtPO+u6rPGFsr5IxbSBlJYqFBqoNR075bhwyjmlI8i4ur1mOejxYgfXC7Qnl jyxrFhoHmKzuYlSfUJbl7VQ6kMJY+K1IO2/jkNPp5xhMHnK6b+0O0MWTPhnE+mAje3cUJ/gHUbz8 tLTQbnjb6naSPPGpYMnP1ZCqllrsySfRkPycpacQO0h+1u/lnHj7Qlnj6scgB8Kj+kJfpPlfzNPf WsF95W0i0tYyBeXTojl1HUqscjGpHTalfuyrHp8hIBxwA6/i3K1PaGnjCRhnzSkfpFnb5j8BkWs+ XNUuPP2iatbxKdOsYTHO/JQVP7ygCk1P2h0zKy4JHPGQ+kB1el12OOiy4pH1zNj7Hab5d1WD8ytW 1ySIDTbq1WGGXkpJcLACONeQ/u2xx4JDUSn/AAkfqRn12KXZ+PCD+8jOz/sv1hCeXfKutWVv5sS4 hVW1VpTZUdTyDiUCtD8P2x1yGDTziMl/xcvtb9d2jhyS05if7uuLb+r+pL5fJXmFvyyi0IQL+kku DI0XqJTj6jN9qvHocqOkyfl+CvVblR7VwDtE5r/d8NXR7kVpXlTzF5X1eG50SMXWk3iJ+ktOaRVM cgADPGXIB36fd4HJ49NkwyuG8TzDRqO0cGsxGOY8OSJ9Mq5juNfjqx781/I1zDeya/p8RktZ/ivo 0FTHJ3kp/K3fwPzzF7R0hB448jzdr7OdrxlAYMhqQ+nzHd7whPLv5xatptmlpqFsNRSIBY5i5jlC jYBm4uGp8q5DB2nKAqQ4m7XezGLLMyxy4L6VY/RSA81/mdrnmCE2UUYsbGTZ4ImLvJ7PJRaj2AHv XK9Tr55RQ2Dk9ndgYdMeMnjmOp5D3BG6ToGp+UNAPm66t4zqQdI7G0uVYiNJKgyOqsh5kdBXb59J 48MsEPFI9XQFx9TrMeuz/lYk+HR4jHrXQc9vvXf8rt81/wDLJY/8i5v+quH+VsvdH7f1o/0J6b+d k+cf+Jd/yu3zX/yyWP8AyLm/6q4/ytl7o/b+tf8AQnpv52T5x/4l3/K7fNf/ACyWP/Iub/qrj/K2 Xuj9v61/0J6b+dk+cf8AiXf8rt81/wDLJY/8i5v+quP8rZe6P2/rX/Qnpv52T5x/4l3/ACu3zX/y yWP/ACLm/wCquP8AK2Xuj9v61/0J6b+dk+cf+Jd/yu3zX/yyWP8AyLm/6q4/ytl7o/b+tf8AQnpv 52T5x/4l3/K7fNf/ACyWP/Iub/qrj/K2Xuj9v61/0J6b+dk+cf8AiUt8xfmdr+vaVJpl5b2scEpV maFJA9UYMKFpGHbwynPr55I8JAr8ebl6HsDBpsoyQMzId5H6gxDMJ3rsVdiqa+Uv+Ur0X/mOtv8A k8uXab+9j/WH3uD2l/i2T/hcv9yX0xnWvkjsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVcQCK HcHqMVYvqf5Z+TdRmM0liIJWNWa3Zogf9gp4fhmHk0GKRuq9zuNP29q8QoTsee/281bRPy/8qaNM Li0sg1ypqk8xMrL/AKvL4VPuBXJYtFixmwN2Gr7a1OccMpenuGyV/nB/yhkn/GeL9ZyntP8AuviH M9mP8bH9UvCM5x9IdirsVdirsVdirsVdirsVdirsVTXyl/ylei/8x1t/yeXLtN/ex/rD73B7S/xb J/wuX+5L6YzrXyR2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVhX5vqx8lzECoWeEn 2HKn8c1/af8Acn3h6D2ZP+Fj+qXg+c4+kuxV2Ksj0b8v/NGs6el/YWySWshYI5lRTVTxOzEHqMys WiyZI8URs6nVdtabBMwnKpDyKN/5VL54/wCWOP8A5HRf81ZZ/Jubu+1x/wDRJo/5x/0pd/yqXzx/ yxx/8jov+asf5Nzd32r/AKJNH/OP+lLv+VS+eP8Aljj/AOR0X/NWP8m5u77V/wBEmj/nH/Sl3/Kp fPH/ACxx/wDI6L/mrH+Tc3d9q/6JNH/OP+lLHtb0PUdEvjY6jGIrlVVyoZXFG6bqSMxcuGWOXDLm 7TSavHqIceM3FAZW5TsVTbyl/wApXo3/ADHW3/J5cu0397H+sPvcHtL/ABbJ/Ul/uS+l8618kdir sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVSbzlo7ax5Z1DT4xWaWPlCPGSMh0H0stMx9 Vi8TGYuf2Xqhg1EJnkDv7jsXzYysrFWBVlNGU7EEdjnKPrINtYpdir3z8pf+UHs/+Mk3/J1s6Xs3 +5HxfM/aT/HJe6P3MxzOdE7FXYq7FXhH5wf8pnJ/xgi/Uc5ztP8AvfgH0j2Y/wAUH9YsJzXvQuxV mP5U6JJqPm2Cfj/o+ng3ErduQ2jHzLb/AEHM7s7FxZQekd3Q+0WrGLSmP8U9h+n7HvmdK+aOxV2K uxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV5f+Y35Yz3lxLrOhoGnk+O7shsXbvJH2qe 69+2+afXaAyPHD4h7DsPt+MIjFmOw+mX6D+t5LPBPBK8M8bRTIaPG4Ksp8CDuM0pBBovbQmJC4mw VmBm98/KX/lB7P8A4yTf8nWzpezf7kfF8z9pP8cl7o/czHM50TsVdirsVeEfnB/ymcn/ABgi/Uc5 ztP+9+AfSPZj/FB/WLCc170Kb+XvKut6/ciHT7cslaSXLVWJB4s/8Bvl2DTzyGohwdb2jh00byHf u6n4Pe/KXlWx8t6Utlbn1JXPO5uCKNI9OvsB2GdLptOMUaD5p2l2jPV5OOWw6DuH45p3mQ692Kux V2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxVB3+j6TqIAv7KC6pspmjVyPkWBplc8U ZfUAW/DqsuL6JSj7iQgP8EeUP+rPaf8AIpf6ZX+UxfzQ5P8AK2q/1SfzTSxsLKwtltrKBLe3QkrF GAqgk1Ow98uhARFAUHEzZp5JcUyZS7yr5JqdirsVdiqW3/lny/qFwbm+06C5nICmWWNWag6Cpyme CEjcgCXLw6/PijwwnKMfIqEfkzylG4ddHtOS7isKH8CMiNLiH8I+TYe1dURXiT/0xTeKKKKNY4kW ONdlRQAAPYDLwAOTgykZGzuV2FDsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdi rsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdir sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirs VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsV dirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVd irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdi rsVdirsVdirsVdirsVdirsVeM+f/AMzdVm1OfTdGuDaWVs5ie4iNJJXU0Yhx9lQR8PHr19hodbr5 GRjA0A992N2BijjGTMOKct6PIfDv77Yb/i3zX/1er7/pJm/5qzA/M5f50vmXe/ybpv8AU8f+lj+p 3+LfNf8A1er7/pJm/wCasfzOX+dL5lf5N03+p4/9LH9Tv8W+a/8Aq9X3/STN/wA1Y/mcv86XzK/y bpv9Tx/6WP6m/wDFvmv/AKvN9/0kzf8ANWP5nL/Ol8yv8m6b/U4f6WP6no35ZfmPqF/fpomsyevJ KD9TuzQOWRamN6bNVRs3WvjXNroNdKUuCfwLyvb/AGHDHDxsQoD6h+kfq+56jm4eOdirsVdirsVd irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdir5TckuxPWpzjC+0Dk1illKfl1 rba5a6MJ7b61d2gvY3LSemIySKMeFeXw+H05mDQz4xCxZFumPbmEYZZqlwxnw9Lv58lg/L3zANGv 9WlEcEGnu6SxTeokj+mAS0YKUKnlsajB+SnwGR2EWX8tYPFhiFkzA5UQL6HfmxnMR26b+TiR5s0Y g0/023H3yqMv0v8Aex/rBwO1P8Vyf1Jfc+ls6x8ldirsVdirsVdirsVdirsVdirsVdirsVdirsVd irsVdirsVdirsVdirsVdirBPOXmTX59eg8q+WnWK+kUSXd0w3jUitASCFAXcnruKb5rdVnmZjHj5 9XpOy9BgjgOp1AuA+kd/4P7UrvX8++SpYdSvNROtaOxC3iPyJSpptXkV9mrSvUdMqmc2nPETxx6u ZiGi14OOEPCy/wAPn+v3fJ57a+SfNV7bpd2mnSzW0w5RSoVIYH6c1cdJkkLA2enydrabHIxlMCQV v+VeedP+rTN/wv8AXJfks380sP5b0n+qRe/2FhAkNrLLAgvIoEi9UqpkUBd159aVr3zpYQFA1vT5 nmzSJkATwGRNdPklvn1S3k3VwNz9Wc/dvlWs/upe5yuxzWrx/wBYPnDOVfV048nKW82aMBufrtuf oEinL9L/AHsf6wcDtQ/4Lk/qS+59K51j5K7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7F XYq7FXYq7FXYq7FXmnmtrryr57TzWbZrjS72JYLxk3KMFCd9hsikV67jNTqbw5vFq4nm9b2cI6zR HS8XDkgbj59f0m+5R8z+fh5oth5d8sW0s9xf/DcSyLxCR1BIG5/2THYD8I6jWeMPDxizJs7P7G/J y8fUyEYw5Ad/45BV8vfmT5N0DRrbR1+tyfVFKPJ6S/E5Ys5Hx9C7GmHDrsWOAhvsw1vYWr1OWWX0 Di8+nTp3Jj/yufyh/Jd/8il/5ry3+VMXm4v+hbVd8Pn+x3/K5/KH8l3/AMil/wCa8f5Uxea/6FtV 3w+f7Et8yfmv5Y1HQNQsLdLkT3MDxRl41C8mWgqQ5yrP2jjlAxF2Q5eh9nNTizwnLhqMgef7Hj+a N7pO/JP/ACl+j/8AMXF/xIZkaT+9j73Xdrf4rk/qF9JZ1b5O7FXYq7FXYqpXV1bWlvJc3MqwwRDl JK5CqoHck4JSERZ5M8eOU5CMRcixqH80fI8ryKNRCemGbk8cqghevGq7nwHU5hjtDCert5ez+sAB 4OfmP1pvbeZdFudFfWobnnpkau73HCQUEZIY8Cofanhl8c8DDjB9LgZNBmhmGEj94a2sdeW/J1t5 l0W50V9ahueemRq7vccJBQRkhjwKh9qeGMc8DDjB9K5NBmhmGEj94a2sdeW/JLNc83aS3ku91ixv CIZYZobO5VJFb6wVZEABUMp5jqRTKs2pj4JmD0Ne9zNJ2blGrjinHcEGQsfTsT1rkx7yP5qk1DyV qcM1/LPrNrBczOzF/UROJ9NhIRTr0odsxdJqOLDIE3MAu07W7OGLVwIgBilKI6V57IbyH+Ymlaf5 c/52DVJJr17iTiJDJcS+mFWlT8RArWlcho9bGOP1y3v3tvbHYmXLqP3EAIcI5VEXv7no2mapYapZ R3thMs9rKKpIte2xBBoQR4HNrjyRmLibDyuo088MzCYqQSLUfzJ8m2F29pPfhpozxk9JHkVSOoLI CKj2zGnrsUTRLscHYWryx4ow2PeQPvT7TtTsNSs0vLCdbi2k+xIh226g9wR4HMmGSMxcTYddnwTx SMJjhkGPy/md5IjjdzqQJjbgyCKbnX2UoNtuvTMY6/CP4vvdnHsDWEgcHPzj+tNbDzPoV/pE2r2t 0JLC3V3uJAGrGI15PySnKoXelPll0NRCUeIHYOFm7PzY8oxSjU5VXnfLfkwb8sfOTXuuanY6hqMt xJdyA6VFL6jAonqvJxqKJ8AXrTNdoNVxTkJG75fa9H2/2X4eGE8cBERHrIrrwge/e00/LiW6k1HX fV106uqSqPSIn/dEs+59ZECk8acY6jb5ZdoSTKVy4vn+n9Dh9uRiMeKsXhWOfp35fzSfnLf7UD+V muXktvr1xqt/LNBaSI3qXMrOI0ActTmTQbZX2fmJEzI7Dvcj2h0kYyxRxQAMh/CKs7dzM9D8zaJr qSvpU5uI4SFkf0pY1BO9KyKgJ+WZ+HUQyfSbdBq9Bm0xAyjhJ8wfuJTTLnDdirsVadEdCjqGRhRl YVBHuDiQkEg2FC007T7Pl9Utorfn9v0kVK08eIGRjCMeQpsyZ5z+qRl7zb5fu/8Aeub/AIyN+s5x 8uZfYMX0j3Kmm6bfanex2NjEZ7qbl6cQIBPFSx3YgfZU4ceMzNR5sc+eGGBnM1EJ7/yrXzx/1apP +Di/5rzJ/IZv5rrv5e0f+qD5H9Tv+Va+eP8Aq1Sf8HF/zXj+QzfzV/l7R/6oPkf1Mdurae1uZrW4 T054HaKVDQlXQ8WG3gRmLKJBo8w7THkjOIlHeJFhN/JAJ836PQV/0uI/cwy/Sf3sfe4Pa3+K5P6h fSWdW+TuxV2KuxV2KvP/AM6pblPK9ukZIhku0E9OhAR2UH25CuaztUnwx73p/ZSMTqSTzEDXzChr fl3yLH+Xct1bRW4C24e2vV4mZrjiOKl/tVZtmX8MjlwYRgsVy5+bZpNbrTrxGRl9W8enD7vLoVPy 2CfyZvABU/V7w7eAZycGD/FD7iy15/12j/Wh+hQ8vXlon5NXytMgZI7mJlqKh5GPBSPFuQpkcMh+ UPxbNbike1o7dYn4DmifLVpDL+Tdz9YhWRRbX00QdQ1GT1OLivQqw2OSwRB0hvul+lq1+Qx7Wjwm vVAH/Y7LPy/tLRfy11K6WGNbmSG7SScKBIyqhopalSBjooj8vI1vuy7ayyPaEI2eEGG3Tml/5d+V 9DvfJOqX17apcXLmaNJJFBMaxxArwP7J5GtRlWi08JYZSIs7uT232hmx6zHCEjGPpPvs9e9Efl7P dxflfr0tszCeJ7owkdVItozVfl1yWiJGmnXPf7g19twie0cQlyIjf+mLH/JGja/eaTNLpmnaVexF 2SeS9UPKpoDx+I/CvcUzG0mKcokxET73Z9rarBDKBknlga24dgzT8qvL2taOL5rl4H0674vb/V5h MgdSQeNCexp17Zn9nYJwu64T3F0HtFrcOfh4RLjjzsUaSL8qPLujanca1PqNrHdtE6xxLKodVDly xAPfYb5jdnYITMjIW7H2j12XDHFHHIxsXt5UjfyetofV8zae6iW0WSKMxSAMrKTMjBgevJVocs7M iLnHp/a0e08zWCY2lRN/6UrfyhsbJta8wytbxGW1mjFrIUXlEGadWEZpVarsaYOzIDjma5H9afab NPwcIs1KJvfn9PPvVvyg/wCOr5o/4zxf8TnyXZn1T94/S1+0391g/qn7oPOEfUxZXqD1V0R7yP8A SLwgE1q3DluO3KgO1fozVAyo/wAy93qyMfHE7eNwHhv4X+OdPfvK0WiR6DaLohVtN4VhderH9ov0 +Ov2q986bTiAgOD6XzPtCWY5peN/eXv+zy7k1y5wnYq7FXYq7FXi35hfltqttqk+paTbvd2N0xle KIFpInY1YcBuVruCOnT56DW6GQkZRFgvoHYvbuKeMY8p4Zx2s8iPf3pf+WOm6jB55015rWaJF9fk zxsoH+jyDckeOVaCEhmjYPX7i5Pb+fHLRzAkD9PX+kHvOdI+bOxV85eaNJ1WbzVq5isp5A99clCk TtyBmalKDfOV1GORySoH6j976r2fqcUdNjuURUI9R3BnP5Yfl3qNnfpresQm3aIH6nav9vkwoXdf 2aA7A75sez9FKMuOe3c877Qdt48kPBxHiv6j09weqZuXjHYq7FXYq7FULqml2Oq2EthfRCa2mFHQ /eCD2IPQ5DJjjOPDLk3afUTwzE4GpBhEP5K+WVeT1Lq7kiYERx80XiSKVqE3IzXjsrH3l6GXtXqC BUYA/H9bLdC8uafo2jLpFuXmtF519cqzMJCSwbiqim/hmbhwRxw4RydJrNdPPl8WVCW3LyYlN+Sv lp7sypc3UduTX6urIaewYqTT51OYR7Kx3dmndx9q9QI0YxMu/f8AWzJtD0/9ByaJEhgsXt3tQsez LG6lCQSG+LetT3zO8KPBwDYVToRq5+MMx3mJcW/eDaE0jynp2laBNodvJM9pMJVZ5GUyUmFGoQqr 8tshi00YQ4BdN+p7SyZs4zSA4hXLlt8XaD5T07RNFm0i1kme2nLs7yspcGRQpoVVR0Hhjh00ccDE XS6ztLJqMwyyA4hXLlt8W/K/lXTvLmnS2Fk8ssEsrTOZyrNyZVQj4VQUog7YdPp44o8IR2h2jk1W QTmACBW3xPee9jmo/k75cubp5rW4uLFJTWS3iKmOh6hQwqB9OYk+zMZNgkO1we0+eEQJCM66nmyn y95b0ry/YCy06MrHXlJI55SO3Tk52/pmZgwRxRqLp9brsmpnx5Dv9g9yG8seT9M8ufW/qMs0n1x1 eX12RqFa048VT+bI6fTRxXV7tvaHaeTVcPGIjh7r/WXeW/J+meX7i/ns5ZpH1F1ecTMjAFS5HDiq f78PWuODTRxEkX6l13aeTUxhGYiODlV+XPc9zvLfk/TPL9xfz2cs0j6i6vOJmRgCpcjhxVP9+HrX HBpo4iSL9S67tPJqYwjMRHByq/Lnue53lvyfpnl+4v57OWaR9RdXnEzIwBUuRw4qn+/D1rjg00cR JF+pdd2nk1MYRmIjg5Vflz3Pco6J5D0PSbXULVPVurfUyDdRXJRhtXYcVSn2sji0cIAjmJd7Zq+2 M2eUJGoyx8uG/wBZVPLHk6x8t+ulhdXT2055G1ndHjVv5koisDTbrv3w6fSjFfCTXcw7Q7UnqqM4 x4h1AN/HdrzH558u+XpEhv5yblxyFvEvNwp/aPQAfM459XjxbSO6dD2Rn1QuA9PedghrT8yvJ93P aQQXhae9dYoovTfkHZgoVtvh3PyyEdfikQAdy25ewdVASlKO0BZ3HLyTPV/M+j6ReWdnfStHPftw tlCMwY8gu5ANN2HXLsuohAgS5lxNN2flzwlOAuMOe7tb8z6Pos1pDqErRyXzMlsFRn5FSoNeINPt jrjl1EMZAl1XSdn5dQJHGLEOe/v/AFMG/ObzA9oLCws7uW3vd55BEzpWJqotWWgPxIds13ambhqI NF6L2W0QnxznEShy3o78/wBKr+YGvw6h+XtrqOmXEgje5jT1RyjYlVdWHY9Rh1uYSwCUT1Ydi6I4 tdLHkAvhO3PuR2m/ml5StLPT7K4upHmW3hSedUZkV/THLk3U79aA5bDtDFEAE9A4+f2e1U5znGIr iNC96tmxurUWpuzMgtQnqmcsOHp05c+XTjTeuZ/EKvo88McuLho8V1XW+5iL/m75LW69D15WStDc CJvT/wCa/wDhcwj2lhurd4PZrVmPFQ917/q+1lcWo2U1gNQhmWWzMZlWZPiBQCpIp8szBMGPEOTp ZYJxnwEVK6pi035teSo4BKt1JKSxURJE/PYA1IbiKb+OYZ7SwgXbuY+zerMq4QPiKTu081aHd6HJ rdvcc9PhVmmcK3JOAqyslOVRmRHUQlDjB9Lr8vZ2aGYYZD1nl535vPvyl80iXV9QsL68mnuL5kOn pKzyCkQleShNQvw0zWdm6i5mMibPL7Xp/aTs6sUJwiBGF8VUOfCB705/LqGKPX9fCaxJqLrLSSF0 kXi3NhyYvsW24/Dl+hFTn6uJwO3JE4MV4xDbnt3Du+e6XflbqEp1nzK93csYIGDcpXPFFEklT8Ro BQZX2fM8c7Ow/a5PtDhHhYRGO57hz2DNdB836Hr000WlyvP6ArK/puqCpoPiYAVPbM/DqYZCRHo6 DWdmZtMAcgAvluLTrMh17sVdirsVdirsVdirsVdirsVdirsVdirsVdirsVeS+WrTTNQ/NPXF1pFn uEkm+pQzgMrcZOK/C2zFYqcR4b9s0mCMZamXHz3r8e57fX5MmLs3F4JqNR4iPd/xXNT84WGiWf5m aAmmpHDI1xatdwwgKqv644kquysV6j6cGphCOohw94v5suzM2bJ2flOQkjhnwk/1Uw/NiRIfMnle eU8IY5izuegCyxFj9Ay3tE1kgT3/AKQ4vs5Ey0+cDmR+iTX5uTRSa35YgjYPMJncxrueMkkIQ0H8 xU0x7SIM4D8dF9mokYc5PKh9glbvzxhh+oabP6a+sZWQy0HIqFqF5daVPTHtYDhiU+yUzxzF7Uiv zUtre38g2sVvEkMYuISEjUKtSjkmgp1yXaMQMAA7w0+zs5S1sjI2eGXP3hL/ADT5Z0Wy/Ky0uIbW NbtI7aY3QUCRnm48yW6kHl0J8PDK9Rp4R0wIG+27ldna/Nk7SlEyPDchXShdKvmma6T8ntLEJbg8 dqk5H+++NaH25BcOoJ/Kxrya+z4xPas76Gde/wDstKNJ0TzPceU0Fvp+jPpU0RZrqUAS9CGd5CwK uu+/bKMeLIcWwhw97nanV6aOpPFPN4gPIcvcB3H7WWeSND1nRvJ+pWmotG0bLLLaGKQSLweLehHa ormdpMU8eKQl8HS9ravFn1UJY7vYGxXVJfyk8s6LqXlzUbi9tY7iaad7bnKocoixI3wV3U1kO49s x+zcEJ4ySL3pz/aXX5sWohGEjECPFt1Nnn8lf8k445tE1WGZRJC06honAZSClDVTtvkuyhcJA97X 7VyMc2MjY8P6UP8Akta2zX+uytEjSwPCIJCoLJyMwbgeq1Gxpkeyojime6v0tvtVkkIYgCaIlfn9 PNFflb/ylfmr/jP/AMzpcl2f/e5Pf+ktPtD/AItg/q/72Lzq4bWB+nRacxYNcAaiY/5fUf0+Xfjy r7Vpmrlx+uvpvd6qAxfuuKuPh9PyF/F7j5ETy+nlq1/QR5WZFXZv7wy7c/V/y/8AMbUzodGIeGOD k+ddsHOdRLxvq+yuleX4O7IMynWOxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV5P+av8Agv8A S0fq/WP07RfW+p8OlBw9Xl+3SnGm9OvbNL2j4PF14/L9L2vs7+b8I1w+D04r+NeXf/akeg/4D/TW h/V/0l+kvrcXP1PSp63rLx9Sv7Nf5d/pzHw+Dxxri4r/AEux1n53wcvF4fh8B5Xy4en7XoP5p/4X /Qcf6c9Xn6h+o/V6etzp8XHl8PGn2q/rpmz7Q8Pg9fwrm8x7PfmfGPg1y9V8q/X3fqtgf5d/4G/x Jbev9a+u81+ofWOHo+r+zXhvyr9mu1fozW6LwfEF3fS3pO2/zn5eVcHB/Fw3ddefTvZl+bn6D/R2 n/pb6z6frP6X1T0+XLjvy9Ttmf2lwcI4r59HQ+zXjeJPwuG6/iv9CI/Mr9D/AOELf9JfWPqnrQ8f q3D1OXBuNefw0p1yWv4PCHFdWOTV2D4v5o+Hw8VH6rrmO53m39D/APKuIfrf1j9HejacfS4evx+D hXl8FelcdTwflxd8ND3r2b4v588PD4lz53XW/NMbD/D/APgG2+u/8cT6knqfWacvS4inLj+10px7 9MthweAL+jh6uLm8f87Lg/vuM/T3/Hp7+nN41P8A4N9eX6v+lf0T6g5U9L+O1fCu+aE+Fe3Hw/B7 2H5vhHF4Xi15vadF/wAO/wCD/wDcN/xyfQl48Pt9Dzry351rWub/ABeH4Xo+mnz/AFXj/mv3397x D3eXwSr8pv0P/hy5/RX1j6v9cfn9a4c+fpR1p6e3GlMp7N4PDPDdX1+Dm+0ni+PHxeHi4B9N1Vy7 0P8AlH+g/wBHah+ifrPp+snq/W/T5cuO3H0+2R7N4OE8N8+rb7S+N4kPF4br+G/0qX5UfoH6xrn6 K+tcucP1j616dK1l48PT+mtcj2dwXLhvpz+LP2j8bhxeLw8pVw3/AEedqv5efoP/ABH5j/R/1n6x 63+lfWPT4cvVk/u+G9K165LRcHiT4bu97+LDtvxvAw+Jw8NbVd8hztCeQP8AC317zJ6Pr+lQ/pH6 76XpcOUnKnH9nrXl2yGj8PinV+d/Fu7Z/M8GG+G/4eG7vb8bLPy3/Qv+IL7/AA7+kP0bVvX9b0/q vU+nxr+85fy96dcGh4OM+HxcP2M+3fG8CP5jw/E6VfF5+Xv6Xyel5tnkXYq7FXYq7FXYq7FXYq7F XYq7FXYq7FXYq7FXYq//2Q== + + + + uuid:775a4d7f-a10b-459d-af7e-266e382541d6 + xmp.did:03fd4a37-9d8f-574a-82f0-4661c79cca29 + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + uuid:4b615770-c627-4ab3-9353-d0e15b2b064c + xmp.did:ea5770b6-6a3a-1648-9dc9-559c817e39ce + uuid:5D20892493BFDB11914A8590D31508C8 + proof:pdf + + + + + saved + xmp.iid:081fa249-16ae-9446-959f-dd9f3aed7a7f + 2020-12-04T23:22:51+05:30 + Adobe Illustrator CC 23.0 (Windows) + / + + + saved + xmp.iid:03fd4a37-9d8f-574a-82f0-4661c79cca29 + 2020-12-05T17:08:41+01:00 + Adobe Illustrator 25.0 (Windows) + / + + + + Print + AIRobin + Document + False + False + 1 + + 994.714113 + 151.653500 + Pixels + + + + Magenta + Yellow + + + + + + Default Swatch Group + 0 + + + + White + RGB + PROCESS + 255 + 255 + 255 + + + Black + RGB + PROCESS + 35 + 31 + 32 + + + CMYK Red + RGB + PROCESS + 237 + 28 + 36 + + + CMYK Yellow + RGB + PROCESS + 255 + 242 + 0 + + + CMYK Green + RGB + PROCESS + 0 + 166 + 81 + + + CMYK Cyan + RGB + PROCESS + 0 + 174 + 239 + + + CMYK Blue + RGB + PROCESS + 46 + 49 + 146 + + + CMYK Magenta + RGB + PROCESS + 236 + 0 + 140 + + + C=15 M=100 Y=90 K=10 + RGB + PROCESS + 190 + 30 + 45 + + + C=0 M=90 Y=85 K=0 + RGB + PROCESS + 239 + 65 + 54 + + + C=0 M=80 Y=95 K=0 + RGB + PROCESS + 241 + 90 + 41 + + + C=0 M=50 Y=100 K=0 + RGB + PROCESS + 247 + 148 + 29 + + + C=0 M=35 Y=85 K=0 + RGB + PROCESS + 251 + 176 + 64 + + + C=5 M=0 Y=90 K=0 + RGB + PROCESS + 249 + 237 + 50 + + + C=20 M=0 Y=100 K=0 + RGB + PROCESS + 215 + 223 + 35 + + + C=50 M=0 Y=100 K=0 + RGB + PROCESS + 141 + 198 + 63 + + + C=75 M=0 Y=100 K=0 + RGB + PROCESS + 57 + 181 + 74 + + + C=85 M=10 Y=100 K=10 + RGB + PROCESS + 0 + 148 + 68 + + + C=90 M=30 Y=95 K=30 + RGB + PROCESS + 0 + 104 + 56 + + + C=75 M=0 Y=75 K=0 + RGB + PROCESS + 43 + 182 + 115 + + + C=80 M=10 Y=45 K=0 + RGB + PROCESS + 0 + 167 + 157 + + + C=70 M=15 Y=0 K=0 + RGB + PROCESS + 39 + 170 + 225 + + + C=85 M=50 Y=0 K=0 + RGB + PROCESS + 27 + 117 + 188 + + + C=100 M=95 Y=5 K=0 + RGB + PROCESS + 43 + 57 + 144 + + + C=100 M=100 Y=25 K=25 + RGB + PROCESS + 38 + 34 + 98 + + + C=75 M=100 Y=0 K=0 + RGB + PROCESS + 102 + 45 + 145 + + + C=50 M=100 Y=0 K=0 + RGB + PROCESS + 146 + 39 + 143 + + + C=35 M=100 Y=35 K=10 + RGB + PROCESS + 158 + 31 + 99 + + + C=10 M=100 Y=50 K=0 + RGB + PROCESS + 218 + 28 + 92 + + + C=0 M=95 Y=20 K=0 + RGB + PROCESS + 238 + 42 + 123 + + + C=25 M=25 Y=40 K=0 + RGB + PROCESS + 194 + 181 + 155 + + + C=40 M=45 Y=50 K=5 + RGB + PROCESS + 155 + 133 + 121 + + + C=50 M=50 Y=60 K=25 + RGB + PROCESS + 114 + 102 + 88 + + + C=55 M=60 Y=65 K=40 + RGB + PROCESS + 89 + 74 + 66 + + + C=25 M=40 Y=65 K=0 + RGB + PROCESS + 196 + 154 + 108 + + + C=30 M=50 Y=75 K=10 + RGB + PROCESS + 169 + 124 + 80 + + + C=35 M=60 Y=80 K=25 + RGB + PROCESS + 139 + 94 + 60 + + + C=40 M=65 Y=90 K=35 + RGB + PROCESS + 117 + 76 + 41 + + + C=40 M=70 Y=100 K=50 + RGB + PROCESS + 96 + 57 + 19 + + + C=50 M=70 Y=80 K=70 + RGB + PROCESS + 60 + 36 + 21 + + + R=253 G=75 B=45 + PROCESS + 100.000000 + RGB + 253 + 75 + 45 + + + + + + Grays + 1 + + + + C=0 M=0 Y=0 K=100 + RGB + PROCESS + 35 + 31 + 32 + + + C=0 M=0 Y=0 K=90 + RGB + PROCESS + 65 + 64 + 66 + + + C=0 M=0 Y=0 K=80 + RGB + PROCESS + 88 + 89 + 91 + + + C=0 M=0 Y=0 K=70 + RGB + PROCESS + 109 + 110 + 113 + + + C=0 M=0 Y=0 K=60 + RGB + PROCESS + 128 + 130 + 133 + + + C=0 M=0 Y=0 K=50 + RGB + PROCESS + 147 + 149 + 152 + + + C=0 M=0 Y=0 K=40 + RGB + PROCESS + 167 + 169 + 172 + + + C=0 M=0 Y=0 K=30 + RGB + PROCESS + 188 + 190 + 192 + + + C=0 M=0 Y=0 K=20 + RGB + PROCESS + 209 + 211 + 212 + + + C=0 M=0 Y=0 K=10 + RGB + PROCESS + 230 + 231 + 232 + + + C=0 M=0 Y=0 K=5 + RGB + PROCESS + 241 + 242 + 242 + + + + + + Brights + 1 + + + + C=0 M=100 Y=100 K=0 + RGB + PROCESS + 237 + 28 + 36 + + + C=0 M=75 Y=100 K=0 + RGB + PROCESS + 242 + 101 + 34 + + + C=0 M=10 Y=95 K=0 + RGB + PROCESS + 255 + 222 + 23 + + + C=85 M=10 Y=100 K=0 + RGB + PROCESS + 0 + 161 + 75 + + + C=100 M=90 Y=0 K=0 + RGB + PROCESS + 33 + 64 + 154 + + + C=60 M=90 Y=0 K=0 + RGB + PROCESS + 127 + 63 + 152 + + + + + + + Adobe PDF library 15.00 + + + + + + + + + + + + + + + + + + + + + + + + + +endstream endobj 3 0 obj <> endobj 5 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1000.0 1000.0]/Type/Page>> endobj 23 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 994.714 151.653]/Type/Page>> endobj 24 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1000.0 526.49]/Type/Page>> endobj 27 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/TrimBox[0.0 0.0 1000.0 144.287]/Type/Page>> endobj 33 0 obj <>stream +H‰¬—ÍŽ$·„ïý|âò7I^=| Ã?À@¶+Ò¾?à/’UÕíÙ Æb§;ÙüIfFF¿üå-|ùé-…?ýð¿?RÈ­Å2GÈ)¥p\Ö??þ~{|yû[ +ïßBŠkþ–Õø›‡…ðíŸÿÌÏÿüöø=äø—ÃXÌÉa–XGï¿>4üëãqöàûÇj+%~?òŠÝr8f¬¥…÷ÇQZ\ãÔ„2bm“Oy_ìm_qŽÔ[Ö +vH#­GKÓh¥„£çXº¯À‡\ù¥[œ‹#Zm˜+Žl\/­z[ï“õÛf­åkWËÍW;yŽ÷Gé1Ïæ§ÏVÎÍ\ܹ>òmâ}SÓ¯DÒãž*ZL%6r—îo/Sœ‹=x¢h^/SÒº.£¯‚QY: ceITSB§Dµe +"«œ¬«*YÐ×bs:ØÕ-E7FûMMç4ôÄk`PuÞÆ–×æo«ÀMP{°ëÉ}êY󛉢óm²IâŸk@©+NæPÁɉG­ +Òz¡LÚ1ÓÔ¦—å`ÂTñêHÞ8{RÆâéE#")«­Í9xYU$ËÆT9ÿÞ»Û jÇg.ÕW›¦·—¶+X¦çA5õU7G­çaïJTU·[ì¾ë2b(\íæÃ1½gy›î3{³šlwåBP‹»ÌEóÚÓ}à¼ôj|[Ç­î¬Ä:{-ZÑø»·wºâ"’ÔqQ—_NF zØ8eʤv(û*Ú£“wušIB¹sšL¯ÍÉüú]•—¼%uiÀI·as-RK]^ÎÞÚ§øÉ´©‹¨˜ËŠ_žº{w@ûdíyŽP†Z¶ZË.ž Ìäê(º0Yô䪄¬ìRjJ›* K¤]ö6ö´·`05k4/ð(®ùè¨Rü:¼ýÎ-e˜nEý‘‹WÛ:-ëšKÚ’{ hr% `¶ì"@«UòZÂGò…lN!¸Øf×ÓÖu€YÏÏÄcLßqzIˆ›eõ>U;˜’0s…”äô´ñ»?k¤l¬Œ½c&îýô,;l{uÔ=þDµõµŠ?¢q÷îù2Ýâ-ëà/©9תÉä—­AaƒÕ΃‹8 7¼˜NãÈbÉòIC¡QÑŸ(É统x×s'¡`R¦ØR±H‰j®ÎÈ8©=ž ä5ï <˜¶èׇ:i®;pz›yAÅ6Å»ÕA $Ê[9œv§Œž#ZUâÑsfý²ªTfÙ¤;l÷ëâÍË µ6¢§Çl/Èõ|Ì,%\M“ªIÅËhz—&:a"ê_I¡!fS”:\ER7åšs…ÈåV)rhÀÊ­ÓnÓ)W$yÚÔê” ç²*¢;ÏÚr$ˆB‘Ï…]©ÙUv ®å +´«†ÀÔæ§rÖXt…z¦™,¥‘o“Ùž\/éSnQp5ØEo¾ÿY¨äïÌÿ£P)ŸüW¡2‰\¢af!BÜpßaGq1„ž2–ýÙ± Üyû(0ëÍ궢”èqBo“Ô¸ž½€‡z„«£;Wó½»÷^ŸÕ%èÜAD‡m3<þ$¡^–‘š˜÷€^°"DÖ–µ=’†ÕÞÉVjv]„ÈáC}ß²Þžþ~íO[Ÿs“Ö5‚ÊÞ›c—ÔTévn.圻E €Ûü 8ý=Ú›G\MN˜j/¯®;Ý'êþø™i/i‚7èós=ùs"oWXÅËZ"O öëcóñ:Е‹ìoŒnZ‚™ÿ.äô>µ×‘ë”U½©¾@õ?É•z |üK€ý¨sñ +endstream endobj 25 0 obj <> endobj 34 0 obj [/View/Design] endobj 35 0 obj <>>> endobj 30 0 obj <> endobj 29 0 obj [/ICCBased 36 0 R] endobj 36 0 obj <>stream +H‰œ–yTSwÇoÉž•°Ãc [€°5la‘QIBHØADED„ª•2ÖmtFOE.®c­Ö}êÒõ0êè8´׎8GNg¦Óïï÷9÷wïïÝß½÷ó '¥ªµÕ0 Ö ÏJŒÅb¤  + 2y­.-;!à’ÆK°ZÜ ü‹ž^i½"LÊÀ0ðÿ‰-×é @8(”µrœ;q®ª7èLöœy¥•&†Qëñq¶4±jž½ç|æ9ÚÄ +V³)gB£0ñiœWו8#©8wÕ©•õ8_Å٥ʨQãüÜ«QÊj@é&»A)/ÇÙgº>'K‚óÈtÕ;\ú” Ó¥$ÕºF½ZUnÀÜå˜(4TŒ%)ë«”ƒ0C&¯”阤Z£“i˜¿óœ8¦Úbx‘ƒE¡ÁÁBÑ;…ú¯›¿P¦ÞÎӓ̹žAü om?çW= +€x¯Íú·¶Ò-Œ¯Àòæ[›Ëû0ñ¾¾øÎ}ø¦y)7ta¾¾õõõ>j¥ÜÇTÐ7úŸ¿@ï¼ÏÇtÜ›ò`qÊ2™±Ê€™ê&¯®ª6ê±ZL®Ä„?â_øóyxg)Ë”z¥ÈçL­UáíÖ*ÔuµSkÿSeØO4?׸¸c¯¯Ø°.òò· åÒR´ ßÞô-•’2ð5ßáÞüÜÏ ú÷Sá>Ó£V­š‹“då`r£¾n~ÏôY &à+`œ;ÂA4ˆÉ 䀰ÈA9Ð=¨- t°lÃ`;»Á~pŒƒÁ ðGp| ®[`Lƒ‡`<¯ "A ˆ YA+äùCb(Š‡R¡,¨*T2B-Ð +¨ꇆ¡Ðnè÷ÐQètº}MA ï —0Óal»Á¾°ŽSàx ¬‚kà&¸^Á£ð>ø0|>_ƒ'á‡ð,ÂG!"F$H:Rˆ”!z¤éF‘Qd?r 9‹\A&‘GÈ ”ˆrQ ¢áhš‹ÊÑ´íE‡Ñ]èaô4zBgÐ×Á–àE#H ‹*B=¡‹0HØIøˆp†p0MxJ$ùD1„˜D, V›‰½Ä­ÄÄãÄKÄ»ÄY‰dEò"EÒI2’ÔEÚBÚGúŒt™4MzN¦‘Èþär!YKî ’÷?%_&ß#¿¢°(®”0J:EAi¤ôQÆ(Ç()Ó”WT6U@ æP+¨íÔ!ê~êêmêæD ¥eÒÔ´å´!ÚïhŸÓ¦h/èº']B/¢éëèÒÓ¿¢?a0nŒhF!ÃÀXÇØÍ8ÅøšñÜŒkæc&5S˜µ™˜6»lö˜Iaº2c˜K™MÌAæ!æEæ#…åÆ’°d¬VÖë(ëk–Íe‹Øél »—½‡}Ž}ŸCâ¸qâ9 +N'çÎ)Î].ÂuæJ¸rî +î÷ wšGä xR^¯‡÷[ÞoÆœchžgÞ`>bþ‰ù$á»ñ¥ü*~ÿ ÿ:ÿ¥…EŒ…ÒbÅ~‹ËÏ,m,£-•–Ý–,¯Y¾´Â¬â­*­6X[ݱF­=­3­ë­·YŸ±~dó ·‘ÛtÛ´¹i ÛzÚfÙ6Û~`{ÁvÖÎÞ.ÑNg·Åî”Ý#{¾}´}…ý€ý§ö¸‘j‡‡ÏþŠ™c1X6„Æfm“Ž;'_9 œr:œ8Ýq¦:‹ËœœO:ϸ8¸¤¹´¸ìu¹éJq»–»nv=ëúÌMà–ï¶ÊmÜí¾ÀR 4 ö +n»3Ü£ÜkÜGݯz=Ä•[=¾ô„=ƒ<Ë=GTB(É/ÙSòƒ,]6*›-•–¾W:#—È7Ë*¢ŠÊe¿ò^YDYÙ}U„j£êAyTù`ù#µD=¬þ¶"©b{ųÊôÊ+¬Ê¯: !kJ4Gµm¥ötµ}uCõ%—®K7YV³©fFŸ¢ßY Õ.©=bàá?SŒîÆ•Æ©ºÈº‘ºçõyõ‡Ø Ú† žkï5%4ý¦m–7Ÿlqlio™Z³lG+ÔZÚz²Í¹­³mzyâò]íÔöÊö?uøuôw|¿"űN»ÎåwW&®ÜÛe֥ﺱ*|ÕöÕèjõê‰5k¶¬yÝ­èþ¢Ç¯g°ç‡^yïkEk‡Öþ¸®lÝD_p߶õÄõÚõ×7DmØÕÏîoê¿»1mãál {àûMśΠnßLÝlÜ<9”úO¤[þ˜¸™$™™üšhšÕ›B›¯œœ‰œ÷dÒž@ž®ŸŸ‹Ÿú i Ø¡G¡¶¢&¢–££v£æ¤V¤Ç¥8¥©¦¦‹¦ý§n§à¨R¨Ä©7©©ªª««u«é¬\¬Ð­D­¸®-®¡¯¯‹°°u°ê±`±Ö²K²Â³8³®´%´œµµŠ¶¶y¶ð·h·à¸Y¸Ñ¹J¹Âº;ºµ».»§¼!¼›½½¾ +¾„¾ÿ¿z¿õÀpÀìÁgÁãÂ_ÂÛÃXÃÔÄQÄÎÅKÅÈÆFÆÃÇAÇ¿È=ȼÉ:ɹÊ8Ê·Ë6˶Ì5̵Í5͵Î6ζÏ7ϸÐ9кÑ<ѾÒ?ÒÁÓDÓÆÔIÔËÕNÕÑÖUÖØ×\×àØdØèÙlÙñÚvÚûÛ€ÜÜŠÝÝ–ÞÞ¢ß)߯à6à½áDáÌâSâÛãcãëäsäüå„æ æ–çç©è2è¼éFéÐê[êåëpëûì†ííœî(î´ï@ïÌðXðåñrñÿòŒóó§ô4ôÂõPõÞömöû÷Šøø¨ù8ùÇúWúçûwüü˜ý)ýºþKþÜÿmÿÿ ÷„óû +endstream endobj 7 0 obj <> endobj 16 0 obj <> endobj 17 0 obj <>stream +%!PS-Adobe-3.0 +%%Creator: Adobe Illustrator(R) 24.0 +%%AI8_CreatorVersion: 25.0.1 +%%For: (Jens Langhammer) () +%%Title: (authentik-working.ai) +%%CreationDate: 12/5/2020 6:45 PM +%%Canvassize: 16383 +%%BoundingBox: -511 -2294 1508 -627 +%%HiResBoundingBox: -510.000000000003 -2293.33618881663 1507.267 -627.051511461375 +%%DocumentProcessColors: Magenta Yellow +%AI5_FileFormat 14.0 +%AI12_BuildNumber: 66 +%AI3_ColorUsage: Color +%AI7_ImageSettings: 0 +%%RGBProcessColor: 0.992156982421875 0.294117987155914 0.176470994949341 (R=253 G=75 B=45) +%%+ 0 0 0 ([Registration]) +%AI3_Cropmarks: 512.552887051825 -1068.8889 1507.267 -917.2354 +%AI3_TemplateBox: 500.5 -1520.5 500.5 -1520.5 +%AI3_TileBox: 613.909943525912 -1299.06215 1405.90994352591 -687.06215 +%AI3_DocumentPreview: None +%AI5_ArtSize: 14400 14400 +%AI5_RulerUnits: 6 +%AI24_LargeCanvasScale: 1 +%AI9_ColorModel: 1 +%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 +%AI5_TargetResolution: 800 +%AI5_NumLayers: 1 +%AI9_OpenToView: 176.666666666667 -697.500000000006 1.2 1768 1199 18 0 0 46 121 0 0 0 1 1 0 1 1 0 1 +%AI5_OpenViewLayers: 7 +%%PageOrigin:194 -1916 +%AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 +%AI9_Flatten: 1 +%AI12_CMSettings: 00.MS +%%EndComments + +endstream endobj 18 0 obj <>stream +%AI24_ZStandard_Data(µ/ýXl§îi„­ .ÀÒÌ0ŠÌ´Æ±ØŽ5¢@fXÇ·‹ ßÝ׿3’’2KiA©1%›Aþ\!x ® ¿ÇvHýsÍYEzV¿4¢44%¡Ù­¾h®ó²Ü=d²ô™#¢ODDŠK«j—I‡Ìå•R&­S}E=ª‰hfvÌLF²Êò]Sè¨0ðŠÑ…‘¨¨?’Õå%>ŸçC~Ê!YJ%ºxI$I^Va ‹ÆƙݔMA$ ¹2ĉƒåHʱ ?=´Â°ÃÊñCŽD&6Qºj…QH 01¢T¢,‰K¤p—Â(¤0’"Ëãâ+Œ×QHRhN*Œ%Ôý*ÊâÙE´…¡«·è¢ÉSUÑZJ.™$¢…qÉ‘FŠ^aøÊæ9±,…± Jº0”#QÔ^?DYÈU—y¤0ì²0K½²$\«ìQÇ‚P ŸY~ ä²C=’( *<êõÈ…« £Ò~šØÄGY‰„ ƒ\va˜‰$9J«ŠV’Â8êØZ-M¼B+;“’ÿ!F¹(‘$y^”9¶0Ž=Vs±0M=¯(‘Kayl*Z-¨è!ž­l)Œ;ôÈ )GÛHp‡·Û¶n¯H'Ý“Õ˜÷Í3KÜÍÛR¾I)Iïw‰Vÿ_^½S—þôwóg̤#%¬›WUO­ÙCæìæÝúZmºo‰k†¹{ȸr¿[YhE¼Dºëíž¼O¤¤h‡â!FÇR媄/»g%AŽ„’ÀK<§äE¡Ú„Æ)ºFahaxAðP,4’rÉ!ktKb)Œ+VâXŽ+ŒäU™d‚•@ +£’cÑœø‚¼0Ž¯OüÕPë­m‚$k¢0ª0(±,Jaì²xV.+»-Œ„”ÊWÙ¦’0ê°ã<òÐcÇʱ‰ƒ#…r"‰(²È‘AQI ”øì0‘‰ÂHÝH¢$$I”DI )VìTRYåP +ãP%QeQ +-´x©¥[ìÌE]vùò/Ç‚(¸¶µ^ÚEMôÐëª*ê©ia”’&RÉ$‘S×ç¼¾î:eˆ±]«2«Eº/±´ªò¼ðõ3DÛë׌6¢ãÒ40fêBퟦ9ÒÛ‰¿\«£H{Yw£s·;ú)óÜ5Ï™ÿIíG˜ÄóTO_ÇÊÁƒ…!16†w'ë M.⡬›±cŸ*m¯¹>N2EëÀH€–$Ñ4ÓÊ+’™.®ñ˜Ð¤+õîØÙb‘ê®]Oš×.÷”ÙœQòéå«ÏÍæ–ˆ®‹§?«gìihyg£¦yë1jiê¢éî™='VÞDuölö¤Z'«í}§ôE»Z·Çô|mÊvÏ»Ï3¥yuÞhs–õÕ½gë™UWQ~wm¬7­žÚû^Qþí(Ò–jª USu·\çöÌiîá­˜gÕÆê¨ëEßtV»­îªÚñž¹²«Ã¯–ñu—,ÏýŒÑb-ÞÐê`Úe"ê–Oý6óŽ7áJ›ëf™ÎF×YŠÏUŸ§Í^Õž.žñàÏ^Óz¼6µ–/]*ÝS¤öª“¨[¤tü3©Šzå»é–îÏþ̦á~×æRŸõctvÔÔ£icºŠ¥7-¢ü¢]QÏš½»;uymò²n=I‡dËoeæs§ivÏ«Ï•ÚºTøsBsGTxW«Ÿt™$ósÎú7¼§¥»Ì¦þÌð QïJeÒ¬DvѪù÷]³c*S¨ç²¬t ŸÖ;w›Úã%.ÕÙDƒ÷J̃T&Sñ‰keu2Ñè—Žwº}Îe”®¼è„`Êî·»÷y\e¶ªÌ¹„ø³‡fë~†¦»¤–«Jxnh¬ªç»)Ó 1kíHçCWÆH³0WÆZ™Ûp1÷n:Þ¥¢j¬2·bn:딡Aûù˜,3×ì~´Ð ÖÝuOÖVi®Sñ ž¶Ö~g;weÌL¤Yhð®eš§Ìè;ªñØjsí”ÇeÓ–Ý ÏUÍ-*;WhNÏ–ŽV™R×sÉHÍÙm¾“n²2Æ"$]£ó”ÑÞÛ±ÎZ™5ò’Ú¸†,`LñÌðI›çÖf_V˜k0¿5Ÿdw®6ÿí]î¡ák÷®M)Ýr³JÏÞâÙµ9.Yå*!<4{¢QÑrIÄŸážSµÄXå¹´É­µËÕÛþÌ‹x*ÿœÚ¸hñ褡Ñu­á¯§Ö&ýC¦vtm6MKs/ok¡ÖÕºgÑÝ]Ÿù¥ª¬ôf>4(Œ=‰xh–7â©ïn—¹zGë&âU­ðÖÉžÇM×!sZz,—SÕd¤#-¤ûžÑªK¿-ïýŠðøÏŠ +èܱvÿ;¤×¤®UHf©¤vS]:d6É7;Éw>£QùP·\{Ø¡Æ '–¤ ѽ’ø|ä+/ %òéþ( *Œ:®”DKBIvÉ›…q…UF‰je òÕÍJ²0hi‘#¹ÂHêe;†>”ZZev«v"ߌðW4´Ï”Nª!ë¡ý*æó¶®nF†‡¶—Y‡ÌB Uò³âñYhhUuÈü°nT‹®Úk¦ÞÕ}t‰f·ô˜ñ͵¤.®%ÕíL«vwÍg–(ˆr˜R…ñI” ×%’( ù,±$/¥0Žã( [[MM¼Â°Z)%yadaä%äx•¼0®0Ê‹VBù—\’Å<¥°¨0”]Ô¹PrT%‡Ÿ(WY‰‡BW 3>9¼Ô|"©†õÛ\õ-q<”SUaØeaFa¼0|É•RÊ­-.—ž²M³/éQÝ™ïêYæCÆþ×·ï"]ž‘ÅCÆgo®ôm-âê‹ìe ãê E÷’(KaË.•e•ÂX2a- ’&” §­!‹Ñùì8„T’BEÒ¿‹‚_}a$žUýçC&Õ>t»•¶»GÂ)Ï¥±ÕwfݪòPm]F×øßêåK‰(ïfz¶öÛT¡ÞDu·÷»Zï§2G%ôJHóIuVÓ󇗧ʬlßs{šÒ,S\Å]Z*­¥üm½Vk÷¤¦Ùtô–䃶Á²¦Á×éõ,Ú&ó?Ûj&…%Ì,Û»Û©”Àvˆ,Á潩e.ÕâÕjõ™íšµ›ûZÓLpOg[Í´ßVºZ:ßÉ'žLƳ¤öVž}b­¢úÐÓe§-Ú<Ú’íÌö˜$¶öÛ×PZ‘íŽUy§£:e¿è{GÍ:E…[”·™U}fGoS§^½“T§u»»êž¶Ô;•ñäô±[IOËîS5}‰Ö:º2 ÍÎÝš¸ø»®Á+áÑä£7æš•"ê]¶wÃÄsTŠ”çw"•Ò^•ÞMþYBÛçž“Éû¹*õònÓ“áOQåé”ÎoÜzþ~ÿÖtýnªDÂïf¼ÍÑ¡»è¨ð¸·çkõO…ÿ:ïÂ,2|Ýíï­ð¹ug×øD¤“ëÊÃ[Rž«ê¼« kSHXFפhˆOõ‘žøU;5ų©ªFù¼Û“h¼Ýãí¬¼ž÷ŽßU;¤'²=y‹UŠ/—§³Œ6ñtÿ!µ9í¼¡W™/;x^ÿšÈùCcéºòèÍó³åïl½J­dÖ4ë[/ÓMÛœ?ôÞògʼý;g{LË_á;;ùïÚnc…ª¿ßyÏÞº»igïÂÛi¼Íq÷Œ¯Û?Ä®OªOz0mœÇ³DÓù™Œu|ÙqÚT_zNm%Ãc¦\ã"âOíÕKgÑx”–/+;ÚiåïzGÏå<Þ1tíwñ6é©´Õu+ou?y6‰hûíÙ¼y3úd9oóWf«¨.OHz|¦]þl;u6Þò•ÏSgcµéÌoÕÙ×5Ÿhy¶®¶4ó”¤Ç–—_ËŸ›Ÿ>"׌¤kcZ¡umŠg Ë–¥{»>GxFw§[Ztô·¨{…vüÛ\¿ÔÏ +íj1sµÌg /—îô¤!¾è”„ˆß2ËM›ŽÍVyyвܽ|xCÝ]ÅÝ<<•ˆ¹øL\SüZuñˆ¿YR1ëŒè»*â^~)¦Z:¨¸ùÂ5,:µHI‰k\=O4J½âÚT§ó–‹ëSáÞþ*«>;ºHFx긇§…[I?µ˜Š„&ëoëèçŽ{x°÷š1ÊC½­Ý[ü¡3»¹ÿ­³öØ1MbfÒï;tëäÑft~·¥÷cÒd¦*ƒuȤ>žÛß]Eã[úÖ!§Q#;¤Æž—¿]«í&i]K”æ´vVMfåÞ.¹ÎF}ÇîðŽ}õÍE¿­ÔîLæ¦Ù§Ì`E:tæLæ߸ëcf¬Èº¶zyg ÏgÍäÞЧ•§Ì\Ýæ!ž²—¹\'í¡Ûº=[fs×g[–¦»Gˤ&)Ù^žišÅ;VÆvs÷Ž*Õñ¡u½{°Ì5—¶WO­^»•?¨©yÖ ZkÉv3ÕJd[šæå:k¦ÌÎi–ž4cÿwku_¸têL摾¶Ì¾­GϬÍж„º'Ó~ôÌoµŽ4ÍgÏXíÏÔâyežöÍívëÖ|?®4YyòÊ·w»³…eçgîªw3}£±b]m‹/ æù,»Ã;•ÆŒGµS—}§ÐX^smÏZ»ç)4·´«³ª3:NãýÑÝÒ–÷çФê­o÷ŽNV£­sÌ5§%ýг¢[[´ÍsKc5Šwî\Õó5˜¸g>gß­~é¨S­~HÓvö´o+ÕŒéËB[ÝÞç*Íõdš4[Ým:Uí±5EV¦·dµs”Hu¦×s$›º)â­æW;uƒF+õäšÕĪ[źs•t;¶&Q1o«g=gi¬”ήAßèÛij£z£êŸÝ’ž[¿Vz²Éô¡J£yvèê6õöX¥ÉK=tc®Ê´=2=y¾=›¦¨nµ»4ÎÓckL˜xs¬|âašoú¬¬cdcÞµ£¥¹ý»q¡ÒÙ^yOþ ãí¢­æù%ýÎÑØ'5[LóûÇÎ *¦mµÖNf\ûY3·¥¦e›kç†Oƒªw|¦Œ‹µÖ´óM£.=xF©NmfâqšÚ£šÆŽN©¹ák×õsšæÔ{¼&­-&Ò±›×›d¶º×C™Æôî¤Í+ÛÖõ;›i¥1ºVÍ÷Çh^—Þã4û{˶¼zœiþÌsÍÚ¯ï$ÉëõQbbéÙ,¥p´£ºHjËÓWžWkµìèyí\•÷™‚ï4¨Ýjòêv_å׈ë•ÍîW…dvwªúá¡Ëx¬Ûë…i¿gce°Ïª„®ŽªôJO;¿ÊõåîqÖÕYÝÚU¾:]‘b¯ô”+ƒä´h€@·Ç—}}2±SºtvìP«Ž­|ì+¥¬s;<§´HF¥d{¯öÎÝL\B<§yú£Jçr}çÔªµÏ“i۞˲çQ;„wE¾“hZõ;^4ò¡Ÿ×LMìíZXtìÎ[I6~õùÛI–®Cå½û .ÍΑm£Z‘ªÓ¥þ^}y0kuS“ôz,M­Î1íTžRžgÖïg¯kÓ±L¯òÞdÛá±^í^ÍTu”Të·v—Õ!¯^Uš/»V7K{°lzjùE¼Ëûl«<çÓ|Jgyçgâ¥å=uñê­ü¡õôç=Ã$¤2£Tôu3÷™ëUÌ´íå±ô­êU~Ïß#çOWñœ/´™N×·»o¥¢bejšÉIzçTö¡ñ¾i>#C\ß­÷SÚ}‘ðXUú¬Î¥æ»•ê³²Mª{UÙyUÒbÚݪ4‹^÷»éd•ïæß’©¬îh•}iw§ª“õN•ˆ¦SÃ׺PjJ¡v¨„w¯.áè(ëìŒþñ…ªVÚM¼:·Žìöë,m§›zº'žÔXDtøZ×Þá×>ž¯j­JR¼nþ<}CS^ÑuhXgºéTUßîÞg;Z¯¡ª ïwª{÷š{ŒÊ®¯Âµ“žwrZ÷®':FEt˜wrå±ë¶NÏ]'£½R{ÝÙ+c–žºª[fåÞeµUU§{ñ\UW¨·.¼™ë~U>aâ¡‹V©ôrõîW‰g]ZeIè»­®ê¾T»h^U7¼­ñõ<»ˆ—tkvjç囓·R£Ã"#|Þ·ŠT{p]÷½Uï_wøõ©ìe‰¥ö½ŠxR»òúê—x¼ªSê³²µ£m.sÞÐhiGJoÒ­fÝ{Ó[x¦Eç<éê)ô©è÷üœVÕtê¥&Þê5"Þ6é¤Ú®go +¶Ù7Õîy•«P¯òw~ž ïxMtP•™x«íÒ‰7+<¦¾þ‡«Vã•hÝDe«S•*IßIª¬gé}Õ·ÒE½+•åLû¢ËIå4:ÏxÏ­¤úÞý®êRïèÂBºÝ«*ïÎÒ*«­Ê{yë$•>-÷nT…eC3/áÝ/=üýÞ“ég*›úp亮íJïK¥º·x®²ïTMÕƳvŠv·k{˜„GxjtVFçÔ²T½{]iÆí˜Ý•©Vþìý¨VX^ÛõáVêéì¯P/m׋ù:‰§ŒÓ7ë[š»”VB&wóŒ>­Êßéîõu¬’Kï5OiX¾54˲Ëg§,ÛóžÝhtëy·´cU}vªÌCYvX[†YZyHËï³gýŠŒöÄ2*©ý +±î~«=ñxöj±ðjíèõÑ+Ÿ­ëNÿº:w¥¶hº[©Õ¾W©¾Ñ•÷hUj‡tG+´:Þ.+4­Õ}«08Äñ CÄà <¨°€äô‹KÇôÒŸ%%Û»ï9}IïMËû)[Þó¿hýRÞ)cú|Îu¦Z¤V%=Ú¤_×ÞêñÜ4 ÷j—j…·x«6®|¾˜ê\Í3ê—hñäš™—òwv•ìD‹H¸NÊ{á)³íí3oæýÝÑ ‘j–ýòHϯž2#²=Jû}÷I?VÞ[ýËÒ,½ç¦ä"¥]ÚkÏÙ1}³ðwö©E#"]e^O•T3¬½éFºóÒ -ýŒ†{ÖŒSé¹Gt`6íWUÛ$Ÿ-¦ˆÈÁÏm":0öãŸV‰15²O“P+Õl‰Ê—D?†÷ýUF½U‹N4›Ö«gš-–©ÚUóN^É·«‘ŽéLq/MúE--ßá+·lx4ÑíÖílOqí•VÇ®Ì}SÃÍ2¥O]K¢õý•·ãéïÕŒhí¬0Ñz>¨Hç;•ï˜•9;LÃÅ%3»Cß/oÖ[«™?¹¦x»÷>·º¯_wª"ü¢ªR¤§¢QÞ}gǪŒ’)÷póX¢I¿Žl–»Gw‰.·W¨%#2$+ŸBLc‡ûK-Ú£'u€•—vÇ̆pNo®sW'™>‰ÈNMF ñüº?ø ÑÔ²L»·ûÔÍT£ýÞÑ ©%æíŠNšÞVšž sÍfÏ—ÇK=6´²bþ´ô ÙX%žÞªÝæËrióYY{©e³…>½]$²3§æ©¨®\›—d/)êéµ6±lVm–·KinÕ\o:õ$e=]6wô+›’uóV6™x©ª'-o%¤ÔµEÌ:kYæ£öõ?ÝOZ³ÃijIRhT=õ»µ¥Ó½µ;û[º",Ì-ë¦ç„¨´Ïu¦4îúºvžÁ7_w*Ÿc¨„1ì2Rše2È ª‚$ SÀ0 Fã‘}d~`>nP2FC¡˜t¢£cÆ  2Ãb]Ð-0¢Ê~ø¨âºcÔ¼ÙÖpM'©ìqÓt6š<oqRÃ` {/~ìzþ€}´›ËGo7²Mr¦qÔuN%idnÔÆé¶ÀÜŠ|PE'Þ¦6‹øHûxh() hhؽ®BÓg°ô¨•ÁÔæBy«/,q|iŒÚ‡ÖrF*µz¤Á¯xW£q!Íê‘‹ù Ñ7¥˜"¤kiGôU‹X•³n-Ëf6ç@î1p„TÈøðgßvºà5¦‘x +WT8;cE£tD=ZëK¤l1x\걇˜£œÕF«G"O»¹ZÕ£H%ŽÊÔr{Sg°\÷Á‘è÷ei—8O=üÉè=6º~WÏGä“ÖÒù˜‹ðn1]HTo1*D­`.ÂXØŽD#Îu–,¦äwõ+è=VRÙSÍLËöÔõ½J“Ê3ÈNà–ñe¾a)o§.YèŽÙZ¿A Ë\;>Täꦅ›[1ÃÀ‰)tGøf4¹±9n ®”jVÂ!Ï>¼©5 °Ú Aj ”S‰QДÔJ]lFŽDå|…&òt5ñJ6SÆšaéX`ágR~@çÑ‹±À¢Î„††ÚNe3au~N—-8&¤ÝO5Ô‘§Äiœsɦ±»Ëf4J–½Sug-×*•„z«ã”õN¥[ý&.žª•[ß‹»©¬Tþ{òø±ÞÂQ¯š–>Ô¾ñ5ë lN*-a¶•$«,ÁS;ÚG=µx¥îZŒÛ®»X‹Ù"·´s*:ÔFÊà0YÏW€G +¸f\M•îÁY}¢ÇÅË552ÓlœS)öD ¦F¢>x†Ê x²¬ƒ˜ +.I*‰©«$§Û€yHNUi+x±qDJëþ •¶ ùïX:QR}dèôÎ&÷Ì–˜zKÙ:ÿÚ=É”Â'ïì`ÆUä™äæ$L2”ÞÌRý k*p¹yYTSW…å±È.ãfÄ´ìǯ;î•0ü{¾î™Órä[Lº%M1®Ï¸Uò/©‹šíÛ+‘sêÕxžO­ø„3>ýgò )+ŽE†Ðƒ£J¹VÁ œÐS§¨þ?ày°4ÚÖ0¯!z»"ãWuE¬jQÏÛ“9úUë_У sÉ™ü%í6B†yØô€nPþ +–j~N5º£>13ˆ“$x’z”¬ê«;Kµ® +•ž£¼ôUKS¶­Ú—À7pAUõU¤ úèÝZ*ò–•a%ÌG¾ µ¢O6ŸQ͇n1²šx`@QœÓWºÜßBaÇÒ€¡«ÆæÍqǯÚýÂPƒ"EÁÒ(ÂQÈKokA14Û!ÙÏÞ +)W¦"ó*-Åb^Mªm'YwÔÌФõÚ3&8¯Žv¯…„¸gñÄ WÙÔXe‹Þn–ª}%]•ô¢k‚+Ã3|EÀrâYûªÕ,¯RdcVmã²ßÛž¯Î“©Ç.Àa^!®d¦ms¤Eät¾ÄZ;—*Î~^>Ì2o<ý¯Òw®@’ÂÃyœ#Å~ÆÀ)Êû/%_(±5A˜Jßûqú$+¸§{A¤ O/êКuêTF²¤# P¬ÍRâ×Ò¨†ÃVF§¼HL„NЗ¼d+€j›Jå¬`P +¥²¤¼|yááDOvã„É|ÔtÚÏ‹÷a½í£/Ö­sÊ6žÛˆ^¬ Õ°ZèV_øRVƒÓL®¼ +ó xâMhtÀíá8¼?ú Æ…[³ £· š.lÓN°+Ú>¨J\b€"RÚBP"P³ˆ8ÿ @#e$Sÿƒ¯Ä¾ŽKZ}îÜ1¬äÉ[\¹;C!Šf=,Œ 1çsp>㻘ç¸à='šîÔÑžFÙÁ¶œ¥Õ¿Ú.Ffe+Œ§1Ôt—¹ÃÇ’éøW§‰—Ç%Á¢#ŒyzÓHYùñ“ëÃBø±–ÿ„Á‘±ÓêaL[ù‘hOUI•@÷5Ý4y¦EÚ°…ø, è^Ùe¾É\ÞîV_dYÝ.nKýsÂá^,؞ΈQxeÂ…jM¯Bˆg”u…Ô98jâVÖµ­JÉq µÉ`Pn¡H増õXk}=°òq+F’ÞT8í㧥¢—{I0ŠH hP#BÞ~BW ÐÓ³yÉÎ_sl#9!ƾÜÔÔ q`üe&àŠ•×(µš—oñì–-×9’œ+ ’…S’gGWï¤^f¼OI0[*ÆÞ3á÷ Ù.ˆº÷ãÍ5XxGÌWÌp”0ñi„ø‚,Åع +U‹÷ `°QÞWiÏø^¬¸ùMö¡¾ØmÅažèú®ƒ’|6QBcÃv¢ƒFÚ€ +Ægr‰"4Ð×F…€ íß¿»ØZ û­vPîSxª±Ë[Ñ˽áÕ„sz´ž<”c哈ð¼öùÉW6eÝ©CœÜDNwFu3'„Ô‚„œU,;œ©E‰¤ ôÑʼÊ„¼Î¶)èF6ò•¹¬­]­¹»j'ñœÓ’ØQ`mÌ +pxF~žÙ½h8ËæŸì$KÝ4ŽžêQìdêº1Œs“ )ØÊßÂéí ½öÍÀ]\Ó,£smöX\\0,K[‹ïe³iq ®ŽËZN–V±xªÕÍzµ¦§²¶â£–ˆ]§‰SÅm3õŒj-(oŠKX¥7eáSû7{vâ©£ô®÷ Þ2/(ÝC«Ét¢GÞ… H’Ü7öJMz7ÿÏ”IoïÆv#,¥ÊI/)J´îô¸€>÷­ºÜš 9–1së[¸$ñ}µéõ»U¼{³ÞËH¡z-Hꯀܼñö„U´ÊØÿ~Sñôµ’o6vQH3ò¦)aMмüFϺΑoòÄëš ‡€½º·ùG^J†:L’ä³9öH®³è[^ƒ=°)¿„¸ß¬b>·³¹Æ Ñ{Y«ñÎdµ”Ú¨SG²kä}p dyTyí¸&[ï X^ŒV)R·Km ¥^L“wf*øx>·-­Wè ¼d!<,ÊKsô¨ßÆœQÞ8ÄÒÔDÞHKÊð=ÇîPÞä¾$á ]9°ïô.þëA‘z.> õŒÌÒ„º3ÖU +e ÅlK[1 )5Ý…¢ªìIÏÑ.¦y¡$ÄPE›ôŸœ9C!¶²I0 =ñ/¥Üè˜f }Våž ¡ä½°5ÔÓ +]-4´®Gœh_C{gÎW²ÌkèÿD‡4´B4Ê+×\†N å““ÈÊzuc›wÊÚCH¶† Ýùí e°©­•ã9§*ÃCMMåëñÞ<ŽcQ¥{å(±»r¨®6z¹Î0cšCÉ©x +%]s;À’B5r/.èyZºš*£_ûB§QýIšTû–@çÞw‡»¶vÏD¼ú²×û¶$›ÿÞ×m}ß´ów`í8~׿ïÈ­@A~Ç·0Ãí¢Nô /b™1ϻе+èws„ x6C¿>¸À¤Ûçú­Íö!A¿4Ž¤iýžŸ”ЯS3@iÀ}ýº¶Â40ôë:)Ùïç—ÔóÐóÛ0›L¦rº•àhèç÷ÿ8ú˜XÏ/é'»²ÏoîxUçÿÎ/åHƇr~gªä¿ó«¬nà½øM^u^Æùs%ýÙù5iÂËâWÞsÞ{ ø}ÿƒyÆâ-þ-ê÷C‰¡W +ík‹QceVèϘÏaˆ,RÐÃÕl¹ŒÛ*È&æ_Sx|TVŠi”F×MÍjÍ¥Êâóì¿4"7: W ³Öw‡zÏ úŸ3}h¼0y\g¼…=´úÉsÃz]'#ÏgðÖÛêef¸r£P¹;±€ÈråŽjàP6GÿsB·uÅr>·¦QýOѲËq¾÷»f(@¢·”u%n^Ns„[H'g®ÓÂ𻻘ɽŽå jÊ_¾ÆÛý¤é¤S%baŒ`¹É”ëfCèJ¶1ê8C§tBGu.ÕJêÐà ù<ï˜W‚L|¸/,ÊÌ‚–c‘²Úuô‘¥3A2bxü(ŒØ3` hãëâÖRª °&!ï JÝÜpYãVp‰Ð>w̵ 25tž=¢g&<уÄBm¬z+ÎÀøojeg’‘­X<û8&Ñ|1ââ쪿zݪõ¿=ö¾ô.€êo:ÕÌöK 7ê Í?A¨=Z“d»˜ Æ#ûbcnViD_¶0…á_‹m䆙,V´Lh›° ›»‘xãE"›m¢a½Ê@œaD+8hö™¢ÄIcb3}«e¬A`¤¶71‡«³ì. ÕÊ*C-CQ6}ëø·\†Uv18y(ølÊ›vîž ßørŽÕ±Tö + MÊJý°TM}ã^½ÊÙHãGð@E‘ QØ‘æà&ÖøBn Å@ûR,y=ˆrŸ›<®²Y®]¥éƒëWËD^¤§´ÇÏtm•HÞÓñœ”¹àüÂ0©ÐLªçÄ=|›Ïœ-©ÆÖ"ÑDW‚~¾ÒÞ†¦8úºýŒývî~_ŠŒS©Þ,ߥ£8u©]+Üíüãœl¡/Y›©L¾Î}ÓÇü<ìð,wr©‘ä•Õ\Î_œ4¼Ë@Lܪ N¥PïŸårü»¸4À³GZR8>µ\ÿU·6Tζª]kÏlNk4êqð7•ŠNË‘MÃS(|¬4Mkl­YF°Eº Æ·dã5 µ ñp,Aa•t¨À¡X ç“øu Oüûºƒ.šð"pˆNocØ.><)Ñ.½P—ÌÔE X#Íå"W98NÀ* 8? š_n-%•ÙªD&%­ÖN¤ð‘Æ‹ ÕÙôD• #Ï0&"–¬i=_‰ûRètÁÆ•¶å‰'³>«5H¤üUAÈ«–ÓFdPÑY °!µ…‰#‡ò>¼;Oâ²`õLaRcIz9qæJÒ»÷ßœ£¡£W¤ŒøÆÎ9¸à± Yß–¾þ4‘é>Ý£E”A„§â ª‚Ös×XL6§pZ µÓÄ*Ÿ° 7,eb$º ÕPý³XrïˆVô…n¥MñkQö¤‰1±_‚QØÊ$ULR$J3ƒ®:$/øÀò¢ê !ðÛð÷VÎ`†ÂFö†y:> …Š@±)®p %ÔšÀž +aáé¼É)~a°Žµ,@åá%áõ³uÜÁ¤J‰hBiœ&а18~öüŸx^…Ä¿’øÙ;þÄ{úS¿F®PL¼¸eO{® +ƒŒzļþMæžD qú_õÔ;a|_m¤Qíšàš$Q}RÏwAÒ\;¨xç:ǹ&p’RtƒÕí™·â‹^iºïÓ§Uó×ðµI^ú¥dg2"8”÷Z7#:Ÿ ÏÇ«Ô‡j!2^@$B÷e,˜LéÌ )Ô $Fqû0,‘Ä>€r*“[ÌÛÇ +» «aš¸€#`rÑšæÛ¿ÏíÔÍòUH<>µP6û¬3ÂÓ+î¿Å.·+!ÜëܶæÞãi;st¥yx•9›žS©©D™á@ÉíÓ)i&†ieU«umZ”ýÓaú蔄&Z¸Ò}'Ü˧qKNÕ)¼0ȱ¢¢6ÁON;TÆ£‚M‹//¿õÇÀYàÊŒ§üàÉQ$A€(õpÄi{á‘c +ÈVXÆ 0§–B—Uœ„UÔ~Äë,þ‚ƒŸh³~pž¡2È®-n죎€M@h¡:Ûü é°¾”¦‡Þq³ý(Qó ?E?Ë¥,n`»qWAc(%Ó±6²…ƒ´/åcuªYxøpv_š8n;9×q¢ž _¦s>æ®4âÔH>-W9®&ËeT,ÝÖ “÷0Ê `Aÿ1jÚ|§»·ÿáà¨çŽÒ?Ó×£¡…ØO~£…¬3Y'9At…#õB¡ôOâ$Ý@*†Øx\²ž×`‡³GÅ ,”ïÒ:œÍ’;uði°ŽT5FÜÁx"›röeoé"þ®žW€›êìx(—P}ý7«r$k_kOnø‰ct6—¦èé¬r‘›ôl'ÞÚë!·‘<EÌ~qáë|žè ;™Ö赩^\$Æ4Äðftjø";$É؈GÿØ‚¬la¨Q3wÞÁBÙº¡¦%V­ÈO:j¿/ÀçÜŒ†Sg²¡™¡°ŽPK»†GœÇ–€<÷ñ‰P¬ Dìu l*G@ìa«ŽëûHx!Ô!DݾAx6á“W„ü¯¾Ø©…¬´€p7ç‹Ÿ÷Y˜}榎o=ú_Û ¡ÂiWï-JÂ9Atû&As•P•«Ò@,®Ù,”?½\ÿC'išd0·”)kXìòA£pÅDË5èÓ‘îã÷ »I#œ]òæãº:åÕõtÇ£â²sj Öõúa¥Sä5`þ Â&Wý(¢)2™ç¤G¥6Æ8Ûͦ •_±ï©×0˘å×S¹±èŸ&Höš%¦§E à4xÚR†ÑËD¥È`²›´°­¶g‚{†¬Ã+g׆`r:OÄl¦‚­Ø¡]Ÿ-G’ûÓHg2ìB=ª§iêZ˜áMíý¤NT é²ÝÅþùÑ Ân©b’&?Úuï†pÙ;¼’å¾»˜Uk°µvçÞ¿²›\ ¥°Éí7i/žÅÆ0FCzn[&pEcEy‚¸QE¬A"@³QùP}ú™òÿòƒ–ÇÌ +pV,èOxý®nÍœœ¾ Ñaßà¬Ø×ÚŒD]u¯¯^´žÈ¬ Á»œóKå—›¤ù*ÈŒ^×xš+*ã^nXC=§bz´ì Šµ'&=’:óz^­éãc¹=òôÙú¶“ø_íÉΉöëÈ¡c‰aÕlmpN?¼;&éÒãIl-éÍ ne-ø¹“S‡ŸÏÍd‘ÐÿRèy5÷ž»ám%—>–‰×Z²Rmuð.­aR1vË57RÃú”µ€®ž`Uð5´&4^LÏ=9ö«ÒŒ¶Cô«¨ÎÛ%\?Û48»ÔQÒ¡yܤAÁ< +×=ž«rZóÛÖñD™°ˆéƒPëL‡Jòzw‰>På¯å0¬¡–)¥Ð. OÅRòkG®ÚõÊÅî:]©ãÑøÏ_ ÿ‰sY.ËoÎ|I0ìÞ»J„G­:ëô­ïö½|”üãÚÿPhµ<ÔŒë£9´Ñw+Cðvi߀ƒôïªÿ#Jòÿ•À@õ„²Òye¦dóˆ 8]ª¡;-#H\ÏèS¹Zf¯÷Œ–Sé¢õú6oŸâøeÙ]¶@2Q }6›ÿЫ1Z8í×ܕ䀸ª·¬¦—h6*ÝÁœƒNÿH+·µdl÷bµ…‰­Üžå(º`ÊH½µb[©õ„z¯“âÊØMœØVDlÄÖ×sû-¿ØtÍx­ +Sâ3¡è‹•à³ò\(êŸá[ Ið}îO—í©Ñ»Kÿ‚¸q äêFE/¼ª¼Ï®œÞ[—=Gÿ½¨ØáŸô†O&ja{TŒ³ˆy'²B¸À9ÃÆ7¨ÅƒáaàðÎÇ, +cˆ/¼»8î&0ËIò7Ëå|²4£1ñ·Â¾»ÌÞÂ`¹§©,-©ë½(,!{NP¹H!¹‹÷•÷ þJÒ8¢ N·ÏR܈K [J‡sTX™\lF£üdJý¦Y¯6G“ •HÏ?rC@Ðæ'æÚÊ= Ò÷¬dx‚–uÍ®ÄÌ_„„Nü”‚ô´È¼c£fkÚ}1*Î8 ÿügr–ÎQ>°IÝWÉ׸¦FØT¬EW±íiZ':›ÖÚÐØ5¾êìPè?ÅBlèC‡~5.Šk.³N¶óꎸðïˆ^É̬CéùIÁ#£áé-m]8VÆ…«r뉪qÐB‡Õ»É¬¡«ü1ä¶2ûÃÌ“ŒÛŠ…Ÿ9Ú9G]¡Ì\__}/«ô]ð•aeíØÂ!j|™û-I¶W[j|SRã†ÑAßÕ·<Ïäˆl¾¼ú)j[П"xü,n*x€ôw¨]»„²¤¤ÃÐOÜža³ÜŠakÖ$nÈE%KΕwB#áò›È]éÅmíUǧ±‰l㜔4–Ãt(Ve +«ÚÈ  $_æ—sªqbšÂ3KR8\ÀÝpûÇýšª±¨ºãè¯*Þ¬Hǃt'ëPª™8*Hüä|‹bÇ'_›^åÏ@¡ÿ;ÃmFû¥Ì(Êvü½Ðæ +×Í}ó ¹0j¦¥%?dÂÁ¼rÝ¿é‚|œÐ úŠf T*kÆ°xÍ!Ò¯òu‚Lw}¿›7ƒÎ.ìBZ@Æ/ÕÙ—~\ ÛÂóºË0É^X­„ñ¾×˜f›×%ª‡áIð¼ø,ÈüfûË é¿«Ã´H‚ÕÌî•õÿØÙ“§ýÝlZ$ EÞ$¦IU@ñáâ\ú‹·»¿Î§iR€Õm¶’4Ô¶ª&2ë·‡“dß®óÀÁ3]ãŸÂü3ÑoÞŸýA…íN¹þP²õ¦É?tJaÉ^%ßÞlz{öl’”¾:¹éŸµbnß9j¿m+¦ãŽ¿…º¥À×U€H*v ¡þñ°~tcÕ¡ÕwÖŠƒµ<±e­ÂmPÆ‘¹Ð´áL†µøR9s¼â—Â@ƒ‡H§•ƒ¾Á}-ÔÒWØwƒ%8÷BOx6SJ¨ãbêÑXhÒå~6mœå’ùp8 M)Ü‚òóY(»ÆíÞ%øaYçø7ó{Obò>.h¿•ÍµûÙ¾k•¦×× ¨ª;ˆ 2˜Ø?ñew·|‹$·G–Ùp®½S ¢-XCÖ¯„»uSÅù”6Z¶WÅ-6QÌ1RÝžvFÝòøºšª;’‡½¤ë#V™r=™$FNæ5õ”Á⣧ÑÇœT§N8V]ÁÛÅŸØnªÖqr¨&å¾¥†š”î™–ÑoÁ¿ö¢¦­ç,_Õ2ŒX•-M+‘{•GAZNï(ÏLª¡’™Ð:_=ï ¢×ÖÂÒ…Ý-¿Vã¦LšÝfý…m;¶Þ.÷U U%3ÄôS-?jA-*èB%3^Û;ÚfîÐÂ묻‹œ‚˜¡YO—¢ZÖ[÷ê-2kão+Ì»2XX1êËD«WpعËPÀzÚ þhðogUHQ˜ÓmÜs ( !ý,%­tS_»‰š+E.õr÷Èe—ãj—º?Ÿª•z)(‘P:8X…¼5aà+Ø)XízµÊb<™óée\% +Oï†ýÇNZ5ùOHgŸÖ”U+]DÖªq$º8Ïze­·¶ÎÀ +™Uä¨"œë2ì‚iŠLÃH¡ ZA’<œµe†;å¢8‘§ô˜.61J†’§,§Wôx½ +úºI»¢´×e`#§Uš Ð3¿´"Ó45ZìŒáÙ×fJTÅ· çYÔUž¸k?áØC)Tè­”—à+r˜„&M ’)ܾ»®¡]ãJóñßÈæ)ƒ%'M®¿RŸ„ÀóÉJMbï N¶¢ÄMQ“p¤ät åz-7‡Â[r°´M;“+mÌ¢º‹üÃÐ'í +¯ô<Ý9gIWåEj¹èEüÄ m?Öü_ašÒ [Ñ­'\RotÑkêà@u«®5¸ÑÔeêùžTßD9ûŠÝ—=¯©‘³þvQ»0{AQ’s±dY±ù°¢Hf¬ùá3N¼5îßÍjM¼8«–Š™5<ÎÕTœxvm8š)e¾V ¬ä@9Ñ­ª°{½åËÍ‚9Kƒú9ÙØ—dc¾êZ.,Òy Uäè ËCIpjÙë+ð?í'QŸ¿ÖȨCˆëŒnz5Ý,XÔK¶Í"S§P¿“£Þ™ø𫤎Ó( »²Žœ: ]8Ï(§<ËD½qÈ?›‚ûy³Rì~ T +÷kìv®#‹Ï‹Ž¶øb¿¿B‡»ÿ¥àÖ—úÃLìÀ•;®rAy:¸FP3CÖ¿PÚÙ™ïO™ˆ»Êö—L0<>­þ„vüzݧ€ø7žø+{|¨í›ádý†5U~ sÜýûغ 4Øuöµ 8¸„„zÓ%l¦sŽ<÷o\ç Dìáf7ïºÉðyŽ^6piHî‡ôXº–øëÁ‡b6¤/óî(à`–D-^´’¡M,"~i’À‚z§ëRr|Ï?ŽÁéxWiŒêìGG?¬N“ôv¼»´€ h38Ý;2•zgïÔ®\Ðz™›ÆÎ&½#N›ÀݨbÓÞïoa8 +±ÁȾÔìÔ;¯¯C¿®G#SVY¨¤ß¦@½–fI]Aûøò$¦§->e8.T¡ÕÕÄ:Þ„^­qæ +u+Áõ8õøAvèÞÔ8U‰iXÀ~ŽËùåÌCRè"p¼àXÕ1NƒùPXg ó›êÀkž®wdD@ò€*ç…ßWbËðSò×A/ä¦äÃÐ$Wj™oÍK~_Cù4é`ÿ0ü5;¨‚£ˆ©xLºØ}GÕÇ¡bÁ¬Àå^ñÿ\ÀÚ­„G¯øégZw«¬ƒm¨4)+Ç‚iT—8¼jV`VçwÒF&Ú=Û›Ÿjå»/øžÊWïzøkÙª«0åp‹¤mý¤ „LxŸ ïÁÒÔ lØUÐ5R¾0/qöSº +È[(PÍ mÀ…°¨IÉn÷5NHŠÙ.îæjÛwT¬ÖF¨=haZÊB³kØOƒgúSÿò% Hm,-Å—àÓ‘=ü§©Í^SÚÓŽ‘£Ý$R™ÈÕîìÍçàÕ ª]Ô_CÀ¾/÷a%ñŸ¬ýôÀ•Q +î<ètK˜ +`oÀ^ó¶Æ5ÍŒ)z`tAº7×uá`‰Þ³­ØŽUƒTOBRþ‚Ïî~9¦ª²¡5cüÑÑq‰¥˜ö_hî›ûw=áåš.wwþcÓ÷>nyœ"1 ´Êål<é84’÷Š§8?Œ2&Š¯+[¾ñ%ûh„w\žÞúSð/¯0y£€uó• œ8áÒ¨¡ýšiâ‰Òêw欴^@ãJž‚díBœsÒó¾fЭ)*«Q(ÛÏéx>Íép³€Ö>Ó‚íÀƒKºŸµõù¯/–ĽFEÅÙrÛ ªóXÌÃïìå*{9Hk™è½&agP¶p´² „Å˼2 ^çl6±›q+“ÑŒîoJ›x±‰ž,#èNKÿR¼5m=M¢(†¨á +CxuÆ?ÙQ5-äÚÒÆ>”N3t…Zj ÇÖ9ÐÙH½mõýáÙXMZÂÂoŒ.wÆ„ŸÿŒþaܯÇß'©Ã–!·c““_0·…Yá×Gáwî€8æÈëÏ£ÏI?F#V,«?Ìf¿¹ ¯DzD! +0 ¾î2ضgÛfOÈѳPcY õF¬¶¦îó!dæô‚Ic Á¢ ÊÓ%/ÿn3D§ÑmÓt¹ù™¤íA2³ §‹[ÏD0áx]ÜYדUD’iÜE>Ï­ ¡è]8 œ¼F€Ò„3SÈ÷ÏE^åú;qû(L[’ì #²hÏ´2za­rㄹ8‰UòGøb®²uÍØN-·Ú›¿ýÜg=Ι`˜QÞšÙÇC²{OJ9ƒ^7¹ë[Wüðp DÓ·ì¬- ÑàÅD {ÄË4ìnˆ+v% KškÃF×à°,b¥ÃJìCw9ºö³ÆatÑ°(¶6º°°`Ö³»ãlSË<‹ gíYÖ2«‘³Hr볂!{"?Ûó‡[Œ ßVšü胄Üc3*äè+ÍÅO§@Ñv§4òpyDóžRIÆת'zá\7 :7öyt%©›ìÑøÜ’ ú¹ð])ú0>УQg‹‰{²ÞîÖäˆó„:ÎYå'ò£//W°þ'8B@ÒöYµ57¸(ùì1™ y¹6¯  ’¤¯–•š~†‘xïhµ) „^}°Nçé× lÌÀ·úÛ<ɨc€¼4!0Í`dÚéÃÌôÓt!f³PÇ/é'< ÿàžÀ›ö Ü5 _:›°:UcJßÿ±”â®;Jå†U܉f]63o„HU8ÂÚüO¸ožèIJãq7½òoøkù™×æÆÔ¶ÏX¨PŠ¢ÆÊ+ÚgøÜ`M!ÉrqBƒ©ªPfs»$¨4z¹bÍû딶!µGyšQ5ÛviM•¯ƒïÅAܳmT¤mX8ôs zv¼¬¦ñ²Mò¢áÚÕÙ"ÏÀ ¥Ëb®¥PQЃ¼¾N-@OØ0f¨HK˜‹¬CÀG¢–þ%së ü] ·ðé +Ä6áÁ„èRéB¼#G‹«sœ +ãèP¹@Š¯žÌÑCLð¼Ó!y‡ü–x|´¨‘‹ªy >ݼ²‹OcÐbëobåVÔ +ŸÅYzƒº”¨aèË2IÃRÀÝÜÁ ;j†ƒ„e²:úp„>¬üÒQó¦HVæÓ +ü>ñ—Wwqn€î$ÎWw§XùBØ𣎚4¶!žo\Loô*ú¼áXVÏzMª¾º¾ÈIp6çZ!±–ŒºÇNXF¨jYŠ°†úóØÈà6ÊÚIWm´]º9V×kã²ß›åñØÇ_æ ’¢º¸«P³Š½OK:§ðT≎®c@îxüY¤§"á ïF÷ ' Ëc8+cÓÆZÆôïeÈ‚dáòM< µ…í_¶Xyø@Û Šð¿ú!Œù\4RyΩßFòRŒ\Ð ^Uð–l ÞÙ%h‡ñ°¼5†›FžTkå*~2©y‰&ï…ДûÀ6¨y…Œ Ú2A|ܧ"·ì˵õ¸U qÀê®JQÎÎ Öc[ GNVÖNÂLDev{l“fU_s@¾ØÌMÍYÑ™xÎ *§J¤8Nœ>á´Éù´­®èÅ’…[ÂÝ1ÞŽkýñJŒ(} ÄýÚk3;5Dið~‚å>× ÎdâEØwd^•çÒÊ_Žd§­¼\ŠrJ†ƒ¾$/L N…ê›Ùá̾r†fhˆŸ1Á6löëXÖ†û‚>‹Ú°Ü*—þªX\‹Õó¸Ï"«:äé‘_˜°Ù£± ¦lÊÏ/?’@>’„÷vœ¹k¢êS-=¹ø#ùÚ€K]Ê,ô.R‡e¸ÿ£Á«iòêó¼úqÑáL¯õ®”eeìxUßÕ•ÞV‚7S¤^ t”€}NLPC^5dmWËe&¬ÂOôµÿèÚ]üù¤ %¼°Ä@i’Êeü®ä5Ün2'F)bë¯3²Œ™þí(’óוRøÖö‚a02úÍ?dsú"p,õì$G,Vhž +ž „óAt°Q’Á1P$ä‰ ?Åðë¯bÒëWvëY7¯š™™Ê;5:ã7™rütè4Y§WÆÁÚÒOWô…hüª_¡NE} +övÿÂþv Ð#¼ÛÌŠ ÝOu!—~3°ïbep`ßø¯•¸‚`Ó“'^ ™£Šx]o€âãݘÏž­í§ W» `}kD}Qó‰P^u˜®"¬ky¦_‘GYk“ ¯$1H/x²Xíæ5³[aù”û©õâë®ýü·ß(ÖxÞ5Ý®à“§6Tñ„ª›¨§5näÞˆ ÓÏþÛ*Õv˜(@L•¶Nlx'&÷Hænñ’~RW{ !œÉv¤'æ +ûÊÆÃ%Û"$à{òvÃo'ÚÒr‡«Qù¾LŸ»è i6í¸é¢±•t€ÇŬ4 +‚Ï! ©+—i)³íÃW~NR†ÆìÓuiî¥aååO «"ñDæÂ#̽öPMt(·/ ÁþËÃjå=áNƪÔÛ5½7“ûù¼o4:=}8*Þ®‰¬I~ëÆ1ª5{\–„Õ?bÆ•Å?a€>Bˆ£tÔÊöÖE‚ôDW¡‚YËc­ª%B ¦…“Å€åå1þé°ݦU½°Ãö&"ŸàYà¬7Qô;œÈþwà§wÇn±ÃÅ{˜¾uY„¹Ä¸ý%Î$|| èåRÈ8i€b½+Ï£n.Äçh!.Ë9ÞÒ¹¬{_Üãµ=âƒ^O¤‹í9ŸoÒ\1ß…1†ñ’!ö…9ÃFaàúv£¤ s9ÂøQ8>@ELëà§Ô_“#[³4+\]5eO0žÜxZE=ŠFqÒók©Jž• ¿_aŽcYçòCó–âãülV&°<ëÓUGµ'wùI±À€W {^ ¶jÀÆýÞl­Ð™è+‰ABBHÕsÕl‚¨z!xÐ>©BƪrB`yÁ]¿xÿoYl_³Æm/ͱêŒxmB_öt;´Y"ÃXéÆ6ül¦œÝì³Qp²‹Wën2Íh;$aÛÙ²=Ø}Ͼ5«­¼ ¡|@mpTöçîcTn³KÜ ¸©Âáë–¶Å$`ItD9ÓÊÌ4)§Ž˜/ˆM)Òˆ™rзÈS»\!;:X¢¥8-KK)sß_Õ>bÖ·FQÜ„);¼¼Çç+y/ɤý,ZK´Sd³€ ‹å¯JñË<[áä½ œ‰tÛ$e>¹õ'ƒ0„v¡Ô"°™ÙÌ´1,`4D£aá% ^OÂÄ/b‚¡‡Ñ:Æ Z¾˜ÅU_®Ÿ\< Å(ü6›NH}u@g ˆwÍN˜Y’@oÕ?KšYú,‘$°0ZàæÄ$LF³Ï:Ù£è4º-6­ý'ó«N%æÿ©ž0ðÑ¥£žPóÒ” ØæpÁÚ¥œ2?æþ¸÷÷hy¨À`È/·Fƒ?l¤ŽÞ>Tà;'ø ׎ˆáÌM…£]–Ëš°]Bí¢y¾ÕçpF,<ËÛßذʘÒmžÜGíL-ä¥",ùHbb'é ÉJ4‹\Bâ$¤kÂM:Ʀ› –ôÿcÒ¨¾dbC®Æ¶%¹ôïJŸQC5qÆ{h C0–ªb’l…X­°b.\gs´bl<ÿô´\{ª¿açx8óÑ¡ˆOZlòÂ: À佧ñýaðÂ^»0¦ÛB\i"\°Qt"÷ ˆ—Aƒ{‚ìž‘.2((¼PUþZÏ3¨[¬Ä¹·ªC®°•ï£tyÌæ~ÇcgZ,aíײDœÆS°tj  @„RcžÕ™¯»\j”q;+ô+ùa+vùÏghÏèµFìiŸ²•°ˆ; ®ž½î;»×«ûmqgµ¼zû&°«Ó=/TÙò¦¨ì+Œ=>öÄñŠV^ò†ÖûîñdÓ°À©²`*lT|ø²Ù+Y„ïQ×@„i–Ñóv+ÜêbÊÓaMö,¯ +Ps2±DÍ @âÚHá:qTš5˜£nÊäV¢ñŵO–Îü®] $(÷Q×>S`|)må(¼½9CFÕ!2Ùúxæzʈ¦EüœŒ—ïþ!Æ‚–bñøp¿Œt†ï½¸ÞÍò€†g73Æ>¾Û¯ƒV¸G'M¦K9âøF«Ûh¡k$x©Y$Äq }ŒVå~M¢#]¤­jÇ'ç" ­h0}jF†[¬”ë_ý¬™Ÿ;³{^A‚n}¶È-7â¾?Ç\2`.-Ïö“'#´=Á“¼÷Œ«žé±¬°j8äPz£„Ý•ŠìÖ7oRö´JlNÉz ²{‘q O¹à’ã@š~]º£hh“ò3STú †ÆB™ ÒëŽ@m?Aª˜0•¥¥ÖG<>b9Ú]ÁàI1 +5fj"!´? õ#Ž°º|jW{6ÊÖÆÚÍ<Žð“Ýãͱü[ýѱÐX:ÒÙ]á>͘Jôâ +Àhts¶ JZâ ÖØþˆñ¥Ô=23)Çxã,Ž¼çjûpºnq¢‰&‘ #¨õƒÈ) 3›š^òýáÍltEÎçýí¿³~ ç^íþ­¯+—;1ªÓ!Ï8'$j|Ú×7€Õ¶­$ESxªÕt&€½üÑÄÃJ „R”—,Ù÷1›‹!Þ™`©DÖ‹¤Ä|‹Ö%¥ 6äÛâÚ#%Ç/UEÔy?^k{ƒ‡ Ê +DöA"¸©nf–î™ÐùSïbUÔQƒÅúõ@€jü†þ”éb%ŽZCà|·~áßoêpµÚ1Wazs/‡A4^ö_ñ¡¹1`õ°*Ù2ñ΢S4̦ã’Û%úUGÂö´|P-D:X“89WÃQ…À¨˜€1Æ¿Pq ñ "^cJ-õßSìœJª©á¡SxóÕ,ÕœR!IRd: +„Âu$ÁÂmrEí‹äX8ãÙ.c¸ê+Ëž¤›@Ô#8\VÖýÈ:r{qDÉ{+GG L?د@B6`Gc.-yæ\%žR{ëZuå½È´ãá‘âbøÅýÌzž™+¥ï¥EŸøõœPâdÑ1Þ,ƒÃNð§5cf¿ßI£oP OfóL>þûXôŽåc²T-» ’Ãìë5r$ŸIZZ°ºë¡§ú@°:Œò0™µ,Ÿ³LüSâ +å¡ØVßÙ©¨›ŠPFLIÝÚrÅc_·cÅQˆjËN*~©©wTd|mGÞ0¡KæÈL íšSxý3ÛôÆÄ;Ù—çUPÃõ´UOηìeP3×óký{XD)aÁª'=ü žSÒ;w)/È%´£€ <˜ÃWejƒÃ\îÓi =Ã.¤J†»…â`™.ogRøB„Z2ðšÍ®;-wö!JteÇF-I¸ö‘nBØfÖÕÕÐ~°“š×ßøínÑÓЇ]ôRÅÐq%«ê µPò…#K!QD²S™+I­‘©2 l)”NÙs|„ ÃL“‚þrwS”  +?;zJiJyS¹óŽhþEBˆiþƒ¸`ì³¼ô‘$Ê‘¨IçìG‡e:ݘCÅMu°û_˜¶£,ù?•+Œ•'Øpá&ºìêw{Ò6ÃU¹Ž +K‘ˆ£—žË²~K1ƒ^jÓw +LÞþ’Õ,´Ëì!‚ +‘r]ÌÔð_@#yâxŽ ý›»(ÿçü݇Ž×÷0½{HÁòRêÓÏ Ò&+PC¶êg¼†Gúh}œT^3öªZAxRIw+j[N•? +d¹B¢lH¤>$¡ ‡‚,äº$ò–½›Êöñ…ßCC ߔ؀Vëìg°›ø3”ƒÃ¯®x“ùÃ|k‘ÁWšW/®bªý_Á$HÁãj8AÕ•`@FøQù¨`t£òˆÌ¨Îâ¢&McW¼ó¹y8£ÞQ—AÈŠQŒC\Ž£â/ÄÌý󈼟v©ýizï8óå@þ˜vX°ke( ™1‘þpb°"‘E¤¨C³@@Rô¡TéËÏ%±ƒ7Ó¾5ÄÆKÌ5Šh‹´ºìYfÀ3³äiý›} +@# k’AÂU‚À€A÷ÈŸTW×Rº5þô€D8AÉI]5ø1P20nïqAøT€ƒ,&À $"@g%ŒgFDäB +¶¨ ¬<%ì&þ‚¤A„i4ì ß%lˆ/w ù^%²bÍèµ5ý|y¿ï‰W‹“ !æ§Zè]è=á0AÇÖ(‰‰Ç5ÑÒ‘ßBJv¬ÃJ£ä"õ0¸cbàÍÜÊæ­ènƒ‹-2kbƒgwæ?ûÞûÉEw_ˆtâwrÁÍŽ&NÀv¹J,:iÉY’ ¤ÑÄä†7Vô¤»àa)è Gó^`=søaß—OÀ,˜j:A2â·Ðô«: ¶»?3Á…@§i1Ýž7˜š—»í®®•ŽýÄÍO'mõ©»¯øAHF:¨èߌ‘|°Â1ÿèsG„•9ËýÜtúA/†ú~ºàº…V¨Ö3éÇÛç…U­¾gdœ 3hÆš&ÖD„–·›*<‰Ô½zðŠ%‹€}.YÖ& ‘¿VõÉÅ­¢ +‚£¨×qøø?ÛagP¬9¡G-€=:ÇŠ´Ê2\ñMÝP`ª˜˜}­²wüœžÓÎçß’ÇU4¦ŽÖ” ¬ªAî}Êd‡/ßI°;}‹8Mq`$¦«þ±{AÍß4®[si1: ÿqgHãuۨ̿ØF£’Z¨pÕªÙù•¸ß‰Ø@v†»hSQ}Ü@BÐÁ»€ë½wûù7Œ",º»*ì…=ŒjÃoè’AÙ"£TA½·øizzí,JÖlñ£ÃB¹^YB*§É¾ú öÂhIQéÆ“Í[ØDDq•ºN¨ìï ¡ß©-!>Ôv.ð—›wÒÂŒ«eÓ ?äž—ü*zª2“7›D¿&óÛàÒŠél4Œ§‰ÿ/ ¦0 2ùº»O¿,Ðu)4|n$`ÒóÔÀÿW¸Œ ¯­}™–Sr @12ˆËAuê] ÜŸ15ãËœXÙkÂÇT´oú"'ûî^ÇY$ÔÁ2²›l¾RnåñRËVWå¨ÄRŠÒ=ócØtIÖAe¤Ÿ^ç꺷ÝN4KŒ3=ˆÂUå¨7?1QpÁÔ¶f-{{Óús´©˜±¢vP¼Ä[9Oˆ¹£ŒáBc ñ‹_H’žEÚó šÅüÎìÏãL ÷—s"±ä° ¹R(Õ­Ä_³t.3Uô²Mõà#|…Œ8da2¥à ¥´qš©˜I O†[¸™Õfœ¨a…ÐÇøv&½“ˆ +Ý '‡œ| +CcH\ÄÜìÅ‘rõÍa8©@\U°Šá…oRy£tºXH¶Ë„Œb*Ù)ïV‰)yͺ˜WfÉHBöbLæ"“GÄTFÒ•ÏX)Þf¡N„vv>’œN¸"š725:ŽjO­‚iBhTŽ[d‚t"½+^eç‘‹ªqò#¼ëéÆGRëÈEq1¹ö*Š‡8ªj0Cu— +úž`%Þ/Ð'é_ŸHùŽ`ÄcòÀo"£ —N¼#È“xy“ %f1OV¯~{¬—ØW²Ðš‹SzNc kP«…æý„ùƒi/—©Âáåÿe*ÒPØ >L5 gŸ8aå¼*vL-¸ÜÀüƻф´oÃx¼"œ#TmkaÆçb P%`Ð)Y¡gǵÊ5/Ð|åaK‘c’DìXÚ%V òBt™E bJõ²§$´X+’iYä0D9Û[33w‡´Hm!ÙŒ¡0"q¸tÔJ4¶lXíšJ\Ù +êØ,'¦Ê%vù.I"¼ {Å»ü¢ØR<°[“0%7´M¤-4 &–˜ˆ!ÊÙ$TT¨>I–"œ\7­ƒÉƒ¹ÄU‘ì Ê测œ¡²‰œWðœ¸73p|Ç¡@rŪ²e$±o‡f;JBžo×ȷܶ<ÔÈ=Á‰ù…7W +—º¾=œª™Ü57†TïE ¯w ãCtO(ÕÛÁƒþ$ŸŠ‰2õã»æ&Xw±Ü‘ånϱ¥$aB +ùž¬‚mRÄN‹ïð",x@Ãî!†5ÚEDöˆEÒ®³ DôHÔ®’ B”`;7íÒ2¡;b$p5ÁÁ"UíŽ!ÉÃF:•MRcF“;¡dÄ÷‚¡Áº©èn­a„.ÙA†å‚õýUn¢NÜ%‹?A¯ªS~ˆ ï(Ž´è[‚ûfd†F">¯•ßû¨4ñÍÈ“x|÷,1QÞæÿâä1°Ïhmâ}ÃÕ4UqÇbØ8Í¢æÅø«ŠOïbÁ݇Ç©a‰ˆpi¤QtB¾mÑ"µ0àwšOTxè!¢¾J±]F:9qÅNT¬ù¼Ê÷’Ól³ü—[¤BeUùÜ ŸÉÐÄ›bm–mb§Ïµjy& çîþ¹û¢ 矹Ë!›·§ ŸFûÎÉצÆ2/V„2¹a;í ­™“_óU#[Ñ|ä‡}¨8Ÿ©C}æoqî.Ô§š‡ÈÝ›3j‚ÄJ¤8sÖ›áLÅf*6ûgÑQB6tŠ8Pl ¡àX)ó;„l¾'J„ðkÈóãGÍÂ-sŠh Çió¢2L¬sùÌWâ0v¨ÂKþ,Pyê·“¾ •†(ê wº¢Ó0ä–ûPL»- T‹ì1a˜ÕXöŽ£lŸŒP¹Ó¸ÁTWEd^¦ #ÅëJÖ™ŒÈBfYÃŽ ¨|$ ³ÍYËŒlÌi›zÉ<ö :0 ŒàX@‚ +`à`è@F `£@Ã) +ì;Ÿ[7’Æå”™D";5(r ¯½B¢P½‹) ¶Ý%|™*œhªÄÖ¸pšU˜F¢©.h+í-;ÕT ÷X%Dww l‹%´Rœh¥}\Hž™+¢™·=&*¬ËÞµ†Ûס°)Pé6÷¹G’}óCMöù*HM|Ž Ù;v«ìÚ]¯PrB¢qŠ54q²˜WVhNŒËüáuÚEˆa4+¢)hAKè¦p/¥SÒ›ä€þ‹ô#i°ù¥Gu8/ªTMŒ¨I0Øeg?cTÃ;–ͺZ”xj6A³Ù|bgi^ ôÖɟ׬Á%2¡ÎYŒ m‹!ÙPì`ÕYÍ «ˆ}¦§gŽÊ'Ì7¡ÄZÜi¨H_ÉâAjS}©5Lµø7éì/ít1ë›t'îDå®Ê´r‰hÚLÕÖ6êû‚•@ÅŒH¨ÍçUà Í{LŸCp>D5f¨íƒ§uö6´³‘æà2 㜑²ï#ãiBüáfýŒ4[³ùbBRªPlë„,LOì¿1ó©…ª>žÏLŸÍE¡Uçd©±§æöL•ñ OE…ÕÌÜù¼†xÍ'oèç.ÆÎÇL6ó‘|FÖe^È\V§e4:b*$ŒQN _‰ƒ+ ÑFžAÒ3/SŒ E±a! +’{TR#N¨§þ§/“Ç¥aÜ,§¡¼¶‘G)Ê7›³®ê¢ÑÇdV"Ï”¼†Ä'/N‚p®¡"ù*Ÿy^ EÔÜÒ{Ä WòPÔسvŸ2͇‚aŸòüRÏ©ÆC¼æòDç­¡™{¤Bcvù¥YP唩 +Éi«ê_rΨn¡Í«xyU°Ä„8«Ã:m¼Z$%òs¡ÔuÈÅ<~ +‹Ü 'c"°Êã¦ÆÝM§ú¹Ô*%jÕÇD•°¼QB®†Äx³²Eѣ䠦f¥ÊQ‰… ™i•NшVŸz™³ž½½i4j¦Qh+‰B+ŠaPœâT–¸=:ë +)ò}j~Nˆ2µ('‰–ÝÏ;JÑËÆý*›NCrZ$+NeiõG¼K¨~r8JÂœ)[:–.(n“P>}I8´tÚDdq&ˆ.*šNóQÉbÑ5å2¨èAæ&å‡b£¦Ïe¡•Ñ‹b-½$ˆC­ù²Ë“ß- F_]½A’üó;dßL¹)øí¦‚X„C»ch +}.AD +zÐéQZÈŽàPñmD®l¡Ž¥Á—Ÿ!JÌWVtÌ·“ÐHºÔi(‚Q©"#fôMEÁÅ¥¾ ·lÄ^ôjqqÇ r&ÖÇKÍ#æ; G›©ú– ]J‰Uô&y@m¹×ËU¿•èPÇÅ&Æb»7RÄѬèî!I£!íæ˜ sŠH¦7PÖ0_r +3w‡ŽGâÕ‘‘BŽd[)gKÐU¢¶eÌ"E­LHŒór¢2Ê«I!“ìL#A*;XÒ¾›=äGH§¥Æub….ùæ6MÆŽŒå=!5»¨'žœƒ‘qÔÞ81ó4Ö!UÆŒ#G°bJº˜‚£l"bzËHb +DB»OÃôŠ¸—ô#¡5f D){š,J¦#U?‘‹¥Áê9s)—ú(‰ù:”!•\šTÿ‘ +X,¤‹…&½Ò +.©Ë“)Ù-åa.D®^ŽH [ãY ¬ðž–Mrtoa±¤Ü‚SNIŒ)ÆÎ>5Xr)¥±-Q"]«U¥Ò¥R ™Jö”ì¡šÈ")sªðêÔR2µÖyÐíú¼+ËŒÂß–-äJ ã%”zÂ@5í¦qùäznƒ¢Î!µš‹ôÛ•‹wdQjžHuu’ÆŒÖåð•¦yƺ„OOUH±HVÚ44 +Ú¦(òeXN%¬ ‘È„—Χ×óéQ'Ù8rû‹ +ƒiõ\S©¯3‘-BP…ŠX5gõ9#k¬zXkøÆœ˜kB¼´[†!|PåÏ›ÁuÀ`³p§.ûH:˜Íýû™„oOª#T4¨h{c÷†ê’õ€¤Qr{Xb +õ`Þ‘|²4$÷‡b²ZÕ‘Ê?=p=Œ4Æ‘¤Rv¬N”Œ™9”?öÚƒ š71ÊŠa‚}PQâr£Æ< Cø€«-P=¼‹¢WPM·°Tù`m+Î!‚ÌÔ#‘ƒjìŸbMNÈ%‚:QN‚ÈÉDÁÕ¨ký74SŸ‘Š9Ø+JNMn†dUIE +E«DFÈk“SÓîÁ8F£šÔ+ºúÜÁëÒÕ#ñ£ˆwuv§Ö¢¡zÇEÓ<ŒVüP ¢uЊS¤ì :¨<£(‘Hˆêè€uÔ:+zÕ3Å)YÎÐD× Œè€d„ZCO(™Pôz´K± Ç®/JkÑø¬ìde¡+SM‚¦ÜÒÁ|V®/§·ÁB51¤¶ëSƒƒ‚«+Gx5óK¸ƒqmd£eO=’Œ¹.!7²$A1‘¤›>®h†’¢‡éå'ËI*ˆèEÚ E+y‚ÅY_!ºŠT7‹úßLMÅLÜ]‘tHâ-’ôC‹žØ )¼fL}¥˜¾<2r’½*Å¡H +SýPd"BAE;Qj%z©ÁñiÒëÖxFŠü³A>9þ]¦Ò¸:*#”¯pQSQ­Ìå;‚uDY¦u3j\Æwº¸ÔΆRîUI-åâs1ÚÏ)pbŽ!e-ç¸gÒ~ûâAVâRå$ú©Cì}…BŸ2´ê¨;Ø×Ýá)62c‘nÃ-ÄE,ê©%ä[«›Ñ8à€ýH$ä ¯F³ô€ $¸<@@C@ƒ PÁ @pƒ +2à X`À80 t€³"¡>Á $PÁ.èÀ:0‚ €  6Ð , ‚ x 2à€\‚TðT‚ $ ‚ t0¨À‚`'È@*Á2Ð x@tàA&¸ ¨¸`$(À,"Є@Œ€€EV£xÄBJ;iÌxôC´èæÞP«ì6¢ØømÔµ×L¹X̻Ľ:PUª•W$”óF$“ Ŷ …Tc$*¶\ ­‘”‚¢‘Í¥†æªpõ,ÊÁXð˜i…{ —Ó©\EªÃ4ŠL)X¨^¨'BÁÁCª£ ž  ŠGZœ +œ¡ƒÊ£”ŽU’áBãqÜTð2Ž(‡5õµ6^É‹Lɤü„— •òE¡—1±±%(#KIѧ<±*žõ…tÈRÓ!I‡AºXXC/‘_¤ø œO±€­)Í°§ j°âÌ)ÇOW±’PæÈð#ÍæPF“Ru§¡„/æ® $oÜ_{Œûñ‹‰#G-æWþ£B¶ÿ“È~ÐRg$Ô®³¢Ùį†JÒ"í!Å šn¦^}i½Ø“TÈOå$T“°¡ÆuÈJF!Õ£ûQUâbiÄ÷¥±³7R‰2ïeLR*‡ ëd":hùD1cIÌN.Q°ª5ÑøàÇ|Àà§õ˜µ×ñŠPþX¦X­u¤%Óª$Z¬ñb%4¬ +IöK¤’.‘pí9ZŽ“/ËáEÉË!$5Iµ3ªîËCôÔ‹JÌê¤&QbÃÒ#Eå—|ü¢âxjÂSºŠ¨Â/ ]£ÂSÖË­Šj[ãŠ*“y©[ªåq[QåŠS¨Ù!*2Gä®W´–SX¬nÆÚžŽ MÄv™ÚIØ«½ÊDK+ãPYÆÒ*¤â"1+²¼CCZQV–¹x*²šˆK‰]U–ºˆc5¿D]ÜӼ “ìKê_JI"Š"¼ý¾$3ý­‰ûBÔѤS¡ºÜ¬äÜú=Ÿ’–-Ä( ›EáÔì²Ù\Ù™i︗\7äóé³OŒêçÙ<=ÚÎ-’N9æôÏM]$M£j+vZQÍ OùÄ»óŠjë`'n‡´XùÒÑÈàËÕÎ6qŽŒ¢¸‚ÈeŠ +E…¨£&j©ÄA$huœªs˜ø¡Ö‹­…"NuN‹†„V\”ù™0!ÒÕŠy‚ZñŒÎE<Š(*ŒÐjÆU-K£lW«baª5òˆPJÊ­•d*8AV‡/ÅcžjFˆÄ³EÐ@xP­x™ÉÃ:DédP±DES’GIDsµÊ×áÑç3ï…*jV:U+Pv)éA¦7FHÊ!æ#LˆˆŒ¤Û¢(5T»Ü›¢œ¥®׸(öÛSy4—h.꺗©~ÝÛ¸n:€jED›ôŒ†4hDKp¦«‹õñî!ó™>xZÕ¨ÕJôE.7ŠZ~C$JU!Ž(«L/Š¸g¤!$‚‰Ÿ>YMqþÓ%ræÔ’ª$äq~œ±u[aBEÒÓçÑKQ5Hˆ2‘›5³©T o ¥æ昳ìËÙI±¢¿äRI‰SEDâ½V¶B·ªˆ\˜¨\ER–E¤´)®,›ˆ,C–Ë“—YÞ®±§…Ü2:§¨tpgÖº¤çR4£ £8 ‰Æã’±œê®€2B…Á€0@t‚2+LÁ˜¸ÆNõ0;å>¯ç\à¦å>ÿV&YW ¦uˆß²¶ R@^_&Mwˆ1›ðájO¯Õ‚£¨%ò“¶GàÈð> Ô@¶äÉÝs6rqÅ×¢ð[‰a§“ÙèÉÝ>˜¥p|µ}¬g×iº{¡¸Ãxwî4ð0–kR“•|€£ /"’xˆýV]ƒ®llÁ?ÞŒ^€¶¢‚ù§tÊÅh88TX,j7ƒ7 °ÔgÁ¼òGР׵X»øßG'ß ÊÃxÆË`mÙ Ø©UEá#‘ù•*Ê87'pSkëR7}¡rTª™ò…¥ Ößµ€ûs6[èx8{[˜ó›§z&á,mœ®ËaÌðêŽ"x-†B"*^ÌSï ‹~¾Éêç3$†Jo©}2˜WÕ*þˆ«¶‘»ÚóÎ ‹½“£ÿ²©ck™n‰e*wzSqÊb²½ £©÷)—TÊQ6[¼qŸ±üß2€zлîcsãž +Né^8•qŠÀÓ÷N\@£±ÓLpy²I›&±ÔT-«‡‘aý»ˆº Oj2³xL÷_”…BºšÅ|Ý]^¡Ynàó@(# XxÇ>s&ÏæÝhm=/¨S+Ó¼‹ÆLߺR7)Ef¡Qì`é|%¬½ÐZ`X·ØßJÌx_W¢,“ËÂbÀti©¹w9f»$&b¤.›‹cÖÔ¼Ah¾Ò–ÂAiŽe›îæj Òå~‚bÊn5lQª^XqÀu¹Y’ËÃæÇÌëvx %–kØågý14äÐ^«ÚWOMRø*A0ó9ú\Ð#b¦¼û›‘¨„)¬=„‹GÛçÛ»çŠÂÔ¯¼-:Ð$`î+4¢9²‹Îyqèá%µÚQü€·ÃÓ_‘‘yê@B7BC\^£,h9ñYUpªÜ c@öâˆÒ×=wüGÝ›fö|b¬³Á åiEs(\æ¥2àW’7·é>+v Øpâ‰5BcÞ@ÀÉSÓóÐÕS”¿O§tÊo£ã]£×Pô’!:B¸ +B×£žï5$Ÿ‰1±½‹\>þ•‰¦kô .é ÷ç”bIù·þ +·%àËeïêÐêÐÑ* ‚BäßÒL¬ËÿÄL˜V[V›G/£ÉüGf´nš£VOíõ{ìý0¤_*'–k°‚Â+ŸÏ˺4Âd’{™;`­by’ø)°ñ’2h6ç{ð¨¦ïGŒÇÀ=9fôåÚ£µÅn……^–Æ¢›+A“Ì`èâBñB'‚’òe Ôh{¾¨Ðì¨ÞÛ°?‹‹´½x¦žtéЯFhÀ@VÑàYzkÃm-ã‹òk„ÖÍ̱ÐÒICÕ6Ń&Jˆ*¦Ôàý løží>\9JCféûR¡0Jeýl¨ DT D^:q¯BG>ø'‚_µ_›D­q0H^áÒUO"WMzö‰'´+À%D1p=*ÅdS'U7PO³Ã¤p–˜Ê»þCº Ògä6íá&Z« ù¯ðÂÞ“®”ò® +F`òewâŸC¥‹ÃR»¼{c„@õì‰ð¿‚ç“érÔ·Ùöî;&!¸ž‘Té£v±@ᶺûr/ÈØ©÷|-›‡†–d+ÓaJú,žª²$êÌͱþ ƒ¦ +J†b*íN’ ¸c>Š%p´¨ÚXÓ£ëûs`·Yhg'äÙ»ž€•Ñ"ZKHõQçÀ#B·Eø€%þ²¾ Ëô¡‘A%R<È4TÔ”Yøßê´nÔLçH3"RS½åÊן ]û +®:ƒu%10a&Ùâø„ÝÞŒ$îxÑY—lLÓ? +àëi´¬h#UùêBeó:0¢Œj4¯^"åa°BÁȹÁj&"H‡iÀÍnL›…)yT$&qêG¸ ÇDß`ôFÍÆ‹ó›a7o'bq˜¨og,L<£¢¢wÂEF“Eâc€8æž ðÚÆÅZ$qRÚΟۤ0Y5*ÑâHí›ðŽÓj ½¢*â÷þ>{çs£ßrÚôÉ)”|,*¯_g¨";+ µÿbûH¶Gdæ÷àÇih°:^ÚΘéaäÏ5à*×n$°ƒ•iwç…Å‘] +% 8üÌn„P„þ› ~Ì,Kð•JNDˆ4Å“¤µÏqM»M%ÆMSQ +(oËV›7Rh©ø&ou8—)‘Òý^híìÏŠ|ÍæÀi>@0Á‰×Ç<’û`zaJø§cPKÑeìlËàŒé§ä{¯­%Vëen¤Œ3)L©m€SdPhDˤøÉÃkvVmŸ«µ2s_ B[yOÐë Dû-¸”0DælÑP>Í[Ó0ÿbQLÌícŽB|/+¾¯‰· °¶bïX¦½øÒ-·Åõ½òQãŠÒëÖÑ”Käñò‡ íÛÜZ.ZJXâCE”bzL¶¿³ôh¤Â’_˜ºHLhDÍk´„Þ"kR + T2öuDØ©9œ÷X<#ÖSÙP¼­Íë裘œx»S눆æU¤¿A!ú°u!çÜ)3º¢ xãÁ¿i‡"Ûäâ+0q q’wµ+nĘ(×ô±¤V“ˆé Ðšó&hã60»øøëRUs(æ—XÇ™gÁqPÑJ@Z?%ÖŒy—îýý§³yªRz¡BU}‘Ån•+að –ômd&QH ÀŒ^íÐS +ȸäˆ×Bí0gÃnÁ:°ïeD²“w+,J3‡HÀ'uGx×·™L¢Î3r”½ó·ö?mÝÝÆ|ðö-ù†œo@GÿxsúÖ¨Qÿâ©(ß\íë¹#xÖÄERÌ¢´øm;g¦ ‘â`ýà÷U„׬#Ÿ£Ÿz_þäw¨F#J˜¸Vºžáª1ÐÆ#N;–½,Âvpa£P““Õ0ä»2rýù~]ø•. FT,|€u¦È;͇0:\€´ÝÒ~.Ë#jV ~ž°FÄ¿Z'Ï!ÌÀ„‚£W+À~…Uk´Â#UïÏÌŸk…½ŸÚõ@å&>çäXÍä1T+¦‡âÿ€žÏ+ƒ16ƒŒášòè;*µäTÀ)eâÙ‰` ý}®­”BX”¡™c·£Ë…?‹DJ’FA ™„?å¤â¸ˆeR(DÄ€øÀMcðS—z +De œ×ùîD[L·¹‰Àgg!بj “iÊ;—ŸYpèˆ-E²Z_ìL—9ÑëÉ!ÝRà0¼ËJ—™u-wgO¸cÿj#mª2"¬o/þ¨ÇÐà’Ê!·"`Ès×çã„ ¬Aä¼€v9a2ØYépœCý>üz¸‡ih¬£å¾pÀbƒ+!$ y¡¦àÌçïà +µŽj¥$Ã12Yg ÕŠó +?¾Í»«üF{Ù…o£g4v§Ug\ ·"òÝ•Ôªl4(DÜÈpúö*Úm +…š«©Q|pbT‡¾P¾O†ŽK B®äŸµë2M4…+„”FL¹© rùcµ ÆÙz}Ý‚> ©Ä'ȆùÀõ‚Ù¹öm€†¤èšë–[!7²ªø2/¨2ïÞ‚ä^ùÁ$´G{’ jú¯ÊÖ&+Éà§ädAØ!%~tûJVk4€æ߆Lƃm4zê ‡á*2²Ü3ÅâŸDçd͇bî вˆȨ°°gÌZ^m9ošºA6=”Ÿí]øR»Ø¬C ÈQª +€ýÄ1@Z»ÎS¦u©àÕ¾†¿ÿíLÑ{ü>A'+&`I¦q Çõv‡§ ßyZ^*wM›“ž‚¶ÃAS¥ÛÅ¡xý8ývnY‚"‡ˆÑ¾à:ß±ºkg$ˆGœKR¡–±´.è˜Ô§Øòmtg N…ŒM–T"d†€•ŒÀ]híÔžèO?s#¦ùŠ³µùʨ·y¤ÃF’:«¨DHµð¾{v'¨}æ@Òänñœ«¿Æõñw‡(¢ÉET§kÆØåˆk` +ý¢zÆð»MûÇÿŽÐµ]Ùç1ºäOTšd8~^EÑæ¸è؇ ¶³œ4×=…¹çnœ¬Ú°µ~à \Å!Âl/p$¸´d°»i]i;aã5çÉ[„0úææ’Ü|ÄHæ óJh{Ð̘uÅPØü?ö>ÙiiÑ{6·!Ń°U#ð·YŸŸ.ÌØËÏË]ó{æ=3Î õÆ!+±ˆ#ÎáXGV4°ò¨ðKtNÜ™÷{ +5·á:){Ç+¡ž™z›† +&’4¤]”~i~¥Á’j'~”°¤Ãîk4ùÃE ê’©.Kˆý†`LüQÍý‘žüv¦€¦µÈ¢ñL„xUn\T¢ë•hZ¦ÌK¯ôš.Þîšÿ­Š`†– „nìâKŸÀàq\m³‹cÈC‰;Œâê(PÕ3Õ­Ÿü^Òþõ¸·@ÊÌÒ鈌öéÌŠ—=ayÈ”°ZÅ›{õ5¶çž;ïÉÐû‡Aw­«q ™”¢‰6` ³½Ia¯ÇP²‘ ‹®2²_Ê!©MtÏ(uåÑJèd=Sž‚£&Œ¢~CA¡Ú³?„ß—n@íbÏŠ %R™]¬å^ÑÛ3he+x°µŸË.˘RÝlGò´c-tó)iH²nP“ap V˜r7Ä3¥Š\L¿«Ú<Ö¹3]…qeþ G}™Gi§¹â¼ú Z¸NSNF’¢þ5#4>ÖîCoV–×/žçë|rÞ*ˆÎÏÆu­:âH£Q`¶nƲÝ\bv~MІt˜š¾ýq\{;+{>Ý3ð—l_nìÔðIÅÑÚ|­8¢yÂQec¢ÒÄ©Büy˜_Ä'w°7ÖIÜQRâ§È–ŽùÖ³´â$Ië°1f" R–Xo¢jÜâÝ®»¹Wþ.:i6ÿ]÷pè7ᵘé4Y;Qu\²i€ŒŒ«N¸R¼j°躢Óؾp7\½¢½¬øßþ¦…ÈSáçò¬F´WWÌ°¸CÚ?6'ÿÕ½½6ÂÓ“fì{4"Å ïJ@ Å,(0Úh•à,¾œi¹° ºRr`5쀕V ßìöpÔøÄòy1Žrê*=±^ÿ®/ÚÊäÇáy¢eM쉢گ>±¨®›ò@¶õ %eڻѸ=ÏГJXïû +Dœˆ‰(͉-í¯ʇñÆŠI@}~5BÏ8ò¶B|@^·€jý¥u¶ ű $éè^ÈŠ*æ¼X~å•’9w—íÌQÞöûnRøziˆzgde1¸gXOS‚ò§±]côL%Æì¢í*\” s!cÈù F¤‚“zd2Ø&ˆ“Õ9J| ÇS©¥ŠXÓééy½O4$\OxèÛ`¦Æ tóדæžÁˆñ:Þ€[Y^JN"_Trˆ¡©[±<±^‹½yaŠ–]Ψ‰6·Ûôà';T»íy¼Z60 + º‘ÀÐD©éTódîzDT¿î«O+¦Oº`}4„ÉÁ–Kf6úé“»hÝøIÈeå Ÿ|^JE™Qih¸21AIE·J©óî SaÉ®Ïõn^@ÖN‹„é9g%Më¡ +¸}¼Ö eJ¿a9KÌ1š‰NÑM(Š“Ö'.ÔÀ Bš¢ûR`Ž9J-ƒ~³goþsòâ4¡¨ì¶!*‹ðiïmÄ&öuªÀÀÔCÖI\KÏ=ösŒS‘Ú4í¯‚=¤ÊÝô£s f§‹ßöG… •Êɺ9¦Èµ ( FoBªÆ£j™Ñµ>ýÙV¦^'[…R«UuKœ´¶+„e6sѲ2Ûf‚,.ú +yg ÜK•Y€JÌ­Ïq†¶ÿÕâU§™6äƒEãkªñ œÄpâ°4[‘ø_l. Y¦'‹_f*w2Ÿ–?)¯E%m»Ø V¿‘ÍêQr;;³Û  jtFjgä/‡Æi~ñ¶Ž€±odýgVØ®©Ã¥¯Ä…„È@w +k©ˆÓAp‹ LYYÒ\ž>ÄJø0ÜkJ½÷öŽ‘\ñf½òàs=-üša‰²d¨huð×Ñ VÔùì‡$iõüfK„è ÀŒÈP…wM¯´vMõÑþBÝ”¬ÿ‚ù„xj$¢a¤×SR¤éŸÀP­L¯ôze˜Lþ—·/«Ï»«a¨M¤Ò™eJ¼ç´»Ìg 5•:î°ÙHD'Y‡‘2™¥NÂÒV™aÐù~ê—=àdœÇݦ+#:á¾Õ5K$š)Ì°›ž!UçN„…:.ú²¨H汓²OÎÍx ‹AëÀ³ÊwïE¥5myBßÇ5)tC²b_ʲìq¥ll _MÝ|‡uÏ1Á F‹ÂÉÀj!€øS𹂻 +¡½záƒÄäAÙ7SÚQsTìï©ùomŠU‚ÎË™=I f¹&Š%lû9‹pJ ˜À×·—€ùÔêÿ,€kÏ;®Ôu¤¼uâ"Ä5qÖðçøšúAéGû‘©Ü£ 7AK¶»j¸E”¡eûéó8n{É4܃ê×!žæØm.| +–Xx‹’s¹Þ|kê.ª‚LF.ÙãÖ2\3[*NzØžBÐë“aSϵ)¶?*—Ì(HmZ°Ñá®%Ì®í³ð¨Ðß  Œäª„ù4éÝ(mµRu©ˆD§/c&'˜Î•†y¸2Û¤ºGhâ¢Ì³È¸@ èS}=(°I;>ÆYýxÇ”… +ÃbÏ åæÕ³ZÕ¡Fò7áïo\ZPALSŸ›ëf»Öú96AçpüwgÝÈÊÖ­9"ßFYLE@]SHÖxVë?qŒk¦O¹ÌÍßVfÞCäÚÕÉ”™¬C]³®Àöýž±ÊªÒáª;è!+ö^=Œ:•¯¯~lˆÓÒÚÏ”s!p}?:îÀtA¨mbÍÆ*NÏšB€¸ +ÇÅ3@EŽIXÁ&Qþbxž'g4ôÆùBÏ÷öþ¼N&ã§2Ü0ɉ¤J@ˆ§ï€µå÷ÅÊßÓÇW¶¥¢j=x¯aEqÆ·h'v4Ô öºI‘U*/NŒ4Ñzv$V”·« “µ¡ƒo£lf+„g“õA–BÒ9ÀvìÔ+‚¨T\«Ûb¨‘Ù  ã½Vx~Id×#Ò+.Ùüì˜L°à²= þÙfÿL¼ãŽJ£ÌŸ×g€Ûª ±?˜›dÄO(vêõÖ§ôágJØ!Ê ´v“ô·ÏÐvYU̽²ÔŒ‹``¼g˜ÀÀÞÒå‘C«í1»8$D—*¼²a“3`èO)ul¨ÐÙW°“ËÃ|ô#kâTõX&®=ÕAŒè{MÃ?˜,;¯òÄ¥›t¨'*ôÖ,‚LB9ˆ°(BU•õû$„¥ñ`Ÿ5Lƒ ô¥— ´ºw M®Ö@MEºýJ¶-ÙI_þìL³°þö?ÉÖد'T :Ëâ|ÂŒ“îÇç류³£àÒël,@•ku\<$‰;2‰¿7éqXú¤‰ÍY‡qðH _l'íØÝprÑ¢Š]!MžD(0íWïX#ÈF‡>”r¶˜ºŒ+«9È’¶æº¬×ÓVá/ŠÏý“ôŽºsܸ*,:/kbtÅÝyÁ¼ÿÄ…€%ÉР¸°ñx1]Ç©ÐñOÛes‚72êº8ÐŽ¬™{‹|C™ž`öúm„7̬sýqBœ0éæ`º£jø^µJø­$5,¼»ƒÊ`æt†ÅG9cÁÕ¿3+]a£É5/Ârê?í4¢ÎN%R¬S¹ õ?dÓBõh+ ¶…7êrÆ¥äXÀÇâ”ôjBòçë5–ìðMÔŠìx”aEäèZ„J?àw床 ·ýºy5Ì€Ôƒãj8@‘áÅúï…¢}B«@~xÍÔE¾èýþVÞº|ÁXwS~n8@΃ñÑ롽èÍ‚+ngšJ<÷<)L7W1Ó]=ö:åMØ]ZyÓ«"éuwˆ•m¶A‡ïy·Sì ÀL±KÛ|&?®yNM`ÿÁ>º“ý¡TË‘Ö $›q&ä\m{àÇòÞ?—Sýž:!ñ]ˆî(œ9fLV–ÄIa8‚ Dçhk`6«d)‚'Ž‹¾éœ€E+ÛΞ{ªt˜Psê¦2 °`ôci„A¸åão +Û¥"ðvÉé©2ÇÒª|oUèZYGÒ,Û9 +ù»f7Ht$1·ß{Ìu7ü¥`îá…<õóÉñÎkÕëX^¯½)*¯ÏÕ–³‘M!2Ä•Aò¸ꌀÁŠñ0»$#YaQ4úNpKÞ Ízì/”åTJ7[q8@iKœ +ºŠb¸A§Û,îœL5=œ—°ÎÓBß* _`m ožUwó4Çw†ºXé ^®I༠UyHS™ÝFEˆvb25’ðÂÏrü–ÛÒ©Èqéó—ݾb£PJà«TrAd@íh†5ò™¶Ã^oGÕ~ ny 5¯ö“|ãÈqîGQÖ'å´±)‡3b]EoéÒBqÑ=³¢pCÖРØpyw´¹â­"ᶄ¿}éFG`BÄ –q-ŠìVÖ'èQ(çJ¼hÝÂB MÄ)óŽ7R£ºg±¼$îÜ1Cq–C»%Ê;U­×¬Ú‘Èâó’?zŒpÙÙîû,¥ïBŠBÜù›øé3:Jdß{2­ñÄ4Ë6»£|lenùQveâù»rWCå+|ˆsæÌ5x×l"2µ©WOÈ&h.……«JUø —I 7t±ý¨ƒyÞ?…ª>½È;¿’Ý„8ŸÇ¾n(ƒ‰ Åá¯ÝQ §\,\ü¯¸ÉOº~Óª§¬¼û\™+áµA9ؽÑ#mª +}ùáî,s™Zôl©'`A8j}Žo×<¥[È Ž‹´†C¬Ý6æå 4R »Âhå¿Ü+v$9@Ð{b5Ûª`ë +-àkm½ÄŸÆ*½›šµí~ð%·‘šƒG|m’ëªïžc+"¸CsÆü÷gàÝò1ôµó4Cýø]ÌÑ"³«\µ}È4´xË=ï¨HÔÇò8G Y$eiñzëaob>%R¨f +çÆ73EžíQÕýfÛŸeÙlŽWæ4ˆ™ÆÚ},â„_Çè~\…J݇èëX³"|,LÂø?¿üg$4Á¡Ã°¿]—DQr×¢·s=]R².óåš™‚RÂNØüâàÜ]Éé“ËÌQ%Æfà–/ýÈ÷a÷¼D¿MJ²â»Ýï”Úä-!ÝÙ~)wû^íºx{$Ï1·‚r7ŠÖÀµ„ÂÈVÑ€GJèͳrxgCÖÀUÏ(ËÚËÒfB¼Ï§ü¦ê™yÞ–(‚2Î+*ÆfB¶×!ñ¢)vE2Üþk¢ •Ç–ÏAþupÄÝ@Z-Ѐ)UPÊ‚¸õbÿ<UÌ ?3£ÈãfJN»‹¯WÍ­6JÿñRÛ|]a!³KÓökoÉÇËÞL"` pý_l7šÎnQœœ„·1p¨"Ä+ê-¾T±-S’Õæõi3I׸·'ÌH^ß ñl”$w¿ì¬ +ëp­'˜`Ø:¦Lò–’Åd‘¼D•™Äct#ƒ¼P­– *ÅN!A-`u‰2†ze¿”c)tëR\ãQûS«„gD.—»fŠíR`> D±b×a… Oƒ™^ìv)NñEõ UQãð~ánõ +€¢™2ZÂYQ•PƒºAËë9UÂáʧð”cZ³„ïÕ‡cð½6¨¦ßä÷:”°h !’ Xˆ øˆ%Ü<—ÂNÙÀò(hEþHp.N( ÂÔO8 '¶WB÷|&°bJ¨áIì%pð–$ U{AB !¼#1_ Í÷®Ÿz‰€×+9 –«ßN#nãÒ¦GÍ–IÒ¶-Œ/Ý8âDÛKbÐîÈ5“¶~J._£WY¼WMü÷$+§!¤=¥‚™¯öN“öô-Û#îõžMY)L‘NÚžje Á!ßÏÁéG‰%æGK^¬Ýå*‹íï\g(íç@–¶–s)”í&K›] +“+–â"¦mU–"QÝ/ʌŘöϱ<-í5ш@¦ý] +Ä6m£,…¡›6µKa¶i;,—‚b§í¦p»Ó¦ 3Ž´'ÌÓ>Øõ´‡ÉyÚÇ^ =í,SÀ(gl +ßQi=m³bã%F~ªNíh”JÌd_pÈþOíð§öw8{*Š‡óBOÍlÚA5ýÁ>Oœ)ŒjvÎŒ8ü+óuâ<À%Zz8ô† Âä ;XZ¾|Xá9FvðŽ>”D¬ +óïï½öåvÛuŸ,ÙN»cÀ9QÙµ…¥aw&/1, ý5@5ù^·‘Þ;Mè¼wM죪Ž©Á«`FÕ@‡‹zΧڃúþqGvWs­ºƒÃ1çIÕá–ÛYspbb¶ZÇ7hpÔ†1„gÛÖÈÚÞ% 5¶›á!Âóh2´Òpº»{Hžì„wž{ðæ$÷àm-DOx“d„‡\âi {ðÑ+ácTï=(<tûb Eú ùHÄîèðh“uúÓRŒÔÇÿTë°ËÍRà©xV¸s_…®kö$‘¢¸;mš0ÝvH™þAêì\°‘sóôkîT¦.ºo9™FÔ½Iq8F_’{¾†Y „½Ä„¾ +UèðAP.À·•ìFGñÑeãÒ1b#ï×r ¯Hþ¡)üµ¨pu„Ǭ?k¯X ð/ß¹ ïæ·u§ÅHð^f;ÉÍ Ý<ôzgôþÃÙTë÷%òÀÊ4õîEƒ i‡q¦üŸÇ]°U:Úú/]a´èjbÓ„ë7kLMØýib»CCËgÛþ®¿wIÊKÞAEéä&›î"%]‹;)ÖE.²ý’­ÂUv ~óÍ,9 +øþOÝýbÀ©ª[ &+AÝ)ie:îü£½7eŒ{þ€!>¥¨=Œ0§@Ÿ 0LyÞl rÁ,|zåYs{v‰IËrgÉ»]¥;[»8I;B‹QȇY†Ç-0Řž. +¨¸p²6"Kªr$C"$e ¹O” ‡]º/@ÓDŠB¿—’({øÎ(èçÝAî¶ÃQËMM˜Ã':Ë0ü¬K¹Â6T<’ÛosèHSŒHŠÆ“ña‚~—Vßw·«´›€§tàwÛRÅ>®buõ»/ç·<¹b€û*°8GG>ÍJáæ_ºˆDOm­“Qn\§Õ—S¥9ˆ‰AÆ€ùÊ2ðø¿ƒç…W¯°>¦Æ¡ëüÑ&YݦÚ—~ü‚¡ß <+¯Þ’À¢&Xº"îJ¡Åžg–Ÿ4-¬+BÛ%® ’…ð1D=6„ñ[‡˜;DÂÏ_$WÀÀVìB¿µ'8›ycGØÖÚ5¢>>| iû6Í,ð|úä½Ã¸ÌÐÝ»0k-È•_’à8n28vXAÿÇ+Â(,ö7t(s¾;ìcl±Òó­E¨2¯úÿßÕÓ»jÁ;É+r£Ì˜½€Ý:õð&¤ðʾÄ,ßM‡•áŒøϯ «º³ðUS™°Äf¨õk©- œ#•? ›rÉúJ "(ÕaZ¾35‡®úÎmã`×…Aþ†¤`qƒõyØ`ª¾ƒ›&ê;S íé;‡—Á Ãc˜¯žB—¾kàlªŒä;£ÂHùÎg²PŒ©Š&-Š¡oTå¤Ír%ñ"…1òƒÂΈènî»®1A@DSBaßpwú®9ÒrAäð‚XyÞ»\ñÈ@H,ß%³} K% +Τ‡èU;˜Ì$¡9¸2ßåù˜#ŒalVA9‚emèµ Q5T' ü+<¹wÞÀNNÁØ;Gª Tgw¿]83Ømýß»³)5ôÌàÍ ÊXþGf³ɯ³ö8æ”uÓ¨˜Ã–ÁŽ†g½²‹cúAÁÃ_EÙöäÎ:«[ïhWÝ+ ±$[ÀnId;L–\‚ ´£)rêžâI¤¼ª0uð¨+~Œ=e*ÑÅæ©ÂΊ™j¤ƒã6!BbI"É÷u˘~@uSäGIa5=;„ e£Ç:Ó}ðׂíÑåÝ¥k£qx³÷ ),Nñÿû2@¼®=éŒÔìªîeœÈÂ*Ù†Çs——$† ÝO…œ™žÓýÓEºÝ?åß„ Ê~fB¨Æå¼K8_èM%F:s—D‘8ÜAWHáHÐÙlFD¤µ´ +ïÿ\î‡Ë~Îh Q´Ï© „0ûfeìлç<ô ê9-öÁ¨oâÑgÈûFcªÑA±x·Y ¾VÂ@ŒŒÖ}Ë,èÙÏÃhzFq˜Óvù£ðÄmgy(æ)Ò/RÝoG†‡-¾ô#µ'3æïRXŽè[¨²„œTüÎP¾à—­ž ,–‡çÃ5e £Ínv‚´(…!3Cº¯À1p'ºX„í‰XÞgh[Ú€ä#¹j/ËLÅÙàËŒa¹<2E;lK¹,~M™kg&aÝFˆKxQ¯;Å, +˸ ØÅŸµÆð$SfÑcæÎŒ’l`~ ®rãòBiÑí4ÐäŠjƒž7V624®$¿£,¢s®—³B®²LqÇ…>#¹uدU¾º!.qÜ,ñ&¿ÇŽ–µn§^E3P\ª)H®¬ae_Ú‡jÝ÷O ŽXRëÙ¾–}÷ô–Ikø’ Z£¶p¬G@Ë}ÖŽp.ÓfÎMæÖò £b#­‡f}§³&+6X6AòΠ+£ñKGÇVšà¦µFðþaŽ:_.p&”N+µÐ:•‡YÇçî½VrV—BïpæH›ö׌Ÿ:™uÕ?5ë^t³®‹k-ÔÀ‰Œ÷e4ë2`V:Z—ž•™ Zƒ&8W%Š³[Þk]܉³4R=nq[ŽÝ“Ê]ÁÇnMcžŽ§x]~ìÙ÷Ì)Þ·Sm™Ø$ü±·\Çêý>¸=nn–‰ô·HÆóÔÀìÀ4öb‡8²ÃjÜ,²C” ùL®€—ÞÈ~îñ“•ëÎx°ÈNz_ѹ;2¾EÓ‡}^ì ö:WÚÒ¯/̺¼A›_lñ×±9Ø1aìpÖPý0ÛˆÁý:2ÐëèaÖ–~}%ˆsŽÒxdMj¹«æ´¼‘?ðë¡H±Ï‘w© +4ëG”ä† +Â/ü²ð:§eªF´¸ÏWOm€¸®”¯u‰åa&õ7À‹¥—Æ7U~•ÓDãU±„l·>ŽÇlçaΆüÓ„Ö)Øë¿@‹dÉOýÓ6ߘ¸¸áû[èæzžé^S\?ϼ&àiH@nM¨¿4€”@· P*F)d`€JJØ ñÀRÇò?8°cãt]¢`ÜÀõÄÇ¥wø5F¿ž"e—{u@òðÁPpt¦ÊРœ&ö`¶©òq 'ïÙ ÐË¡•ÍÂ[à „ªË%†~'@ú§:âkxîÈàŠ²ÐP]T°«sçë $¤`Î pF7ë1)D=7. zû›29ÓŽýë›=”3¢H1Á¨ NJ÷{ô4z€8âÜgUÜÀǸ_“2oðPeï߆ç©KûXqxV¿E<Ëò;ò,¡wN<—°È\}õCê2ˆÛ¦ðNfëYÀ­wÚŠÀw›3à9÷ïÜŠ¿…f·|²ß–ð±;qAf‘P +¨àÂüNl<OO~–4Îw™JÛmò"TÔ©ð@º/ +£kñ÷"2wÊ×$$q·ËW•½âæÝty+kk‰Ô ž˜“.¥OD­;Ì‘õÔÙbÝd;S¦ÿ@K†ºáÓâ)Óm© ½l4°` ÔªVN, ´ôU‰ÝáTG@ äG€lHf»&<@2íxƒ5ÒÑÛáܤA£hHùê¼®Õùö:ìôzPîµßú”ñ,ÖÁ_¼£aê® b I,4”’ ÀŠ”íUûÜU³^úí^Ô»~®Øò꛼C®b!ÃË Xs®¬–w¤m¡¤6ØõàBAÁûäD‚“úN2À 4@ÉH˜©D4ã%üëÔt#RX©çÜÇ…q¹Bo[M¿Q@ $ÜÝæŽx2´i,HlË_5¸`ˆ +$j¼%»Ä»vVx‹Œ‰Q2©’Ù³f½–Ðo|¡Àbv¶q·þqh/ÑÁ½Ë‘=OV†~Q mS@€ÚKj}ÏHA†êm±å¤Ð§¨í4›]¹™Dû¦ÙB蛀l×0…*}(·€\=qbÐ\LåuÜKJö*8Üz¡]¸Q°œ=Ú³§º*þlÉ—Ô-؃34KÂ:ïʹA|§&2£cÐ +0oDˆê8ßæ¯â瘭~b¿/úAŒ>”ß«ùJ7ë:z¾yi +Ã9ñª”`ˆN}U ¼Ißqqö6G¨Íê~ Ýk[èÆCõ*i˜{Z@x…ìdƒD3Ùz bÌyq&?þÆ»\m"°[×[·’ýÝ€$-SlٽѼÜ\K7È>Œ¨É*¥¬-Œ,õuÆÌ ô¿»dΓ l^ƒ‡ŸèdÜë½hPÞnE¥‘›H†4iÛ9î\Z®àçq²d—g,°¸îL¸ºÑÄÝV£Ê¯S?ZT{z-¹¥· H/¬ ÂCò üóʪгó Q°ÇÍÓ,Ze$xuyÁ‡ ˜Êké?xfòÈïÀuÈ‹{5;^+3Èx¾¼ @T<–Hkį&J8žž$sþÔAÐNð:Œî1 Üçw÷R ßÑ%z繸ïØñ3dO€N‹ýOI.[ì'Ϭ&ÚíE7_˲í9œ¶±¤ï,9ÉC§lÿ †õí×2V×÷öÊé8èZ(:oW+í''/ÖþÓ*OœXT[Ë{ ©ÄÃ… FzŽ´Åt¢ú/Ïd +(ó­à(%Øn¢$:gäçefŸÙ?´ÞšãÊHË´š”ü‰O28†öÎ2'Î7'9¹0}oV8¾šLìQ×Lb'ˆ&£Gم󅛞Å~˜u÷*AŒx”„I¦í&ÌpVIøäÖa"væŽ uyòÈü'Yp0ï2ú NHHÛ_Xm VD[àHNäsvŠ°#ãXêaí¨õNCŒA¸ëÜý…º"0âS# 2dƒÉ&‚(¬“»ba‡:+ðlWüÃ=IÍ÷‰ÇÁh2jÂîå\Æ{uÆsª\™"¢ᔠ}Ρ¢ÜWOðdEíÁ†‹Ž£ÝùèƤñãv«Ê›™OˆôJ§sEí4À"°Î±EÆŸ\HØo¼¯Àgc3*IªˆXÁèågn^.Ù—ø‰{=á·XR(І,œ<ð»×Û ðåÕ»4/ß4ûõiÎǯ9é–Aj‹añ¨›½8é­îX +:Wº{̼•Ÿà—<¨·2 é²½”gE¯è»ŒœM51ûØí2›ùt% +«ùÄñ0]²­üÄu­ø¦§ D¿êwù¯™èçÂT^åÄÙ¾ säªRÂIJF"ÇË ˜Ñ&Í–¬}H–²sØ)/5WÞÿvdg¹N‘’ÈO´¬J Þ‰Àº© m&”À^}TUI%"š…“„Ñ~Ù^:Ý/G‘¢fûIøa$b3p·À¡3sV¥ÛR”õÇ ¥c5_‰`qh’#(4_ªˆÕ ÃÍ9ŠœÁnâ´oPèLëX†d’½ð)óXïd ªR¯øUßÛýð/Ñ•·è©ù*˜`žƒ,³èÂÿnúŒÝ¹£b劄Åo8ÒúS*¿-_@»‘k—1¼(ýä"û#%y9lX-φßQ%``aønš +óÎÐÀ‘UA­‹#2¯…|¾Þ¯ïÄÄ®•E‰9ã{¬µœ Œ°K€3š&$-ªçÜú5 ¯5$r'cBY]±3D·Ú‘íγÁl ›Aá‡e‚­CÞX¶@(÷Yñu‚6/¸þn”ñçûä·¼,À ÆH¤Nß7†sÒ°& T1$«nÇú›á=¦X‘”R< Yb¥Î§ ­¸‹ õ×tc6ÏD„k‘YŽj¬K€¼4P#J•”¦àº#J€\¬lñ¯²NÒ¸ÜpGðÆî-S‚r…† š#(Êcñ!Lgù…âÊ\&è›,ä)õVß»Á‰¬w?Œs%·Ñ‘2‹n_g]!—fPHjQzË”$É%²vŠ|“»ê„ŸéÕ4a҉؄° +ÍbÎ`eºÿ1ôFQ>1äb=kSøÃ`—ìÃ%µ¥ +ÜÁIå;õþñ_$ì›çQìîÛ¤CE’<‘ËŽ(¹dÒjÊù/éQŒVã.vR•1 +èh< hÊÃØ ÈQϊׇu<$àXѶ“ +¼ô¥cälÚ£ï`ùõ+Q‘hE|¢·ú +•…\s,«$—¨Ž>¡HÃ"š]ˆiƒ²è¿7œO8ÊÉQ°QYÁC~dtçì}xHˆ™3¸wO >@(ÎÔ-VY“‚”.-.v}äÞtŠîµ`8ÉÀ8›Æã±—˜³ž|{ï 'oZž|éñ~Š3ÍRáé'CvŽ ïheW'½ t~*¶.¯?z}dÛ%sü’-’K†ü +¸ð ¨FŒy ¨ENâîî—v®Ì¢Æ$&Ìঠ@éH˜[-kÅëÊ3¯M:Hõ +E¨%NRŒ[‡LË2W 8‰uoº÷y;T$rùåÇí‹Ê×qîÓUî“ÁÎÆ–Oô!Ë}Œ{Ød[Ö,)K€M²Ê„’À*÷=„üŸQWu¡cÃ7°Ã YГÂzÄ9ŠÂM88çþ°oàP~¯•}A3ìcC$&Œ¥ßôvZä°Kc®¦Æ+ŽÆ考Õû~‚Áüì€34±‡vý¬uKg^ßJãR‹4e No5¦›œpÃ4†Ìvû“ è~¡ + ¸ƒcÈÁã+¯¡Ûô1|©¼˜YQÀÎB€6p×ù²pÛÔ±s&ö¬ytd!JRvLƒâ™…Øy¼ ™T­†a”Êå®û¨b3T#º†ñaEÚp@°øl$„Ú°;&)nrB€m±ÂYF„Ž QÐ`kØ°<á泡ܺ`£J%«d c’ íÇ{çÆག± ·u¯7JõâË!@ ÿ@5®êÛì%œ#“r%‘•€J1!ÀÈîÆ"”Qê´2?X«·`o6åEõKåSäü¬Î§S?æ—ER `¾©Þý2eŸâÇ“bÆãsõ9?Ɉñø­ìVîô9‡‹ĪÞu>>ÒÜݸ"+ÆìøÐU¯ž´zk ãg¶æ_ÑñƒÏVW¹1ȉ««ýdåø‘GÄÓŽïBç¿¥‘?n8+À/¿¦‘?¿|û%'识©ÑL"œXZAý•3’+è»òÔ°èG4Œé»Æ/l'€Œ£žzùÖ˜î?ØC¨®q*#]¿!u´D7¼®±I5{ì|[[ãH¼ü2’5n9F<,×8,݃fÈ5JQ?ÄkŒ¨c )ìÎ k<±-¬QŒÓIÀ@Í·FÆŽ7-aã¿×jÍôFA†þ¸ÚOmªE§»Œ³awŸ›4’¹̸Æõà úfr㤚VzÀU;˜0Umò$FˆcHE + ,F{­s r9ílUŒá.†PŒÚÏDöCÛ‰Q#D£!WÆ(ñm©OŒ¢‘1ÒÍ’x ãöëT x2ÐôYÙOÌGÆë~«™žŒ{À‰£R6ÍEFÑ‹‰ë§½×{/,§ §3v™6`w`n;½ `B *Ó`‘DR°ÍRµzÎ=ZÈhoìS ›*£+ŒRQ nŠ³TçA[ËÝÁÕ™õ×Ø肨1x¯~W¿X£¸ÅOº­ûjü5 Ý;ðÛÃ\ª£qSÊ¢qòÏÆ.ÐØ<¼íR˜=#žRJ™0Â.€YÆ Ÿ'¢q|> õ hä¡̧ݎø™Æ¯aéoIëD p$Ù³Œ€ôòëäV õ ãÚÊâ'/¶ÂHEᨠãÛd†ÐÃÈÜ5êcï¾(OÕwfȡă¡ã¶‚Æf¹E:.Úé¥*a¤þ `b:ÊÑ2ø=%ª> AR@̇ÑM•¸ö>c>5ŧ„õ:î<ø¸¹ªü˜9m'ÞAÎL­,Ä…@cF‹f<®Å±°÷ÇažIŸ•Gý]±X›Ú™FW?šT¢¶†¯~Íìøã>¤$aù+š%y\ÂwHmy8„ƒ)ÊTmµ¦—Þžb`…MÌu~†hÑŸýx‚  Ã£¯H©ÄQX=ÜR3Ô'·š“~$¿óäÚSA—u·ƒEH¿à|GˆœO¥®Œq°Úã +…ï574ëÅŠ&ërº´U}˜’zóp†Ÿ†P?&«šCôiPNáp š”òIüZz{ºzjÈKK*†æõÅ] +N÷»Dο®À[} Z·u²K9^íOmà–K¹èïü-vgú‡zë×ÁˆÌ%“üWÐ覦ˆï*ðÕî³á%bÝi[ð­ëµ%¥}Èc,¥í¡ÔÝuþ£•‹É[¡´^ÕX…¹’$-˜Õ¿À<|ÿþý}}ͯ½•àÛáµÐpÌQݾpb¡7Òì90Îa馡…§j6¤5½X…ˈ³–?4@UØëÑ“Y; åå^D@ÞÛœånáTXÉñ:î–/òëFvÀ½ëŸ¼»4¿\¶Œu¸ê¤J^u¹»þvúF†º/S(PŒhò¹6×óðo·ßG^&ð‡³Ê+#LÇ,>'š’ýÒ©EsðÁï^—« ¾šËQ ÞBîŒLfu˜­”šµ\c¶ZXáúUõ°?ÚKŽ÷‹¯qÇ[Ȧ@Ô—ö"AÖ-ÅIã9:´œÎ„Õô–ÊÛ7|F[$ˆ¬Â%â"°i›P|ŸS£ÌO´kT’šó:XŽ¿¤FÁ'Âí/Õ(æÔ(ÚäéïD:Õ(Öš²4ÜàÖ(©Ò8Í~‚PÈ"¥×P¢¹@ŸÉ…Êl”s¸÷'ŽBŠ{ÀB]ÅA¥~ ßìÁ©yà’X@ÀûÅu”¨Ã’­ÙQ„+5ÊŽüWcG8%$3îàŽ£¼ÝŽR¯¯À®…þ“±Ù£@Õ²3ߣ¨[áüà ۣ$Ò«=?ÿ(˜Ô%,@ +,5HI2r®­|;RPzêýR(­Ea Å£4Æ£*2›ŠâS)­'` …i Tº ¤Ðp; Ã”*ªP,¤ðôø½útj%÷oa›¶­dΰsg›z´ÇÑcSŒBÚ b*ø¢áá»,1ƒÉºFÑßúp5¢û«×ü/)QȦn¸¤”¹ã_ ÉSŒ ç˦?4âšjÐÄíRZ0ZêpRk˜GËeÄ4˜jªé“‚½lÒDÔ‹ ‡Õp¦­¦,6PñCyŽ†YÓÖqˆiÎTEÙÿ7#[t°•ãÿ eºïѲvygôæhM1&8Àøô(ÓÁ ¡ì™±—™§Ôþ øç•ÌTŃÇ?°™“¨IE=Œb™éK!Á–”)Ú¢A¦½`Œ× ¸˜Šgt7@VfòÞDCÖ(dJ#¡I£M§©ïŠ¢€DÖˆ:’HJa´¥bâ +* +…A ñ˜:?5˜î|:–ahê Uƒw“' +‰½³åè}H/ÊJ¨ožS`©A :Š™úÛoÔ5ö!¿IÕ©PÂÎô ¤§ì5)3b×éÉÃZwoüÎT\TwæÈ[ 2ž©y*PÿñLqzà!iúeÆ9àe]FƒéóÇ)ÿ'^f=C c=åÇ[¸üýfuÑ ©›õÀ5S™Þ¹n 2'SàÐ"Èh=:L¹iö/3­¦z”»»Qƒîj¶€)‘/*­„Eð¥ôiiƒÂTƒ9ßS õ)DÊÀð1QwT¯ZSF©•ÛHÕŠ(€ +©VÎàóvˆÝð@ep9©Y†;!ήmiàN[0Šƒ@¤ª3þuP§j²í§ð¬íÿ0‘¤$_Å“„Ûû…´ÆôBþ=yË] +0p†«´U¸5i‚‚ +våŠÔ;Ôññh˜Üçâfô;Ë\©(í3݈^—ÂÎj(:|Y¡-žK)A³Ãjm³ûµYÖ6¼4 ¯‚Ö‚Ê=Óé¨g @oñR÷ÓÞ€iïÒÑ ÈÁ“1äG´Ò}˜Åäõ u¢`/5G¿@xëÍô¥d"; +^z³q†t•ÛçÒ!.Ž!™ñ¹ôÃ㥴²]8yé•Òr;Pf¶:MO§Gê«8ÍoS›.=‡’}ûYç ̇yd0Î(lhÚèR¿€É¤Kß+èÌy8Ôˆ@ö‚K‰F|Ÿ¿"ïüÎ]$ð³6(gV#ÌŒF à-õ¾ü/0X\¼ÚG1;n¼ÿð°–Ë`Oëhky>úf¹}<»:¸úª[ŒY›™Ã2§•ß8¤€ÀØ$-Q‡-Ï¡uÌ’LÎÞÈÁ¥ÏG#sË_€kiD­@ë‰à,E0FÅU䌞d1”$ ßñ¯e9Ÿ“ðLYÇW,¥ (à70-ç\à1–ÒÕù¦wá¯^©.¥ º%ìŸÇ•\³´Ò^:±³Ò=µF¿”Äþïm,մŹð¨và«V,O{":+–2Ôf,݈YœÖk ¥N° ³Ô÷©¯4ºå:e©káàä„X^is0á])êC¤®ÔìÕ5@³X{Ú•ºÚ¤à€)C.–ZkÛKXa@±GÉàK?ѯq%‰ìÝhXú•}õw_Ò;3X7–Ⱦv¼~±.B¥ ßìž²^Kµ/cÒ›¥6Z0Që'NÊHN:'9°ƒªôT¸®7KE +ÇZûû$Ãœësöš–¥F$ZKS–’’!/)+ßð!ª¡(KodÿÝÿ0 +EKçè¶WéiwMÜz Œ>–Ò‚0Þ`IŸ!-S‘Æ ú‰D†o ÓÚ¼)z~¹©Zz<Ÿ©¥. +`Û[ÖòÙéI½tLžë©¹,EP5ñX–zBÒP  ñÐ eàÑ=ª9ý˜÷ "ýQ;ÍdÁkŽÜäÃRn’·KÜò¨+%ͲW>b©Œ“UyôžÓg©u¥ÑÉ-VkbèJãø ˆ<ˆ1ß·ÉiꈕžØ团p¬´×cLû©5˜¼,0Á ôD¤¦Š§Ú§¢âT)¤Dˆ‡%âÝ€•V×±~i`¥"[”^X)¹&ßU ¯®2Ä—pÖ±X©ìŸ‡:4±ù=Šÿ•‹4§G)Áï +}جÐK`´P6Í°RÚKâÇ÷_‹”r %†¿Î÷‰jDù›ÚÅU +±í‡TâÅœ+åF“¨ÈÚjüžPCäE«_¢}F+ä+½'«ç3ë˜w)AÜô\‹…N2¢B<6Š˜+•hÄ Ü6 + —eÖˆ¤­Ûi•D«ɺmr™J8†­•ô:üx¾–Ò*^ù´*åY„¨»ú;ù'± ùî ü-&MT‹«û"x$Í$Ž@¡…oˆI«ûM1âa)¡×ü•K) Y¨”ºü«BV¥ï³nÎÅÎçuïrƒ)͹13ë,÷gŸKHÛ®¢jw”Ú9ù;œÒJõ![â®—Ò&JÚñ÷¯GŽÒnwîBj„Z›\]¾a&(н9Bñ»1.¥ •œÈƒgŸ£šïq¾ê´#çcJwZMs0¥i+ AyüŸ(F¥¡–’¡±b3¥‹\šÚ‡}&E— +L£Å -¾’šÒcld.-Ò”Zá§u•=’~Ó”‚G:›–u€äjrl£Rœ›¶jJáj½n¾‘ÒgC·ÐH©öÿe&A¤ôŽ$IÍQƒ‚àa°FsY«†9æ‰òÊéŸGCiS£o”BåŠ4 R¿—Õb…¨ ¥ƒÝ#¬Oz£S)œÿg>÷F=k®ùÿ"é„Aj-öH;uÒoårQœ@ç]aÚ¶¤°¥d[e²rŒÕ¥æ#² ™”1!‰Ìè ’"ªÀìãñ¿Ë$Ñ™S¤aGìYzMØR–QŒ†»Þ“ÌÀ§|,Áx3JâÉEÏ'ËœØ$Ì%Ûü‰Jæ& dDŒDCŒ´Qà; @uôãíÞg·!!ŽÎ ®îx)z1·ñX… ðÒÄ®=DøaF¢ë€1ŒGžaZ®*0ÑC–Š?”÷ ô¼x_b8Ö¢héßh8Ÿµß»Ø9ça›APi·ïP””×Á›(e<‡¢ûÅðJæ}XZíŠ#ú ¶r(¢˜$í-D#Áé{=x&¯k Ð-*ñP¡TС"ýì¼þ74¢pþj¨óoy¶Ïû½ñ‹8 EÇU¡Þ†7ÙY}>&zQ¡bÅã¦øÄ”µ*œfňzP‘ 4@f&z΃˜qPÁþÀ`ÈLïgñá…|]¬ +úÏÚ ‡w]Yjp·?qÍ:;Êž–1PTÉ¥Â*N;žhÈp&Çž{Š©Ræ&?Ár‚7†ã"»YüóW‡8Š¸–_ÿýD4Õ¼œ´íïΡw·fàÔyŸh9Å4û4: îåÔ6ŠšÍâ÷Hlb·®yí{™ñ©õ-W5WÅ*@ÅbZËB–jãj‘I«€¢9BNÏ;·Õ©DÝ_¡ÅÆñìzõܼtÌÆ€T¸ÙOž‘)XßÜ HÙl`òMa%Sþ<0EvH)ô\g;c€§ -ƒ¹»wvoÏøŸÝûÃz +³“»f¾-ÅÓ¸”V®j'e¶Z +f§Œé ¬ò¯ƒÒîJeH}aU¤ |°NÇ+ ˜¡—y;[ðt:ñ|ç§KóŸã‘¹®Æ!âò8„Ñ)T@⨱Afйì©­—s}j›3–)qΨœ–cÐ:4õÞOò¶^N9{=?ªœ<Ý`rj‡œx¯¢JuîÖ•Ù¤–qÎô$×]Ö¢8µLG¾@^å` kZ:Uu,S+†oüAÕ«ÞýrÛ®õ›*%fèIÌÆ7—ÒKƒÞt¿E$Uº*¤a›ùê¦proe¢H°¸N…¢¬`ŸebÈÛôlÉëÉ6ùô²À~BÊSw¤¥Iî¡M»¸ªa8aÕÛÀ68˜˜Í-Û‘IâªX&{  w®£…¶&7‰¼â0³„a™|Kͨ±5é +fæÆÖ l:1Iý/¶À‡FRIÜÔaÉÃjü‘Éê÷×Þ4•eÃWª‚Šn piÞß.x†Ñ™[´¯êª +b¢Ù¯D ö(‰LZþˆÚvC¤¥½@SŠ r[.Ÿ)ŠHÑTe˜B\åJ%£kIΙhofXÅ[êdrWÈlQàÅï ú·U¡f¦"e¦ß¹[ÈÔ>¹È\AW°üŽË¬À{5·Ÿ€wÂÑ+ó¤ó8[™2µÓ;™w4€Ç†r7GÉdúëpòՄ±™pÇùrœÚóoíˆcÞP ×p +Dó($!+vóòE +Æ‹3&bT³qB4¸F)œk¤†SÚ#1‹ãN8òë…ìFÄä;þUâF­Ý]="¶Û¹‰M)‡]ñ¬œ9­¤›¶ ‹—!_V5)6uk1 œÞš‰Mß#²óĦ¨jÕ’ÆA°±i~þΓmÚ‹W8÷%ö¸“§ lÓ±é#xئßUÄï`¿É6µ¸ç€+ÏŒ¬›¾§%Àì7ÅçrÕñÀ 0›OyC`[´o깸·ú¦T$(ôM3ÚÁéb“oZ½ÌoÚK³@ÓMÍ3# ™n*F5©º)iB‹„5Ñ ÛT | c›~>´ºé×ëØ9^¶i‰~bÃ6ÅåÆÝlS¹,%‚ŠôA‰Žm[,ß.†Õ“„z/î‹°¯ú>š=mflµ·ùè G9T¸6qÍîͨ1ýÌ0¢Vý/QvQã=9\ò]´ª&öIŸw¡1%Y<ô>Mo",«©‰÷Óø‡oÍ‘… ‘èÒÞPc*Áë;KiŽ#ïþ'’¾ÑËðuµCg/³KáÎ3›h‹#0¨qùëOðÆL'‘e¬Î´øÜøGÈx¡»¡Œ'ãUîPª”癌ÃM@äd¬,ê<÷Zá¥|?Êd¼†ƒãpÀùd\•àx<”(2UŒ1&cÖµJý1äÍd¹'㪠‘Â’Œ+ÔªÂÀç.bêŸÄXJûÚ$Æo­á†$Æ…¹:Ì9>ÿå~4 +jú¿o€+À€Ñwv“pf’”R¦”äÈAëºÇÔ~¢QfOÓËp ΉÈ)ÏK°â’˜‡%ÈKÔRÅîáÆvJC\­OŒL”ŽS]qçjµñq4±q|V¶ñ’ê•8NTò£½Å­SX(ó¹î¼&µCjMñ™úñ’Òö»$[¬R²– s·Çôâ…·‡tú½¨â¡}.rû¤Û'ºØNiõô؇Z¢<öµ#”ˆ]"¢Q5›6ºÛõʽô”âCœšTˆ%Zê>÷üÉ;µ·CäÛëçq’2^ªŸê§ˆªD”«8•º;i¼UúSu5©¸qViÞùËU´áyãQ¥ŸjUl‹ï%wõy3f€S+oŠùˆF]Zчùˆnæ¥ðsv”~„Ÿ¼í ±ŠæjÏ¡{’P¼}¾cZ!òD-ØäÄدÏç×8ý†¦RÂ÷P¦“š[‰Ã—Ò#’B†ßÙ+íêy1_ú.Wôô‰b4?æGYšùQF9ol¥ß22PΓióJtÈ •üD'A­OtX®ÝÓîÄ’mw¶´˜mñCl§í„‡öai5¹ÚͶ±ýÚÚ'Òëï¦ZZ{Ù¡úú†IËšò Oé I-åþ¿ù蔩É7–EM,rZkÇP¨&ÂÕ^1'ׂ_}Ž©€+6y|ÒOúõ{p"ãú‘Bäã +endstream endobj 19 0 obj <>stream +Wµá*R›Ô(Ÿm&ä#?ÈUA¥å '–dÙAŽ>åã‡[:h¸êÉ$’>ˆ½‡í«ôíeHÑÞCš3Ù‡ù>Ì— pÀ €,@0&‚.†Âò‹;ŸhãÕÊÈ#ÌWIˆ×t·:Öã€ß/ÛD¼„eðàp5{Ñ®Éؾ:˜‰K@–/Á^ 9hÁã!Þ% :hÌ~>: jG¦Vvd,ÈË;¼…œG*H§õNUÃ̘=™MC6hÏ´èõjú;–,ò}ãJ=5’ )%aš¡´µ!Sˆ\*”‘`«Å§&§ q¥¬ˆX 6´ š•P/.É„ÊӇݻƒÆ‡¦<ºu–““„r1C¶ª š=Yj‹ÐDE‹P—'êKXBT"WÕF逪7é[UI˜5õ ¨–¹«:“))!Ó|ä*IWþ B~Ú‚œìÓo"ªð—‰ŠŠbE«ª­j̲>&ÈXSÑMÌQòˆE4ѨÖUQ‘[ +ˆ&TDäT®·*ªyù¤‰£‰îé‹™©î˜ ŒÎ”JGÊRKQ*—pÁ>u +8{ö ^‘‚3úÌôC“iD°¦|Rø™šqÃI.âLÆ5‹†'š¯,™hh2å—hdÚ"–2$¡YŒÓ’pTá ÑF1D1¡‚bˆb¤,C|  ÍX¨CuE3šPã¤jÈP= à(ôøœêÌ.´ªs¬;|þy­xº:–±åtð>¾ ¢¸.F¬(SéTT•©ò'bÊKVE˜dF-Y䴒庘BN¦ˆKk9‘t¢<QÕ‰%Šdñj+·PI0¨*¯ñ)âü[A®LˆÂtÍOâOUÊd¹$xH~zë®·“ò¬ºË%§·¦s«ÍÔˆm^œÊ/õøL§¦)>¨Æfå«UTùs‘Òü$Feñæ1—·ñ©>¾I­#Óy}²s ß™Àó¢cªºT¯*\×a¿óÚ>mªóº~’ý_ +ôØ.e5»¯Ñ¯iE”æŒZö$¦=[R¹ˆ©q +ÒkÔˆû•©˜¼Ü±ci¿g/ÊxB=øÙËBòñƒXe<•‰ó|Ÿ«œÓ{µF(¯Èæ‰è+D÷ó^hõòÆò;B‚œDôñ¾#22(³5Þ* ƒDUŸ_¿Z^¢z4ÑÈ܆W“N]Ôú /âý‚ØÝcšÙt ÑqÓrœÁÅ"ݪ.!syEQâ±Çä®Jl]‘¬5Vö"»ŒÇG†œÖ¤˜ÍÌy¯ãŒ3Óˆ Ψ*DbhÕºP+²˜ çC®@vû ÿ™(™¾<ˆžšEÅ%婘íå©ÅÆÝݹ÷ u\‘]>.Öœ]#’Lj‚#2Œ•f>Q“¡T?–‡È„Ó•ŒÂ:IB.A—™~4MÑ©HYä¥N¹–ùô êÔI~ž:Bñ[NöÔTä´ïò†Ì£„¢!Xò‰ Ö‘‘1ý©&bÁîGd65r D¦U—k< +"Á8î«êhOEB‚H02ïËÕW ŽN©²ƒÑžF«ª÷Ê RÕØ— ‹©ÉÄÅ÷ò5ñ¥6 +gPpr3UvØx) ñÓ‹ Æb¦hÕ +抭DhJUI7ñS…T¼BJîªù%l…Ô]!œ?²Lc +QŠª0ì0 Æ áå•dP½H£¦Ðe1‰M´ Y†„H,4$¡™¥ˆ&%oK ……ˆB)×»ó‹+}•Q)Z¿Œuå³°ÌŒ¯éf|¬]ƒŸõïÞZ, }@ÖЈ†¼S;έH¬#²ÛŽ¢ñ=X΃%íSFѳšºÑž\ÅQ;ûZZY¼;WÉ$SyqºÏ­­BºÚþÞRJ9Ît¦ŸéKñ™ÎCÓÐãµv§;4ÝçR»ÉS ׯ*ËYÔ­yŠiä°Cœ"iOÃÑ¢æ&ìNü2½ »F)VN÷§…ªIÎW5I±æLÄšQÍRÞÕ%"3?FÆ.Ìñª·Y¬õ$,.Š­¡xNNO¡‹¦>´™øÂQ¹U™W(3ý„$’Ìxæá©°¿°|Fi}'"Ò)ÿØsR_&y‹+f(>§¼‘~5§^Ìòg’¨BL#MS MtÏ1g$»F +ý`Ijs¤.é‰i¬7ŽdË7*ÑØHÍ3Z<£û¤Æ?Z+QÝÛ’Ì.ÑQæQœ¦#ñR'Þé• gLŽh:Óª¡q)DX­8I?iÅkÚáv}è‘z©s¨|]fâŸÑtõÊ;Ê)ótÒ'Ó·Ë]Á臸~5CÖ–¬ñ”d«K&›\_ÕèM¥d8ªãEª©…PWµÌ”˜Ïm‰ uQ•Ue´ÌŒ›zZ¹J8²=¤s»ÓÎ-sÔ(·³¡KùvÈ©ÙÐeQNgíekö³#%Óˆ²”Ñ:äE|dã¾¢ü\‰ºÒ´Va_;¥ô¯Òxœ(Oë}ŸtˆÉKˆ¸5íÃÊÞv¯p¤­Åe/-•Ýn;¹2îäîåN‚\ IlŽ›-ñ „ÊèÌ“€1O'¹¨xŽ±!OrC›Ùð<¥¢hG‘#%¥÷ë¨NHó·X"ñ4|[#a’çN¢âW$ñ!ýË›ô‚Ÿ™üqpZ¥TÍÛ£"µ‹´µŠƒ£jTÅ)ÊŒ•/Ár±6+Kå`ËE©‘7Dn´ƒœÍòì‘ÃÙϦLÚ£¬ˆâƒ$Úï_º¯¡ûN?rK(Aû›±ÒøÝ»|Ãœ£ÄêÇœÄþvU©/“&dÒ¼mhˆ+êgLG‘LfúRÅGÚ†ô$$‘Ô4Q?f;r–H©u =$I É\lˆŸbã_¤©êïA¤ðå +—õ’ìù¼É 7<ˆÍO¹xœ"i›c‰cŽŽŽ?lÇ—1Jx|Žs•EéGè¾ÃY„и}‡¤•¦®ÌÜ°î"bÚs8›´}‡²4îKØIH‡Õ×°F'*ñDœ¢"^ŠÉ*úCX*Whš~V-’Ὃ¸Ÿ'"…¼(EäÅsè~e…• åc2²ý†,YU! +¢LMjË>gòŒ3K 7 +M`y–ÜçVª¼Ok*¯eG­¼SKrï†eèt²ªS¥‰“+ò`QjªÄM5õ^ÝQ€ºÙ ^V¡hi³”NÿZTUœÓrk&§ÑéÖi)>S¢9ñQ㧠+;^ˆ?MKuþÁX|ïT_õcêJT_uU–«ñª.¯„‹‹Çû*)RƧZãp㬠+͆ą*eÜUŸ/9•«DŒ»ZÒøªÞYšqû¤í^—B*¬wq'H7ˆ»¸Å]ܺ¸‹[CáƧC1¥:T jüóN šùeSšzÓ¨V\j¼ *÷qÉrwRY$Õh¦|´ç‰(½#|À¥¤÷UŽß¿,"ÿžJ +ߥƒz\y_þðË‘¹ÑÜ9´8ã·tÍsÄçP?&õ,N]Ê}îPýG:-¾E9_Ä‘žØ\ÈËY|«A¾'f}ƒtÅ:OÄ ùˆÏ“WÕy³Ö‰x)ñR¬Çoå/A-ú/ci³QŠ9z¡by^¬ +ºê=…ï)½l8»RQ)¿³£‹îIäÖ“X—¸2#ŠÇ´E)žÅY ’õä¨Õn=‰[O"ɲžÄõ$rˆ} ƒœ¾ÃáüöJxF‹é+dXJ‹NÍD‰Á©Îqþ”R;VYõŸ’ò(êÇ +¡ô#¤šúj¿!Ù ye¦„´k˜ÃKÌÔÍ#áp¡j™ÝyRÅfôÐ4èAí^ƒÎŠÖ?¼Ù3)哦Ã/š>„#g‰Ñ„‰„”>F‘z$®g{ +G Ô©Òì%è ×ì,Ž‰¨’ö ilb^,q½*/Ä òDÕÚ¤,ìùŠ ccbÈ¿Î$}Æ:yƒJ®´³ˆÆƸ ®Ñ6¹cáy¤=˜° 5D{ÑGÛp\¶óàؾ³;¼iã‘Sh",Û ×è¶HÌeáÈ¥]ó=NGk¹Wl—xeÍ—xü>(ò%H âŒ_b}Ø`ë>ëûfégæR •_G°brmµ É„úV±¨ñ»ñòrâ¹%büè8<)èŸõ1X*¿fd*(¿ˆú2•äÙOŒC¨r©ÕtO>Ò`ZÔEîÓ"&"F^Ò*MºšÉAìxÙLUßÆ1ßÈâ8³£X}͞ĜÒQ‰£Nè™7/X×ñÈÌBÓ>RäL(sNˆ¾ODìùÌV+\ ©YÍLòÖŒ¤fZ-5…¨QÔr×ì•XQ³·’ÍÞ8)[<Ÿ'S¤™uR¸ˆn‚¢3kNáÖ9W¥´æ!\ëßY ™™à›<ÁC $‡ñƒ¸Äâ/ˆûPWï@ÅÃPU…¹\Âü—0??†×^A~S‘$¼>ÿƒ:¡1->«E”&Fšýx9Hø1="Fþí˜.žCÄ>Ó…+e‚âÓl(æ›´¤M¶yr¾¡¦að™ñ4•Ã*‘‚Œƒ §¨d‚L ÀÈ¥XahòšFA*QƒÛƒH^ÛFdh–¢Eããë2w+P‡VóÏË:9(‚$çý‘% +® I'ì +çT?¼6 +y}c3ä¦ IPÔD¯§!'ǃ|E^‰C^4ê¿¡Þ.zFÉSGvꔣÌDÓ(öǘ±†¬¡¬çL𲘗täÑcu¤Ò¬;åÇ°«QµP¤AÆUƒ‚Ú%*B|SÔíW(lÉعiä¼H¾&8Q¬\¹Ô,!§ƒ·„¢yN¿0$D΢‰H7ò•VÄ! +G¤+’nô£äp²f6Cr¹jÙ)ù +µBh¹Ð¦52Ëç“öÙó£)™ gAºÍ]53`D'š ê…OP˜™03³À©ÄPƒ¢\!þ–ôØÔû9ÇQ$h붢ÑÃ5­‰È¶Z˜uã¸>¡§om07QóGmÙË]j‹\B>}Ö™¹Ä,´£j]èP± –mòJ%«¹»˜šMÔ|*†{Ô|Ã1X^ÍDÔ^¦k‚¹ƒ´«¶î9m,}já/ù$V"éF5í£Z ½†[:…+1ºt¥GÍ¥Sb.}kMßb“‡ˆ½>CÓ„OLGá>4§}‚?ÿCº w‡žÓAîŠ2D¬4ñ€à¼ÕÒ2aºcžÌ«£§ä‹6Ri©­*µ6ä%¢LÍCœ'!~i¢q%Š(„E×=ã„„š!ÿ 2Š«Œ«&ÄèrhÐîÐ\:Á“_—ò¬ +†§ÛpÜLÂn÷r’ofr"òP:¬su’Qä¥Ý, EO}*ŸC‹Ÿk¬©ý«|R#w1‘oUnå®™àzŒÌ9iyÍH Y5ÿ_+Ÿ cS¾"»^ä4å`ê3KXMNS +ñ™¤Â§Hcc#!A3­8ÂgLˆT!ľDñÅ3ÆITGüz-ÈH‰ÚEĹ!(?fÝ¥VšÚ§*ÃGÖŸ‰ƒÉ®ô CÊÈDWÝ£iíŠ3vŒr1â¤!]bAÞ= ¼ú0EWjBÍý¡Pã™P#c ¡Mø”2'ZQ ¡RDØœEœªÐùC'x¦f\"¤Ñi>l›°«“ i}Â;º"ÞuŒZD¶ªW™DlÎ ŠçÌVáì²áåÃÑ”’E ,¦fžGY.7½ÀJÒða6»ð¡*£÷Àzà&#" 5¬a% ë0 C†a†a¸0 E ² ]ž@m J N 2çn˜!†ˆ•è½#ÔCãQÈa(‘h‚,2F¹¨¹¤õe’*ãºÝ³bÄEÛÇiѱæôˆÔ5E4á:2ÝŒEqOWä—bjºN´´‰J—™‘VÌÄ?ÏAç|ÄQÂÖññK'êq’g#ݾ gºôB#³Ön¶¤—œpjv^ko[ ñÌ{ĈÈL+MßN ZK¢u§õ;­È¬ù>þž¯ÖÑjfÏ5²AÒDN´DØ“+–h­‡î‡RÒÄ5ˆ´žÃµ5kƒVagøm5‰œ‹A$åìØÎ¥C©(µ¢†T´Ä +™èŒ õkþŒâYÃT®Ñ[XEîlIð4²d¢¯Nu¢QÙOÈÎ{RÛ¥Hs!$b´wñÞ|eŒP‚e£1|QFA'+8ÑÑYÄ»HVqŠH$Ê9'Íþ2Œ³\”+­µ`³µÞ<^Ç£Ö»´yçJ+ëe‹’ƒ^ƒX5¯"ÊÁóíX "ÍŸS¥ôA!‘ÄE…¤·pªñ¨©wçb ‰HQ¤Â#¿ˆg¼±žqÄáHÙ5’C­ŠG„*k}•Oñ´¹‘ì÷$=™S¥D…ïRduY¾¤ îÏ3Q)ãxÑü!úÝjŽ²\óâÆp˜²U¥B8 NwËX‚"¥:È(RqXJ*bpè1d$=*‚[”ã8rÑ4DéÃQ šš™ñ8§™ÍæñY§vT,K°¿cì7ó]ìÈ>OS?¡žDtþ×ñÉ6/! +!넘 [êêÓÒ#¶Ñç­Ás\1 ÚŒNT÷‘5›°ÐªJVÕ©¢c!ï±äYmjÄØD½‰lŸªLu7²9+Û¸ª¬&ÕÈ¢ÕK62§ømJîYg"C!©˜U³’ K¥µsãVj¯Õ¿O¼©Ñ9C1¦ÍHb-h  ÉëŽïh¸Y·}ä C‚èÓ«BIEh|I0a"™é„NLjVõ·"‘bh,ø†¤^vO\«å +-©ªàz¯@š˜â?DSÊÔJà„å..«p¹ÐD i!) !ÁöDf:’‘Ù¬ðHX<Ïbxœ•VŠ¬B£Ê„™Zrù› £Â³&üó> #îx(Ë¡`$Æ"MR1¦HjG>oG™ºZ«"±d ”qݶh`62ú`‚dÒ^¡}‹ìÇø‡í,E Gu¨d7¯^ |¨Îm£¢×[x?T¡Cr¤‰Î¢jß@h7´~9d_h¦ jpfÞÊ[ÝpÐ÷ø +o‚åÌÉOg"LZ5ÞWà‘H£á×¢íÎ@ €À%Q&Æþ4©Ò…¨`…ÃvÛíø®?`ëiƔ̀­µŠøJïáRêK²€ *>€J웵¿%—1,ïkÏÌãçq²P=À-ÒÆãüh¨JóEGÐÆ ûásUt4=¡é¨;´Ê@Ð’K­ÖÐÁ‘`]Pè±’¼Ž¾`s#Hqºü¸àÔ et¬;’–¹‘”Ýtg °EvNãë×é¸L5¦sw$’Ím[Õ~P’s:;š2åŽs Úxû ^$ ÕÀÞÐ=W5d„ž:è4ónX¬\žZJH*Êî€èŽèir~ƒ¦¤6-‡ÃD%»š–?¬X¨Í¤OkÆõ˜vt®N…ûQý4Óà?ò+TUw€¥/íª² +]Ý×¾¨|äM¢ö*“úšÒÀ´»Ö5m*º<ÆCª~8w€ófôA‡¦€ˆ^á†T²é ï6ü7-Êp?|Ý­ÜÇ+mF¯¡\#éGlúåÝÓ¶ò«_“’RŸ¬; *w•¨}»‹S¦; ˜?e{ +Õ¼ñPéèùôŠýáTg±r2G‹w|cPx…Í\J×J)b•ës'®ÛŸùÓµ‹¼õ9ÀÞ…€n2)¾yy‹©€s± c‡‡ <3eÚ¡M¹Àå0 ^±«q•,²¦já~êÎáÊ ‡O €“çÛÎrDÃäq¬¸Ñi9ÀxãIÊ[M8v©íúü?¿aôÄrÀü.¤QçCqõr@ :`åFìÇúóÈ6·©QÚp€uA±ÁÀßU;£¿Ò"e÷ ø€ +(t zÔnÀË×úÏ Y¼ÜøÖì9§ô¸î1)¢‰{"”Æ<+KRQ¥Ac€eFH¢¤at<Ït=1€WnTg 0»¦û¶,Û4zÏTƀМ§Õ¢™•­v Li à·0¦Â^02K0Jc@4Ù éo3Œ€¢iÊ|ŒùS4Ýý†÷Gz²©x <áÀÑpÂbm…Jü`ØSG~XÄaK¶øúdÞð“èØ ->Bæ(K¢xè‹XÁ •ˆ”1ŸŸ¥ïűšJ£^¶é<[˜¡<œ ­€¤K¦U2ä’bÖÉ  —t˜éÿ€˜hH3× 9KSè~Yí)ScଵÝfòRPHk›jt\²?È ?P°! <ËH»$´raǼù!uÕíº®1[ÂSçePLÚDxðöŒ÷ñÿ(g·šä^8·:Tq(Y 6î‰DNE ðuÚ¼£óWY·Ì¾,”Ët¡›Œ0¿NmVBÎ[æÛô·­ i +Ðê_¶~«/ˆKÄEÈÆÃ.ñN,Ù :„ϵ äâ*&ÀÙä´Q­n<íPDüŽ Hy¶‡zñ˜E€âi(Y畘ž|nP.¶À”‚JÀ‡´ˆ@è‹i†[ýpIôþ"CaOèpIßÖDª=ì€R¸'µß˜Ž…Fme[#C‰ 4¥CØ&•5€ ¨Hº“c•ocLΊ/€d oAx X´Àzúý=\%B¬p`‘fVl¬|^1d>& EˆìP¹£uÙ÷Ñ÷æ_Lúy|û<¡§ül(w¨ÁÓkA +o.=Ñ@¿¶îyeîöp—„£ö`‹{¥u†´ž8‡H àœ¶b:þ^P¯bj^ºI•>ÀÄHbä géLÒÎÕð +VÕP_€ŠŸ.tLÎ2FðøfÉ2?˜ÌÁ<ôÀÔþ{ÙSl\Ͷt,§e} ‚)[¸˜z €›.4`òè”QØÑ!þbÁ6}d5ÎÅë +úBŠé&A¬ƒJL€¿Ù ôŠE¸Ñ:2Úÿ¿ïàîÿ‡e‹ i”f\`÷/{Ëí•PbpÊÿq‘ÿc=[× UÖ{.·ÿeF8Ï0«ºô¿HjÊñ`J@‚’ÿ›Ì¼©l½J𿹷òË°ö/·ÿ—ÕÃîV§ü‹9ú?A0§£:­Þï­ôÿ=A ˜b‚fûËù¯šFØ–(ÿ¹1»ízñßÓJí ˜‘¦: O(Q»#p‚ÿ2JÇ c"“U.>êï¹ ü=­šÏk\/Ý÷fö îøŸüɦt_j\à¿dj^;îæPIðÿÊ¿?ø¯"ÇúU4øO=6À¢CÙë ÿ!+ZyipÊ¡oÏf³¦Ô‡È»üos)وɪJÇü¯%¦Ë ÙÝVð?T©vò9%ƒÿç…jñ“ë¿…»Õ×Mmš¯nün>õ;çÿû„üÇ‚ä)²¤ÿ½“ž|çS ` 7Fðáïéýo=áÌxÂè$©°O þ‹™ ýy.¶í-"$rMÔãÃÛ*hKðÁ†ªuCqQÛ¢|mö,Ó;´Èþ+Üç̲ؤþ× ¬˜Æ=oHæ§È8³ ‡}¹@ÍŸ¦¯Pg|øEc é¸ªЩ”ð¨HÓzâøa!7`:«QEš_bˆðÿÚG´¨B <šãxšˆ:´ðßíx âb&8…ÿ¤³;ÁÈõ,ñ„ÿÙ_Ò†´*Kõt÷Ì C}Ž0Ç@{22t- ¡Q~Â95–OðRìà? ¾ö!Â+µD1÷À¦1*an¢2øï%Gp wê™](øo _ÿþ`qc¦s³Ú¤];ìàØURà2ÊÚ¥º¬Ï ×ôð°ÿ#‹Í‘]>Ë2 Á\à“%¢šÅcüŸ”#r6ÌÄí“^×Ñ.øÏ( Þ ótëóÒ¾~È:ö¬­s'CTºd *çÖbðÜ?gžþð ú<ûOHÕjw " {ý‹ôû›ãÀUýpƒÕm4„†×˜þ»Y1ð`Á'&4ýÔ†#ñ:ä§óhõ¸ ~"Räcþe¡¬ +Ê?Ý•¹ÎÓ„K\ÿÙ¨$ŒÌYü‡rPJ<£Q÷€Ü†ÿÞ|{c“Fðï! wûªncpEÀ˜õE3uÿðó”°jz0®êþTnâçņªáþã­mœ‹˜É£^7LØþþ«Á²eùÿÙ_.ÚÉõ5ocÛNí,?Ä¢fÈÞC»¯Ù@¬l×T0¦“¬ˆVŠ¿Š@ê£&X~¯ +ÑÎ +cýµÝ‹`%n¬?[l–àÓ±þ½°œÅŽÆZ6gôØDaá’ ÖŸGÆ-²qÏŒõGeHF?Ùy¸z²þoíàv×ë"ÐtP?µbLH°›™Dɬÿük8Ž.PõYθŽQ·É.•:$§wTÖ?¿£‹ +v¯")ûêÿ>½*O¥ÿ ‘œWIÿ`Òl8=…_ý—œ?ysõÇ ~·í`o{©]ý=H¨^?ˆïê?)ÅY¤Vޯʄþìïxˆ,c™WQÃbƒ $Eß@ø¯Wÿð½o¨%_ýÛYöyµ°þ‰U& -2@Ö?:_óFGdf ëߧ¿¿Â…y¨[ØZaÄïÖoØ:$’vùþÇA¬zÁKà +û[Ën#‚¢°[†ê¶ñ ²æLÏõKS%yÊ×7¬¿y¼ 1´P!ˆï6‚ÒÀ¬¿³¸Þº¤*ïþÅõt,xÖß—IBíS“1Õ©¬Ìú—o‚ˆÐKý1T÷£±þ͉#ŸHÄú‡aØI2l¶[GÞsN wghPÅú§ +¿Ÿ­ÁÆÏ 1Ê€ÞZHóêöœàJèq*‘$ø‚²þJoh<Øönµgý7R©õYÿ{–múž@Pï¦_xÕn&$þ¬‰„ÃË-®fý%÷¸ÿÃ"œõAž'¼î–1ëßAØÑ$bý«ôd’X#RÌ"a7¦ \’ Åúä» Ò¿Ñ6YÊ7Ž¡Qyr¿@èΧÌú§ö*xÆE8ëؤã’ã >4)ëû \H*_›:^Àh4Ô „‚kM:¦?VÃ-KBœƒ ’ý=Ëœ¿æèÿºËõéZÐ-X¢ù² ý¯i1¿3Ʊ!Qä¿>u.¡½VHµøÛbî~ô§x{•ŽeÛÐ/àÆì1òkL×›@S® d/è߈ó¸ÏK²ùîýû~ûâ­}Tó1¦lL£r†ü$¯@$XÏqí„ô;ú/w¸-®ž|D½·¾~©Á‹—Ì……P“2y9Ž«º§~ÈöI÷V·@K²²¡ÿ'€#»xc蟎‹_x:rkU•¡RÙ‡þñòÅ;ɺÑßly¢¿³fÊmz@††`µ£ûr¤Üw´¹ŠT–%ĉþ^{Bél È´Æ‘-? Öê0$?fÄIjUpKÜ|h#Ä¿A~ˆ â <+A €xƒ/Ëồw!wBüÁ‡í£©ôö»¥âÏ`] û<@·Y´^I5ñ÷à1ë…T:Ý„WW¹(’ +ŽŽ⟀=‚†üã öÖ8òÃß­ˆŽ~# ã6¨a Å4wkë2xøs"èÀ€CÑœr6b$åbùP«"êŒ?ŽN}Úñ‘¿‚ÊøƒG (Ý‘*ÅßÏi‚WìãW&¤4ߟâ­ÿ¶*W‚õþ@œ}›âÒÒÒñ59 +ï÷:Zs)Üc÷÷ŸwæˆîWZ %†Ô‹¦±Ö¸‘û©'tÇ–hòç·?ñ-ˆ\ño‰ð}ÛïAGžW31víç>Ée•ñÜ¥ý ÎrŠ³q›#2â›ýŸ Ù¯å#Ú+T†Œì§¢j7Û­œN@ì‡-Ír¸¼üõ§ÆØ£wuŽþV $»™‘ÿ9ÎO¼ŒÍƒü’Í÷X](G=þ˜£ëPÛk—ã7>U“ãVÅ%O²—ÀV‘ÆÏ›`µþ²©·¿sœÊ—EüŠªe4Q¿›>‡_¸ò¢A¥wæ|þ~¯Øä^F§/W ©™‡m|¤ð¦8(H„ŸEñFj©7µ·¢EÑÌà‡0•ey~å¤Û L¡c~ÀoÀŸO‚ø>âVz°4qÿ¾õØôû})ÉëûØÎKñýCf}8¡ôûÝN9£”aýŸi´¥a–ÜýÒgÆûñËOôî‡OôÛÇí~°Q5OÑ^Xk1¹˜g•ç¬o˜ ÷O‡šüáÆîÓæà—ÜïöÈZܯ÷jÇÖ +B¸!ub­€†‰Hÿö›ä À~~ÿF¬í«~ZÇï‡{DÊØÚ×*ìJÚäÔ~ÿÞ³!Ý v¥}1Îè6“be1¤!h¿k­çt.UŒ³¿­ŒÌÊ’—}UreYp$N³$zb“ýÂ8q¿´û;câ[ EPìG* K÷7S8}\Ø÷ÔúñO¨¸÷lŒèا)ÌÐ*£›4;ÄBxýÔ `6ѧ¿??ðÌ ÇjP™¶„0ßp"hëÜÖ£9Q±Ô÷JJ+ÖOµ·‘Ñ«o­õxV}Í¥ZýrI³Íí¢Eï–*kÙ é}ÐÂÌ”›‹‡…û0LÀA"¥M¡D\5õ5šx_)6F5»KöÄ‘ú±Z %4ÐÄw%ˆú¼óVó²‚_1Ö„G)TÖ¢„¨¯@×[ù0÷GQ$楚±XbÇØ}ßz’xéÙü-ªW>Ñw~Ÿ†mtTÒʱ<ô7¬é4ñTèÓþw ºQƒAz𤠠G=¡Ð…Ï„ ìcý|Š¤yøúé1Ú¡´6šqPŸ!CŒžQG®÷¡CûW¹óïOÑœj7¤JZë|½„Õ>ív9ÊewÎï-w¿ðEÒ(Î/‚ö¾5¾ouÛ¼ù¢Ü®¥h  eí?œ©¡›ç^‰7뢿åBBó‡Ûó÷W2óçå“m‡ÜÞ^È|hËËœ²í%%æ„jž!Ï/ßÜU†û2øsNÔå eõ‘”\¶üÈW›ªßƒË׊G:–n,F+_õ`"–ÊÄ6ÆÖ"h%åSº¬Ð~—'ÆsÊ/ØñRyQ¸ã7ù ÏÚªêÿ/Ë%ŸgÂb(Š• êrJòÝ£ kùilŒŽUy"¿~—£"üÆýa:œB>5šÖ ÈGÌã®ÿ<ê'T` Ãv¼þ{¶fA½ã£œúAጜJÛßaÊùiJ3Õø±vz.ýØòf:”?eĉ—½8w3ŒßSÉÈ|YT)ÀÞ¤éÆv‹Å—}í]Cÿ,Qñ…t⚺d÷îOü+¨—DP^˜Êˆ%¾9üÜlóZ5âG`šû蜿-&Ä÷*ý"Ug¨Ÿ{øM®Wè9|€'ø$# ÷ߘ~DO~çúöçO>Jè –ƒáÏO?@ûŽœ,øàS?5áÃl_%•_r‡,ZjŸ:A¸ƒ?éwÔ@éJ½mï*9‘Zä.S»©õïÁߦƜ¶Òxo†ˆ¶Áã¿Ãr† ÈI@ ®}¾}E‰ÂŸļ¢¸Î¯VÕmn4£0ž¿§œ†Ëû-ºú=¢Ý¥x«#~ÏÀ¿g®Ú÷É+T«®Ì&úÛRÜ‘á1Zäûuªs¿÷½’ï ”7¶÷9?Í`žr”3{>ò´Ê©¦ª²?éý„3>É®#Ææ½9¹¹œ,SÂJò¾ 2ã'x9ïÑ‹ÄcÜA2݆Ÿd¸m~x÷nkjŠ‚¶vo÷ÿXÁŽq!Axórßa)ˆ@u—î9e³¬ì~{¨ó.ƺ¾& *:Ôù(ËÁ!\‚ÌªHÌO-vŸÝËýí‘"•t@»ÚܼŠ®÷ E2˼iéD¡Ý×n¨/cpkNu34°3âð´q‚ŽÖç¾LÅüÜ;·‡ž¶4ÊÏýš´ý§«¾x¬}Ùôe3Òõçþ uhƒTѾ> [²Ùˆùôs?:Í÷s/Õ´©¤*ô½î ხÿY(J‘͸i¼¥û¹M + ?÷"šŽ ÿûtßÌ:¿ZiùÀ¥[ÐýÍÈnæûÁî“lAà‚&Ç!ó›{œ•I—û«³8¥´ä¾MsXJåù·(žš« (1¿›*ïGBñë‡))5*Áii6Iæ1ìwÖ%£b>¥5F¦Ë ŒËr˜Ç…JÞÖÎ3,æ)¥óÛ )1Ê%Z NÑ;ˆ)žºh.Ë«J†Õm×*5ƒÔоJÒÁÕØ÷X‰ÃʓƯëèµÐ!´ò0Û¥>âÀbË~ 5M¼ ‘ Wý¬Ô˜2Ç·ò¤Gòm*R¼ƒë®ü˳h +L¾aƒ Žò—§½Èd¸RŒ‰ó|×3ryv8¯Tà9r°¤®D¨)Vh†Žqž1L- ’J0ÎŒw†_ù°šÔV ¿¦Ù®R_ÁÚšUrÙÉï–xôÆy¯à<²7î¥Õ‘ÿPW+uoó^VD‡Òü¡µîý*T›‡‘íþ +œµ¾á¡®6ϬèéÚÉËÐ –f+“–;}’6OK t¹7î\âm¹Ý™" +Øyöyu–fÚ®OÃáËJq¸¼’„»¨«ëÓvú y¥Cb ¨ +ˆh*ÌyÞÂè<úÎ D|Z"Èï½rמªbq¿‡òêÏ€Œ5ϳòò?4¯5^€k´©9»RVÍÇU0õ3ýg>y~Hâ—k̹ÇFPyúפþ”‚̲³†¼VªŸð·Jºüœÿž|U6;O¹låÚ뜘§e–-µzŒRÚy„`æí+l˜b”¨PAi_Éží,Y™†á¯þ—‹}¥9¯5€à\¬mž¿Å¡áò”D*È›!-wA[ûÉ —x*xpó¬âqãÀƒÚ<Ù³uÉÖjxâΤ«Úðiž™ÓÎ&’¤j%Œ‡2mã«eG&ÜWrY6§†:ωÇc•,D®ÜHSáE~è ³×:6÷Ðãt!µ†%÷s ƒKÛƒÿA[ƒ%ó«£Û%zhÁ¬t£“߀ñ­etâY÷Az ƒ%C9Љˆ ç$ 'dý¿ ËÁ’î¤a(ÐúR(Ü ýεVƒƒ¥ÕÇÛ¿­8W +õõôI<ÄÍ3½ÁU¦ ÅÒQÑR…µ¼5äÑâMº„è ‰©åìJë'Ìø3æÁªuõ3ꉈYK‘‡Ž Ÿ¢–Žöäxv=WàOÎöãõê|çRmÃ錩K ;ÿXô·(fÕeÁsÄ’kR„<‹R '©5¯h§DãYˆëñ:ÊqE"ðC°°C3Ôíÿ-»v¥x™SÀ"o½âZ\zQÄ—÷-A£sU*œýÛeµšŠ:h#I‘Œ[I"2$^¸t€ŽT GT#ªÖÒØp+i´ü’߈s ¿ûxl[Ó–¬ºÓ—'!h­™¾ü@®¤(•!5¤( ar%´Zó RñäJ1núò?WºðèúmRŽq\é îy›&JTÜ ÍcçÁhÛP.¢yn×’¨{º$8óôŸ¥*óÄ?©+QpÖ†N‡»óÌ#€1æÿÔ>žyT)‚R{†3ϘºëX®„›p{ydLf/B•Œ†ZRþÌ|}¬¥é-`bæÑù7ýNf¼ˆ t%÷ +ðÁL' s¦Ûv¿y¨õj3Yx¼LرÔà• W`KVH¿yÂ0SÞf^IiuŠ6„rèRP$7ÀÙ `3[ÁºÙÍs\äÒó• núoaˆáyì`@ \¬’ÆX”çIt$y󞯚 Öä`-P"à•ŽÐ-\ÑÖPúd„ó¼Š%¹I‘np &c©«Pγyÿt“üÔÃyV˜šÞHd$œ'Ïë㢾Â@:±e Ô›ž§ösp=åœ*LDËÀ’–ÊÁÜæÙäI }%¥–m§È"@ãæd—É=ýuRfBCqv¿IM€}¥bê4 |ã_v_‰@^B°}dÐDZŠWwaƒƒÍòîÚw•æ‚@Ö6Àzæ„‘#£ŒºNÏ1Ÿ„±ùaWY…ž%Â\î4 –t-VЫBÔTƒJ®Ìü<‚©XÓo?Oüàð1Yúy’Í‚†žX6Y ࢢûl9Ó! ³ˆazVÙCáõÀë:t úµ€ϧžDÂÕ°«É´åz.71„=vN5†i‰Ÿ {q~«–$Ü$ô箥"xÍ=z²Ü»§™Ô=¼[Ú<úHÖ¾'TB Xp ÑÿZ…O5‚Ï&>%ãøw&§\XÔM]—O€~römKõ—š}8ãÑh’‘ôé”a€"õ±¸‘½õ!»tÍ”ð¥m˜ìlBÖؼz0õ¥²$Úü7»Ï¶m´ö)£(,cög| ¨Ì‘>Ä]¢& 0©¯#ä'‚òô“"ÿñóoPF~‰ 4-¿óÃY%æ®|zÎW2g1çG§Zoúadýdfåu?pá•þ´güIöhT Æ”j‹2»ñó½xûû³§!›ZÌi°m#þ“]Óƒò°ª¶p€)qÿ-›2…¬á\äi„dy€0s>¯N@ÆÜ +K×òts@~ÍûWû2˜æø2•I‹Õ™@!w³«¬@\3"œ0´ûñ”…çæeÒ?Ž2õØ<.($å¯ê™”›uÊ}µcwòµÓ‚Ù¼‚jdß|žºâà&Uô`™tdݦ Ðâ„#á\\°ßiž¤ MaÃ|Ôú©+T†±ÖýÎÒ ­Y6ÄíÆp‘Añ¿–Ö |¹¦c¦„D æ?è !”›ÐFjlúÌöÐÎÏtþÃ>ßØ$BuÂfÙt$·þNb¨¦Ud¸ µ ,µÜ•)¶)hݦÃZ›w‘B1úñFè•à/ÞU7ýi9~<Ç­Xˆ°)ò4ö@²ÓÇÆdëâ=î÷&×Hµ@°ëÅoâ;fœ&Ù1§é.y¼0CÒ}`Rl8sèCâ”ÞjIµ8>çS{CñRæ×›¬}>y7e¤ó¬í‡Ö@NkRù…a ë •)›£<Óv\O±w㤴V8Ë'§þ´feŠÂT‰SFg ÏFÃ`WCØwP*ÏOÐ'r¤>?2ó·HÒ‡NÔ¼4>$@œ™N‡i’¨€/òe†’åÐÌYó,É™ŽñßÄäííj¶°¨¹¬òák¬D&C¥¾¯­„î¿i@ô…£yâœ.Åè0ôØ›4WR7†ô¹¿DŽ0ý7MîdUTž ¦éÇ0”¿à¡Ç +f(õÌÌR( ÃÝÚ¢Ò¤†“ÙŠ¬ Åï{ûÁöþ¢!i€Y ýÇ–´¡(—zCLëðÖ‘y‘Þàêt´b°qŠ‹ÜE?0l‚ŽÓ ÖÞ[ á8!X*fqœd¿Z@Þú )ZtéçE*Å•ù 馿wURç7´¡‡ºP¦¤ýƉ Y˜hc³¿¡Óˆ)WG>IêÆÉòHM-ï¯ðñ€á¼LÚA7a9AÁ %-ŸÇ÷#ç ɺÝï„ìŠoÈm›üÊ#Ÿò ‰»|Ku7NÌÌ‚3J§®üŒU õª2éâ$Ó¸ô|67]œÈ²V€ÌÝí`m("VÏ:Ûh¬ ±Äþ{\ʉ†¡Ì¾2v§¤<¢É]Kк8QV¦œ~Õ†üËPƒÞö»´!%j5Û¸²ò@NãÄ™÷ŽâkÐ**Øÿé¿B~h¤âÔîÛc4žC·¹ÎsˆßÏ!.ÑW ~”µrbå ’©»,*moŽtrĺìÓ"Snö4‡º?Ønðåk…p¶[B]:°æò«åJ" B[ ~Ôc˜Cÿ·ÿmË™~1{' †ïÐ*†ŠÌV7„9wJeJ\Næ’èMr*(LL?»Ñ™$†9O:0'xh3sȤ?ÅywÒÙöYÎŽ9ІÿxÀ|IN1º'ªÏ$§†5TsyRh~ܤu9$‘·q·0µæMlë ‰晊B¥¬øöÔÐxA$Ã9¾îj0õYÔ62 +rºŽ£Ü|ø +äôº2ß3^YjÙàc9qðÆÊ‘†C¨¸©|ã ?“Þu2I+StÍ ô9=O"ž¦™æ¼} '¬åå +AjòW“ì6«©°£¸«sN$(ð:™¾ª‡CuÐ7\@9ô÷j8’¹ìÐ'½ñÂ¥sÉ æ–T‡<ƒÓ;k¬?›g.§Í½F|önšÇåtH›ãÞ‰åó +^>560ì½XI1½¥•ËI+» ÆíZ¸ o“²„Ô5_Ýv(]—µ¼_xe$Û¡©K.ü(¸Câ×Û!Wò‡PÍÓ~Ðâ {×¢ rN"ùJ{Z«]úÑPAì@ʨ=n´¸d’HÌ Ÿ©ËNzŽô\ÿrOíP5NýC¦†\Ù¡cè‹&,bÔIY uðNÕÔË‚i«Zåucô±CLmtò<ûTÆgÍåä‘ŠÈJÝJãÛ:ük©mì5Q‘§×'†ÿÕX]*ˆž®g…‰ýU[ÓÉ™“Ò€´™ +¢Ë¯›a T ²¢Nñšè$ïÓÊ»möQ'EŸQAÜ¢Nü²ÊÎ ‘n×j½ø&¬"ž¿ˆbµ•‡8Ð"åí@/4 =UŸ(Òi ”‡q€ž :ªæŒ_ÁïÑéÚ;=qWb2tò‚19=7; ͡¹¬ðz]¢W(:a@ÖT|aû4|U-9ŸwztêQˆ¿áºcâÑɼÎd¿Æ¶pcN;Zê^g-'@åøØ‚²£påDŽ-ª‚ôUmCÂaÈnò“Î9V‘³9º@+>É«“ªÕЫѡ´îl~¤CK:ëÀócÚÓ!‹fO«K§CíWHòtéoPžªCŽ“‹Ø›nŒçÄÅÞߨçÖï]ýaƶv—*íÎI-t1 ÷Šþ™¾;§ ˆËT¡õÜR‰[uuˆåF³ú¿:$î|.#ŽŽØT©CMÊJJ[²¦Ñ +F"JÑÅŸ¨C GÃ=§ zÑuèÊV…’˜®[Ì¢Õ‚ªµ‰P_™:T:P“{âÀÛ€:Äû¯Â2<ÓØœ60\rá ±9a +?ï6ožÀ¨Cššó½1¢kN +SõMÕ™MÔ!Ⱦ·8e¢Égu(/ȶ³"Ú"U:}ÀîÈÏæ!Â‡Æ #¡q’ªaöç” ©íÚ¥^|Ò v ƒÿæÝ +=´Sâ ÷­±N"<Æ°)ô~oæÁØm†íÏÉä2¡XÎìÓÚFY”Ö!:¥dîmN·Xa äßZ¶9éIzk`mNVÓ>•è*s°ä³9( ·-,Dråz…fKä!àN®9ýØ*½íqhùYsªaTäò´¬9Y@êg_ùÿgs¨Î;£M¡à‚S^Í]’¶€\Ì!­øðH½jïb¹QyåIbjæÐáãQ0š"Äè⋵™Cp… Þ,,Õ!UÅ'ÚÍ…ˆºy<†Ï±ÓB !]?I¦QwN[¦l¢dHµ ÿ@vÈÂŽêÂd'aç´îº&ùšÄ×!ÏáÝEìœB¶ñC÷ìœÆ¼8vès'Y‡þœPªj™¬Cöc*>y©å:‘~IX‡Î[TºU Ö¡w"*Ý¿Ca$±+\¤øÌ/˜)Sà¾C‰x¢$™§â&öç$N­>aúö`èÔ ++ñ5Ñ)¯[k¯ý<ôãÈÛ¡~¶5¦X@'÷éã÷’ÔeðÐ1/¼šÝzâ €NÓ»âƒPö_sÓføÓ~4/ðP$J)ç+¾’á—Æl:ñ&Ž#…ŒÉ:ˆ”ôýã…ˆÆAíJêm),ÈŒÈvD}#£Ô—;½Þ©—6‰¢’ÌÖ¸wÚP€1¿D¦h¦‰êåDÄjŸH(Uâ +Er«¯%ûs<ÒºyZäæÌÈ‹-˜ÂTÑ×<3YYQÆ`ø®(£”ø» ÆU¦m•²Šbä(ÃÔô{ +ø/Ù{d‰wá >u/::ðëe>Íh‚~ÜÁ&8å®>õN ¸è4SïsõQŒ^Þ\ôí³p\襸dôÀŒ¸ŒnŠØÙ.#±góÛfä9.±égôH}c`¥?i'k…_¯ú]I‰?îëcâ¡ødˆ$À÷2Ê j_¢å‡“4z<ÿBA©Q€{Õõô>é•5¤›rÞnT'cjTˆ›œÃÑS^~Ä…Q”ÓŒ:FNP†tP¼‚:•ƒi´0¶ä‘FiGÆ6†½#^$Žon<Ê ª1Ï#[Á)öˆØ#M0BÙدpQE­áÇì %VÅÌýŲ¤ãVø„9ú›¦ð i‚®Äpž¦¡¶¨pUŠ./r +¾ê á4T4‘Š¹`ÝÀðê†:»³o¹8?h¤b¬Z©&”CŠÉPç±}YäÉ9ÉPè/›­ø›´¤3®nh©Q£Ná³J +ÄB«H… á´@[ ˜c H•TU6t„;q®’šËZKÊèh“Jµ¤©ë¨—O%Õ’'êa·-)‚¿-Yÿ¹ý$|h$𼈠ؙ¤£¾y“ý8b’*‰§ÝÙ®ÅøÔ˜Ž ª©X–riñ¾áц¤ì!žSé¬#Õ4Cæøs°:Rä›kþ¬´±„¼$Ç tà6–?îr8Å]"Kú~™%AÎÒ¨¯#E|_B©ó»¨#eu ©`OPG + Ïh+}“ÊG¯±Á/·ãó%‡±ÔÄfœÔ²îµT׌¶%)›–¨yO÷’T‡ªVJKÉ!Ⱥé’"‰AŠ4“2aC5+“ªœ'U.©Á˜HÄ“¢eSðŠ°¥dÌÉÑ ¨(5‹ÀY!/ñÐÔôÛK0ÇÝ¿K¥Ò‡Ê*ÿ’ˆåþÀĨ”àVSÂÙ,•k‡VÄ$.üþjK;.Å=€¼7²è1ý¬ù621¼ßžÓUIñ†mAÿ"–-i© µ ö¤Ð“žLò ¬x‘DþRê,®7ÁÚgÆê—ŠI‹ÄX˜G¦Eú ýZ$¬œW¤ìç ™®zk¸œ½ÙðÃ=bûŸæÃw$“š¶– æ@µ6“L…Aí{Ô3륢?¡¿å„jF$“´ŒW0G$“ £hy†Yø‘~ºˆõÁíLè±ÔL÷€äs­GÌ´P¦#—*Ü’Ò6F °€Ó?ö—by[‘èUøà¼òÈô‚ †}añì‘iX¿€Žõ‘ Z¾¢-ĨñlŸh«—’žf¢;ÄIhG&ÖýÈ¿”Ž÷\kÍÎ2e¡¬ÏšƒY¦›‡4"gOy˜úÕﵑÍ$ 0½Â0åξÞqe2f9Ë„I£)Ã2Ó‰×À”â?Y}‚C0Õ5ý‡šMÓ.e²ª{šr"2S†‚u|SòßWKWSÉd–Œ e"€Ðˆ¸ÄÀT§çÌ JK‚2½ÍQÛ +}”‰ÄQ˜(Rp”‰ãŒc˜êÄkÊ—aÊ'nË€âqïFá¿ ÃT« þ [Æ05Tp׊ðc™°—*V„™ànFH£0Siwí•4¶„Sº”6yã˜?`&ÓœZ®U…B,¤÷Ëôµ•D¾¯}™Î½²× K)¦H{L«jªÒ‰)&溦=fâ´¹824ë2%5{ÓÎeŠÖ+_NÁö.S½nPi’VÏd²q …\Kq¦ i¢uâL¢}äÆO…Þ]¦*y‚âÍTÿ©ÉxÛIPì™Lf„;ÒÊPÖgj uƒã œ)EÚx¨Õäò¯Áp¦è…ˆ}Q¯Ô(œ)²è„:êHƒ”^¦Î!¥ÈPz™…QÊÌ¡PÎêàt–ÇòiqÓ—©~ +‰yý”3%%ÚŸ3eZ9ÓDn¶˜*åLîå*^óåLì ,+ån·L…§U +V*4Ó7Õcá´a'♩„àLÎÌwŒÀP Ï È´“9ÿp&Pb7…)ôÅe*…·¥c3mv‚À™ðµÇbè¹Ç„3ùCQS¦§gh§ÄSŠYƒ‚3< 4¼æ2¥«ÀÁÒ=»L­‚„ ™:”Ã} c4S@ò{e>šé9!c du-eüfÂ]h¦BôÝ¿Ž>sÜ/“Ô@…m™Z67Ù£™f`ár+STÀâÆ™)õ¯Nup€(…«#I3Ôüpüø™¸dgƪläÈ”¸5ŸM¼¿¿‹…ë#S”öB…´ŽLéwåC¬ùô½ËôÅTõ‘),c”Õ–¶Á—‰Ë¹3“B“å‹ÇÃöŠï̤õªá!c-™ˆg¦ÁãÅ£|ª,Lé-\‘ýÌ”9Ðf^žIlÁ)¡Ê8jJœòrŸ“tz˜˜<×p¦Ü¡+y¦1ÁªšR¿…9©©u›’-Ý ̹eñãìL&ö°a›š¾eYp'šP¤kªOl‚«ãÙPQlð³äé”·è2u“º÷œwR w +(»oZ„ËeÀÉœÂx +§Þ£mAâtŒð¶q:M§t•½O1Vî#Ü–êæÄ“Pèt X¸¤SË7òn}´˜P¡ ld§qno/% 8T˜´‡÷d¶“(œQù”<û„+]ìá)Aç+ä©ä¤«øE’‚6ª·QÅeG5ø>Z2‡2 ZF6ÊÄHÑåÛQysdBa™Ó©Ž +(=ÃÙQ…”ùóò+¡RO˜†m}wª0O=ål~¹@"¨I=5JíBŸzJ¦öpJvÀÔ“ºÔÂùÝêTO+õYé^âéŸ 7 °-ïlW°wõÛpítäêÒ+l|ÊlXõùÄj0‡Ê8i€Î<¨er¾yäÿžÜiܹ¼ú±Âòÿƒ} +á¾Ø^3[VÇ>¥­wz«q¯–t”êÖ'è$=7q}Š¾e &¶´êõéáìhìõI³à¸T F©Ö°Aµ¨š‰T¹ ¬¯{}êÊ!rH|.¸> …i)µq}:å:Ã>eó*Ä°)Õà™tq’¢Ð=ôé­eqc:´qròóéÀ#¤aŠ'‰˜*×®Ð"ZÖ¨ ñ]@>áXO*ããä1)™UXsÉ'½“J‹@Õ úø¿ =ÔÖ¿zÃJMÆ«Ä+Wͱ[ˆå_–ž8©LN8ØGŒFà.ŸQslp_†—Oi#/?¦5Ë'½ªlZ¡|âÀ.èy‹MªÜÆP­WM*ý¤N–¨.{wÒŠÌÇO¿ë÷¬2ZP™¸~ñKù4*¿¸œ˜²¯êÑMnI,óŽžNà”5øíqÔÓ¾(4ŒÇôéj™ä“µ2%O(òœRT ¶I,ÒŽC3u–¤”Œ÷”gÓ¥NTV™ŸÄHä|á±d8Lõ¡š1ü£§škXW#zÂòôJÚ?þZ‹Jäí¢Ÿ£rô ÁÅj0ÕgsTu`ƒ©M\°PƒÙEOI´GW!ÄÅsT3_ïÓ¸+_ÒÏ<‘WT<8¡¯ø1±ÀÉy@•y²T³%þh þÌ“N*, +i ØQp̱@@ÏøÌwÄ=Áè$1 +¡'=^A Fç¨É>¡z$0fx+Ô¨æÚãó¤•sfƒ¸ª}ž2ñé×|T¹ýŠ{p¢{ô@Ë"ãóÉ5b׬O„Ë=,• Sô„~Ê®¯®ÅŸl¢¾Df¨s6‚ƒµ˜RY$* +í;Õ«ÖpG{Ž ¦2á:³ *õ á^PO‡åE¤K¡ X`ΓPªVï+…r8ß’-Tßà’r5•OjÍnêSÕÄÍàÙuß3~¿D”uV]‚XU;n¬Ï½³²NÒgD£-Ê/ªBFÓÃ…9 +F¬TµèU´­‘¨tZmã)!UÕþO5ÜbÀ"Q-‡¬*cž ï$eãÜĪè¶'5ëôH,ä JÍIv8Á¿#rp%Q\E1²Euª¢ÂzAÞ’ª„•ã‰‚ìtk7ÀèD¢Ä_œ‘Ò×ó‚­57†g*F°æ ‰ +Ë‘D]¬¯äÈR}Òº”7jZ’ÔÄao qì$ª¼µå‡}É­nW¼MV. Úè/Œv•„íH¸1‰Jyna<²*pp2N[²D”–U­ QšÊ7múœ¶ª$Á6üÓYF ¨…ªö£ƒ;d§»zYØÚÀ9Ÿ5>àÊ6âe ÉD¤P­UCXÈÙ'«Šœa[ñ!}'±‘*7‰¨‘Qf?ÉûIVÅÔºz§¶X]Ïmeƒág€·VBÝ.ˆäá=¡'¶DÒÒÖífÏÀD¶Êâ•$Jš¡d5·‹pÈ)µè“B…òT™‰T’¨Î|¨: YX2‰²€’C\]v2 2(„ހȆÍÑ} €œh†Ú½ƒªtWÅ-KÂéæ&Ú‰Š1=®#íDm´5ö-;š´öo'C¬w¢:äÆøä3ÆiªV‚4Óï†N¦â4[TXØ?©É(D]í7Ê;¤TÌDõÃ=Q:£Éƒ2P¼2FÆI@üý½Æj/–•ýØUé- +Vòé5q­ùê± 1öܾj›ðÕïÆìÁØwU—02e¼|I=Q*ä Cš¬uU¯ùŽ†òb&ùÜh6ï%@./öt«ª<†‘÷ÎŽQŸ%a'ø§3«/¾³r0.M Š5'B5·š3 K®DÁŠ¸*r‹ê!(3Y/}JßESprg„šœ Ð8æ|ÛxP‰¯USÜýQ%ê72â8Y½Uq‹c ‚:#(Ú=PT±W¡pˆH[ _G¦¦“vPW•0Æ/æ8Žt¦®ÊïŒög•*±%GüÊäÍA [•Ò\b¦•Ã¹vUºT9«‡~¢6Ýʇ4F½§lWŠžäùDXšON¬¹íªÀOJ& +lD˜OÌÏc’:™(4HZA»U)DA©îØ‹NÖ×3Qœ‰hf#’M‘uô1'^ݨɤnª¨_dUÈ­ÓŸÈ#Š*ݘ@øÕ•¸‡U%-n1 h”0{?¢²aÛv$ê+W&‘|°bUñ1?à€LTÁêwpÚcžÃ¦â†BÇÞh ¬UÅ·+/šrÚíüîýúœ«+Hœ’RÀÑ:W©¥5A×Qa|$ ­ Q‹Šs· †D™"²l? Á Qª3Yÿétè(n|ûJ½!%«R®Rh•c6é,»*S×Ðú¯Ja“;f@LÕ&‹,ªÜEÔh6b ÐÿªôÈ&Œã7ê¢}æ £Ú‡ŒÅ£N‹ÐU‰áRÕ{›Ä«Â®WeQU‰O!ÑCIoƤn¬e²Š“j¬SU«°¿’7+ö°Š•ÛI)¢8¢R€µHªàÎS6VY@ã+9ŠïçIÅÖŸà;¨ÃÃ…)PMÜ2Púá­ö2%ó³ûR€¦HMMMÙÝ0ÙTôØM­Š17Ä)Õ*º§¨UÅ…²kÕªN5ú‡Ý©8*n6O‘+Fî=…€xú#¦•XÄpñ~¼8CO%´y:óá—oà§äAsÃÓß™å/†1gºOÉd0úñBLÍVŽ‡`É%aš­øè +Jã*Í=U¾‘*’Kkˆž£y4 Ï:±U-Χ§ž%¶â¬D»÷ÔðŸ­â¡ðÅ…cU+x0ýüxàIá§R6Èü2UÈ^¢Ì‹e~Ê´¦¡ÕÑ‹´UexUœBdò©øæ€c¥B>ó©Ù‡D[Ð:a#Àh+j8–†ÚV5Ð +k^µx%P•ƒ|4h¤"PÙˆd€,¹@E[ˆ¼söWv Tf¤€?j[aBK¾QC³*‰ž`¥úv5ŸA Šó‰!*=¨®O†‘{\ÚVñæŠ ªc~è_ä²A¥ü HÍšÂT lçþÔÎB¹ÕõCB°r·b<©xY\ö±¢çHE%6vUÇ­2sfµ‘Èéž+žê¬m[5ª ùk¹š4ØÎrÛ\eðK7M¥ +X#1rr*ÉhJòÚS©Iœßµ®€ÐDPE#zUè»]-£RxB´B¹]™»£ÀêîJ}äD4¨*Õ^U­ûªV«q»Ÿ—Æ^Ua#Ï“^]ÊRqÊ>tU%Ø·W¯`ƒµÆü*•–óòäù …úÊX41V¿vWJX~E7U™ñ~¥<^pÿèfàÙXÖ¬¹Ìê“_\ï­Ä_aÉ(Å Ló9¹ü ~ÉÓªØvË`­r劇­Æ$[Ý`$l¼@®ª]GÜ¿V”I¨«¨)ø~ K€þ©è©íºŠ0£àAêŠá #eW¡Ÿ¨£ÚawW»ÇÊg$ÖÆs…Œ{ÇÛ¼r§½õê/ûÉ +º™®ÅšZxüêñ¤ì쯤°¾ +•5XÏãL9Ö˜»ÿ#›äØ»c‘W+1úl£‰öUXGyiTh†5ÂÃZ”‚üWE¬0©óKYhÊb+mó{4ÁÁ¬Xo|Îk.±@è&0þ$\=˜S,z„aò͆6=±ÍD}Fv.ß(–=ü‹WÖMí±ÔsT„2š†‹ þk–jjx T®# Rµ 0äI¸NÀ3 .ƒW}Ç4w>Àk²ÛÏ}¬äIÞZߤûXÞ\ur6WH9VÔÇÒ‹”™ û2}Бfø¼D2úXnjxîhúXûzÒ:³L? !gxÈ#Ù ×|¬sQâø{ã_¾euƒÕÙ!#ñ±X’fÍ"‚Û7IiŸï_Á¦|¬¼'Ö4.}¿¹ƒ°ÊÇòÞUv¢>G"ý 'Ý@¨ñ±xG› æ¬\@å†%Á0¡‚¶8¢ˆ•p’çŠÌÇRÚÇ +ãVüX‰Žž#’¥ï¥‘eÃkó!'ŠógÙ´ªcÿ +•œá/Ÿ¡hÎh²âü?–5¡¤++ÙvÜøEå­Áß,™‚гoƒ9²kýÇÒ$dpÕY/¿åëµDÖ‘ Yo;0¬`™È +þ +êsJpYòíó9Ú«$‘…/aø" ©|TaðÑs¼rY&…ñè{aã:‰,¥­aÑZø {–^®&²ðµ%ÿ"‘%E7 ¶nr½4é‰,/&èEQš?‘5“ÿ#:†õERn" ¸eÎDÚÆ‚àTÊÊ]˜Y?Âßýþw£ÌçgˆÕDV@/Ò•Äâhfγ]¢ISt%²Ð {fÏD°"Œµ,8£Â‰¬-—"8oLeõ KšW~ŸW,˜_ïV)vKmW-1±˜ó†X²«õ”²ù‘ÈB§h´¶X’€ªp›e\ÍáкåéD°•ÉK'}jZ¹R6ÑÊÆzƒMÔ™$‘Š5Ðàšcl»†‹ÈúB«âg ìNš-a#" ²˜Úcž§ˆ×‘Å¡YóÕ¬ ˆƒÏ„{È*Cpu0Ðý!+8˜<ЉB–AèØ…W O2êªX1[7dù¦¨¡`Ȳçq곉(d1•£¼Ø™ÅÎ}„,™?ÔØnerÉò…î¸qËe^âŃ…¬Ѧmî#¹ÔLÍBlõqg+Ó¢¨Ä}ª5'èi©~‚kÞµ%¡s„-’­%:9!ËønE¥¼Â›uã²*€™a+Y€gŸ^Rô$ $(d‰tZŠ]Ðé—wêå +Yõݘ.d!cÙÙ MíB–?!˵R;dß´^eŒ}y{èŠé>ÀË>Èj »æ“²T9rŸýç-Êz`.ìÔaQä©sÄ4È2%»:=Odµ¦Ao­‹«r¼²°xù©.çYð8í!5í2Ó +éþQ"KYO Ú(z% ²ð®¹¶s ~ ! @¦6sg¯EÈröY‹†D YÖž‡áŸ ¨%duLàÞzpB–’×mÒróëBn¨KÈbMI1Èʯô +²Ncm•·™ã¶*³0¿ °Ñ.6“$ê¨L”M +Ðd!ýju•­:ÈýÜxNû~d]Éîž±Ù…¬ ËŒ‹MR®*¦gÌ_dü£Ë>¥n¦¿”£½QL€¥Ø«?3²:„® ò@Èò½9G Õ]KÈÚ«åzåè½e•jdÅ8˜˜|C!dE`‹¡´HÇêñ +²þ–Þ»µz{Ú2¦CŠ”@”AV²÷¶JÞ çNeáÏYØ©ÜRù@Vh2¯u!xƲNR!ÓYÉŠáe˜‹ª¡‰`@–co;xNOU3ÙQ+æû +¨qÈr¬dM2TA0oFâîLýŸ–³þXU(†é¡›VX  KË6I|l§È¢áTø˜( ‰-˜YYp‘”'^úX•`eaº dñ¡ArˆÆ2(9 d9¥p²‚PŽÿXb‰zoEÅ¿¤«£ƒ+ÁóÞIquä7ßÕ/¥Õ°8´«µìýß+ ;?V¾ÉVèÁ€#?–†Q9J,¤^ /wÒàÇ +C ³zú± +cfÆ]¥ú1?Ö¡·â±PÐ6æ€ìçÇúÏšøhI'!¼àÇÊ0=Î,#bÈ•˜~¬ƒpþª?V ¸j 7mÙYø)³ª"TµEÿÆÞ`Útk)Qî~,c¾Uæ~¬n ¬0“' nöP d V>¸”d©ïôh’Y^fÌ‚žÄ3wûõZ@z}güTöá±Àÿce§ •‘ ‰ÖücÞ Õ4ÛÑþ,pÚóõŒ5AH×uß-Ld…`ËD*®ä#>瀬µ=-š‹Mdé µŒmý±°sÓ}w[–øcM»_A)y!•µ¾±h!Fd{3–íýÇš%qvòrÛ•ï­ˆ› Š´kÌŒ°bKè à~±cÿ|‰qÜ·ë€Öš\i2¤Å÷còʵƒRœ-ÖñÕ>ɵª Ôˆ™Å3çÕûcUÆYEAu!ÚÿX,òõð°’tNVªWoH-’`à§Ð}…Žë ÇÛË æï3t7óÂuÐýD¦¢q8}¬³žÕ—ÀæIÒ¦ècU Ë-ëc9å¥Ò_Â&qemKÜJ%Ðî ·MD‚ Õ’V:õ'†œoaÂö±Þi)‹–ô±üÎ;J¿"^,‡Ž¯yS"Rë pýb"ø«Ôô!å}¬\BØÔëcá &ÀíA]ÙB +ÿì6ÿá%}¬ìö)0«~^4>|‚êâýâqT¯œ >Ö-Áêl·ò“x¤Ð郃 Ój¢ôǽ¼X‘[U +¤öƒÇÇ +¤$Wv›VöXùd¤Æ-xp†=ÖêV¿ +}ß²£OS1ª%ƒ—XZé=Ö‹ã§n½}µEï±zo0S¦÷Xnçë ˆI8öXñSU¥#Kø.ãQž+Þꨛ•q>8aËäc¥£€ÈCŒ²äËN•þ¾=-¥‡'‹xxŸ‰ñ4Yù¢Öø°µvKI ì+¡8š5äò^C™åÊR4£ñ +‹KY}Pæc¹…ôb}‚ò±½ê ž®¥}M|¬ˆV®˜¨•wåc¹ À®Q|¬seãò ;dƒO8‡ª°Ç²ôß +­g ØÑv‚Bz59&Wò=h2š{¬ fŠØZƒ*^®‚ j +Y½*YùÁPôýáfX]k‰»;8ÿ¿a[$¥Õ–Ö¦>VÄ*Vl‚Ÿº¨¸ùXQdvæX°0+ÃØB8‹‘¦Š•Šaác5 SåÁùÄÇ¢†üŽÔ“²°Pņý|êùXqG8b%ß6ˆ1otf>` ´cô±„#Ný@èÜ6Yâ>•…šÄ³%›‹3¿ûX!w«ç¡e^ØÇún|Å``kJ%¬çaÅZL´ûX/çâêc­\²²ÐöMy÷ y¼¬“ÀrãÕÂ;.hS*Æ_ÿcå–ÕñÐFé8+\ôó#p²Æ´%õx¿Ÿk-õì; °u fð|w/9Ó1yJn&3°(E¬‘°3\Oû94Â, k@–˜WÅxo)pÙþÎÝYéìÉD,£·€R„45ÉÈÊLN£I KEÙ‹&(odcIGºÂÂÆwˆE[¦Y?»da*™½6Õ±l4\k°^aå’øŒ-Ò| k¿ØÈJï«b4Z€¬¤Â!Õ³’nË ‰€dákM -¸²ðôáJW5¬]™ãJ¤ÁáÕc2_ ëÿg€8ßÄ Kofíç?AÖ +·å£Ì«Ë’P’\#dÀJ0못«d‘¬È,×8êYÒLWÆ_ô`zÞb þ1c‘6ŒÀ±ä &Y›mñ6¢¹2õØ ŠfŒ¹Œ!«é è‰åµ—Îòä†S”Gˆx¯„,(C*öe-DÈêZ˜.8&ò Aj³s˜2’¿u9~ ¢kÜõ´Ïä­ÊÚB…•}‘s {¨YÌŸæu¹µ†î3;Ÿ!kT瑽4EÆ%´…\nÝŒdlY˜+¸4mžx¬¡/ ®” ’õö©‚‰PUT(hé($*ÑmlH€ ›d2yQÉòls ÕŒ3I“Y\0¦·$l`EF³´%hÏ‹QTÈæ!Yè¼Ü{1ƒdáó¼Â?ɺaMJeës½5qz±¥ç¿”ëP„÷" ÜÕXMé!¸ZIcì¦ÿ.îÉŠx‰æj$+²$’«‰ÙÖP’¥}i‹ËK³Îe;c/ïdjIVíolm౎3Ç’,æ8N^Šò#'˜d1¶vñ&Y掱 +¡Y8buê$ û¸ë¤¢ýcý™•&YŒ=r,â•zK²ðCd+É +废e\Ø“)ÉòÈ¢# ¸ô‡–d%{ówÅÆv)¡ˆ%Y[0б7ùØPÓËV`€]È<É:ÃÃÉ*.×S‰eÕ_Ïj}ÓÖš,XƒzÓb!¢¼®Éº“†… ù ¹ÕÀC`M‘£´:X„æ¼n“…?4IÓæ8P²É2A +󜡽›¬÷¸Úî±~§z7Yrýë„H+\q±£™À&`"±ÉZ¹\ŠÂ5¬S\ÞdyŸlƒ_V³øM–RRî|%d“IȨ\8Å*Ð& -{|ÄåM–ao2â&ëKl†YKJÂœ-*RÍ›,•Køü®¥ÂФl².ßW³H7YD§=J ò!›,›búB•d”/Ì{“EðDzôñ<£à›,ª6“Ô {ìËb“Õå04›¬"ÓÎg×嫾MK»WÀj¯pס©QEîôC÷6`6Yˆ.´F"ê&ëÔ íÓw0€MV™`§ IÅWp²D¨Pú¨Ÿ;'Ë+¥´]K¸[ÇÉJgG¼¤¬FN¬&ãd)º˜ádu‡ß©y¥¬€Z„8ªKˆá:YÅŠ–ƒ¡¬Ã +”ÑH4öNÖWø­Ã™C;YK­ÑlÓ؈¬m7ÞçIÒ“åÓÝ°‘tW¶bZ×÷de.Wo.aÞÉúlŠ8Ó*s¨wâÞNÖ ¯(ù{ËÕÉÊ©†Éjž Üž,¿>ï‰' ³yk"ÍPÜZ'‹*Ä;YÃ^þ¬JÓõ&ëw® ¿µÉÊÁÑ`êV1½l²^þúZÞƒº°É +¿ölW (m²¢tÁÚr-×¾¨v«aî؃֢+Ø߸ROývW^“eò“ÖCP4Y+§ÛÏäòÐR“õêëeúM–à\pÆüJÛd¹ØmRžÆ’.m²¸<ñüy}šv9Þd{ÃÜŒOH¾É²•‹+˜(…oºMÖ·E·×dy˜„N:¤yTÕû\ýseʨü{&Ë/zñ×Daš ÊÙÔd©(«‰ Â^‰¡,È“ƒJPÖƒh¶T—‡!tæg\ ,AÒŸB†Q½ Ê )SfáÍÕ ³ÔF×í3E~¼3Ð’ƒ–ù§T9³â)ïÈõQ¬ŽUeµ|M”(«6ü[qaK”õÉ:@0s>àœˆQ”¥Ó†Ä8†GI²ÄMNšÀ̵#S Qó€­˜„(Ë$v%F”éÑ\ +eAò¾p#ŸŸØŠ²ÜôÜ{­(kØÀ"PŠ²QÍÓ@ÃCZõQÖM&^$ keµYÊ' EYNèøØ-<¦³Km1ÊÊTÛTïúŽÃÕßÓDÀ£@ÿ(ë?Äoà›GY 'xNù>Ê2­”á–'õñ(ËÔJLsFY©ßAU„‹ü€ö(+ì»Ùí"€ÿÚ<ʪì÷ß³?¶eÙì?á n|•WdŸF23-t¹Q¸øporŒ²ªÀš(wˆ¹~”uú¨\+ÞFvW² +l Ù£,/¨Ì굋¤ç‰ŠP£¬"¶¹ïp‹ñg”…=‘

 •ºâÖQÒ~Œ²–S}„GY‹ç §Æê)ªå;ñ=Ê:Ê<‰ì¾A\ñ¹Á 1î˜eq`dƒ£¬é«íïš»+n”…ì•óÐ^Zi”%e©òE³ŒM„¨ð(ëãGE%¹`_M…Î,RÖ rjj”eF*ЃsHf‹£,09ò‚qGY¡ª¤J³¡ªëL®ú(+á3¤,Ý£,Ÿò + ܵ•Er–›ÈŠ 3›oe¹,)ñ©¢Cµ^YM+‹[‰ÝB˜VPd!•#GP“2šV6r_2FÖ•Eú¡X…reýa:5{£+«òÓ†jà™ÍweM®ú•…fÃ…¸²FI;¯{øìàךp°¬{™s°ˆJ ö,«ôN°è"O!JËW–éúÌ•Uµì0;››ù¦èúë++Ç°S”&Á+k­°¾Ü–‹]äS~»z$+­FŽW ÙÕ˜Ô\Y¦àzË®,à.믮¬Se‹ØÊz—…ª86JÂV–€.tcjey"t»éáÂç÷©_c­,èàö:àn׈"M}šîlXY»Û˜µd^”<…ü¬,1K¼'põgî&+KthzWˆz“ìeeÁÝ*ûu ×{ûŠ´È•õÖ®¥Ä‚ÏʺobxzùZY¨Nñaú¯Ë¯d ÜÊ¢¨#^Üʪ'/9?RÕí¬ Õ'¿{™'¤VÖx:§he!Êýù‹=.ÜÊ:ËV`)0jÕ5'Ø;‘1§^YüY6E]¸„²ZQ2 œê}×8 ,KiW’(ßeUg Á²VAy”ü!ƒˆv‘‡ØÙž,‹9?™˜ó^=ø´‹ÃÊJXVÒB¨q…¢a€eñ…C왨÷¨ßsªBmË àO‡±`Y½ º¦Dun<‡¨P o Be¦×…eáÕÏ߀+p›6{² ,êÒbY0² LbëРËÂCKæ̬ŽeR¥òÓ²I‚eµÑv+Pw€e1nLôqxl<$aY·QŽ ¨['ÐWV°@à rze4a6CP ŠÕÉ +0ßÉ\Y>¿¾yo¦B«ÑJ}ßß®¬ÐÿÈZ8’…m”¡à;Ò®,SÄÍTõa²§%ý=VÚõÑÓ•ÕsR›uXã¼sÚý˜áJa!ï°¬yq+Æ¿/,‹M¨ÒÍR,k…ÛØwå=äc=‡EV +,+/®íƒ–¦©\~…s5,ëÑ$ß°,eAú +Ëê¥yrlªˆe-\¢s , Ò[ìF·×¼q,Kn‹G’ÜGYÖ*HG¾7Vx߉Rë»wYÖÃO¬•7€Bm;Õ(J–Õt¨^úÔt¶YÖ->ÊfJ‰YV;OmlÇfYw¡b× ×9Id–ÑŽÝ÷ž, ÀŽ–MË\YVV/4èÍæŠNYÖY˜w5dè˲zR»"e%R¤¡,ËRÑsþ)×ûdl²¬cmÛ¦^©é²,²$afʱ¬ÏºÙ7[€0–ª©=„kw˜%ÿª`YÅâsG~>j7gQ¹@réý›ôMQ¹ Ô¹=!.¯,ø¼üZ.j!É]º@M˪²S¥ –|éíÀˆÃXV6‰Û¯˜ÖÑcYƒ 1£ ¡>;IJò&7A½LxöŲRsºÌ® Bnm#ÝË`û2¦ãwçIJs”BÉŽÿ›Ç²–dVÍ­%‰e9æ*HÙ¼Ngè(2(ßùÉyMäpÊ}seIîd¥<–•ä1™vTÄa‡WV&ŸWƒ +ØáÙÊÒq’û°¯•ÕºQP+Dx^ü¤…ŽœÙÊ2$Ó¸„àieéÀ}â¶eÝ™ç†X¤OÊa[ænÓîH%‰lY´Fšµe©çf¥N$;[Ë@òaÄâ¿è›lYH^”0óyúÚ²¨ÛròP:ËÏ_Ë‚L漸LÖ—) 6™Y6Nžƒ„иTêÃÊ”ÁñøšïN2-ûx½eeW±ºˆ„‘[d§E·†‡AÎF\–Ñå&YKF.+®jŒh:,”$.ëý²=..K·¡uÆA±Çe-áyT‰¼/oèèQzËBûeÁ8HuËB¢‘´eýLú@Õ´ÍÕ nyÌ£oYyi 6„àd`˜Ü ëÂ×Ïó-$˜%ÆÔgÔWOÌú´Í•y):]päY¶:xȼ•Õ—/`Vû†Ã¡<À, W¼í'E ±Û½Nk+(DóÒ•žÆ Ì‚÷™î´¾¬“®¸6ù¾¬ŽÂµ7”ËèÉ—Å‘i,aÞÛ¾ø²øòŸ‚R'3ÝÊsýü²@Wqñ@³@¡B>O¾†;z@à&³ «5\´³¨ÏÇ댎{r[¬L¶–W'Á,ÿ LSq¢ÌúUxú­d–½È%>˜Ì’>ëÖ"³«~D“Y¬øXÊZɬûL»Ëád– {&‚ºêé‚…Òj2kœ83`SÇ3Ù~Zƒ.™¥ +FÇÕL#³¾Ü-ŽhyCf5·bT/2 ^†G©t…U,ïÉ,7Él•^ò¿³ä-X`;´NfÝ#kDpùHfœðŒœU†ŸÌòhżA2ËXNk]ú"騿zk%³Ð»e¤¡•YÌU3¡éá=”YŒ{±ãl\·˜ó«Ìb¼9Möñ^;QŒË/<+³>·Ë¸MMe~±µ»‘Y*¸12)´#‹áEÊ<(yeVpNA,Ë@Rfé<¤¯áMU¨ÇQÉ5üu½²äY-a”éÚ_¤ÝGËÂ%™»¼Bûëð<…p“Y¹þ£A²,™+,ívYÞ̲zóLek‘fÜBÊà8TCýfVˆAóìœÔŒfÖ¨²™ÑQà]éÔ™Ÿ/2ÅÅ‘Á89³b7ãšzš¾άPÛëqãàÄ5Ž3«n€uŽ&õ˜<,ûí̲“¶Ì`SÎ7 +òh ¹:™jikZ)rJO!Kµ` ¨Ÿ;g†3 Gº?Ò;b — "åÌ2‰j˸".ºŸ;Y]ªÏ*`ê÷*½—ª¥H MsbTVJMf]–íc$sfIx?³Ò$Ð,‹–Aiª®¢Y9£Ï‹4¦"~®@³ŠM§®ÃîÏ,Ìq>a¬šü™•E€=oЭfã*òÓ(ÅϬ£m< å;JSÙՑ±jR;c'hYTƒf(N9“<^Ìû6žb¼šIg–SRÖê2æ1YpÓ•IgVŠ@# :j;þ}HŽŸÈ%{eˆLZ1î ªšå¥„QƸwf™JˆFžYF}¶27ù™•Ê›r\ОY'Œ¨Eq©‹8ñÌ‚ò¥NI°txfMdö¢“Wwysæ9sñ™e# ï-UÒlâœTXsÁ“€f8zh1‡÷$xÕ Íb‘#ô!ý å4‹QÒh(å7Bz¸6ïÞ†tb+ëAÓTIG‘åY¤g¥Y/&ȧY¡Û˜5+ݦ–£[l/–ÞsJÉW9~Ùâ!W ìÄ[€ÏÂÔ,û¤ez„PgÔ,ß1 J¤AÍ:¿¡ˆ,ÿ¨Y¾­ãu¼£f}ÁmKW:6•‡Õ,‹.BT-j"#Y±8Ú*D?Í +šÇ?-Š>L³Ò3ÆiÖÒËÂkR2®Äžf=hx%… {„˜S ჵH#=šàtÌšå⢬!#†U¯8Pr%ˆ±G‚ƒµ ªœf]‡+‹±ƒ­¼Ç”0{»ÄÓ¬>œiËJj–íãÝ”CgP³‘â¢LÍJ69êjô ®ªXDH®ëÐñÐáš}S³¦E+xÒ%Bo™¤f}u.cÓ2„ÛÊ©Y(¨2LJº Ô,vÐFÕdJŸ4ê¬òàD5«æÒ.@òQ’r¹•÷}5«|t²Ò_³ÀT®Eß‚MÙà–×,À®"€Û 0Ä¿f¹kðÓÇdyÍŠ$вfýÁ~§š•ÏŒ$[.þÁ‰Ž<[j^âJT•šá½,HlkjÖVvýP³†Â‰”WzÆ@ͺ`H€ÉHj@2ÙËË Õ¬cct“ÂA{!=uZÍ’œf…Ÿ34WÜE½7TYTJÍ‚9Ò$¥f•iH4ÇjÊ’~ílÌ‚—¾eeO žla*¶RJ·òâÍ¡Ô,ë‰;+gÕ!u¬ƒNÍúà S 5Ë:ý?ž ?®ÏÜF«Y÷à9®U5kŒþêò9õØj‡þš'Æ‘PŽy³R9…ºÞ^š‡tÖt†n:$Ä›4wX¸iåófiYÜ9¼Y×|þ¤­³Â¼YiÂóË@àÍŠÑý¡SoV™y·>U±Þ,Ùœ°–;Kx7 „ˆáØÕͲ1Äûž¸Z+Zm^FØS »›UĬ! X(c†0w@àWviÒÍÂë\^Ó¼›uþP€Q:cUL8Ã’c1¹Yí{NEaDs8nVƤßZ2à]Ñ•ýx›õ­çA½M˜î––^'çñØî?‚žËHm³nÄDíùý¡m–ÚncVtØN:ÜÊ:±H粟‰¤€•èg›…Hc”·íì ž±ÍÂÛ±rCÞf‘Ïî +l³èÞe37 `3Ã}¨ã> áfå©]QøÕÿ»íËÍbâ•_h2§þ 3àmn!gt–Iô¿™ì¦jq[5'<åf!`ò „ǼEÑjkµdF‡e)KϘ©M~½~WÊj>Ü”±ÁEO3ÕmÖç;µ‡eŽ1LÚÿìË22`´¬#Þ- ž…­–d-õ‚;¢ÍúËM ³YH÷Ü·…aÒ¢rÏѳFG>‚†d³€CгìFØ‹ý\ý†îa©{6ËÕ})@mF–Í +J‚¼X¼šL + <›ð®e)s ®Í:ÙEÂY4ûSÆ*Ú~5^Ôų6Kuàk0åa¢Ø“Íòº—†/ÓÙ¬h6ºíÒq˜¬ÊPùf³|;ìíäK²sùW~QKgKBþ&ÍÊ3ÈȤA__%Ú~Lel›ÿvê%òõÒü*˜Ó‹ÍRVüj•Þ©f0Þµš5;-Bqm~)õJî5Ô3RªY‡Ÿ¨ªù£mâžð`|]¼ ë«Yy=­l+^ÍB—Ì>X³p;†ƒ_Æ¡#ˆ'Þ6Ë©‹é!å;h/FäêÂfíçÞ.a³jÃeŠµ¼Ÿk¾æÃCöpͺ¶,¸|¨â¨¨kl¦8Và +÷)6 ‡—¤¬0ƒ’“&g³Õ\ä8c¡*§pÑ)c¯4PÏ‘`+.¥( sÔPàv†¤ÈÙ¬û¾PB6K»|\2ÉÌfu²t×#:¨Æ_6KWíõ£ýóš?’Íš +=5ØA¬ë^-Â^YWíš*Æ'Çá£?"4ÉñÿQ\+Àfåã4$1MÂó*w¯ÙsvÔæö‘ó—×¹RUo“HÕpT28‚BÊ’Áf}¹fÌyçXrN°à죌Íê®Ôy¼e'ú›UdfscÔë{ò+)Ä¥+!šÍÊ‚¾Òúl–£Óõ² P›uA(éÒo ..¡Íš|m +…{àiBÌ¡¹=Œ6â³Tw# 5ü¼¬Àt_›uB'€e†‹6k]OT>mV.˜¼.ÅÍ2R5S.n–ñØE™s +”2¤Â³Íºf½=‹ô6+±î& b³ºi,š¹×nÊf=gªMWZŸÍÚ¤vÜ!a³¢î%Þ$=è´ë5„$?ñûÂf7ì:ð•d%JÈfÑ®ÆeèŠl—l––AÛ¥<Ù,¿±Îf^&z{g2„Èfq$ÞÔ8*d³PÂÃÿ†À,þl–noXh²kd³ÌÁ|ê½0VQàl– þ\¡•ûÈöj–»lVhZ UM+à_«-`h¥xDcìŽ!Ï_WµYëSx)…Û„697QKÙ‡ö}ŠðECî–¥± S,ŒÓ¶î/›åE‰"Ó;›uE9/ ’xœÄʦ”ÍêÞɤ1£/uÙ¬"‹­ˆT åž‘R‰³¾•IÙ'å›Ò’î0kdxÉž7hœÕk²Öfe¨Šf=”Š¿?2ݳYO•ü¼Ã`KÏ+fÆÈ,!kÊjŒç ¢¬Qb¢F¼÷/ŠÌ‚)âÑJPåR|ù=dÖÁ1—ՠ⼨äÁ1ëÕB”Pù§æ%€µRæÌ1˲ªÛ°î˜šÏ€«æGÉM)C³à»e¿ô4–[D¢Ä…¨@Íz*ù¦ð€V %Ñ(ß hbˆ_&ýM¤—Уܸ Yt ©Š(§Th–•bܪ`4+ë0n·— IÏÇD%…fh2]vŒ™ìA³ŠÓrq©8¯RÔÀJ Íje?E%Ù¸B³ð¶ùÔìø)ø×h–¶®ÅFÑ!c˜øŒ= Í’&J4 h–ó+"®LŸ)4k@t=×¢šÅöEÇé/„fäà×&L†fÙ(·×&zI*‚@³îPê”â‘TIfÄvV±fm¡!Dó™eŽùAv$"šEeÝÚÆ¿×@³€~Åøj1 4‹ÒYKÊ·§ªÝ´44+Z†fúÒ¿¦Y£Ìú©ÏdU—˜eÌžÛÖ•¥ÔµA¬W33µRõªöÅßC¾ñÐõ¤cvŽ% Ä⯡çO”ïr¸¨çƒ ‰i#¾IƒýcuÅïÅæ¡Î2F…_‚9-ÓÂË$Ë? +ˆO¡™ Ý|fD¢ª;ûÄCh²~uؾ£Cç3=i(6•N,†ëH¶ŸHÀ"¥|,.m*Ü¢ääLRÆy”PN ¥Ô&ËhÔár<oÄšÃRFf•(_±_œ/6z ¾úõÓ“ 7¶šeic[@âSIg½Ï§‹87øµ0ú{ À¡¢T±ÇG3ÊÃ`.ez% íØ¥ ѲÅ`îË™Æúz1¨Ü³ÿƒCøho÷\ÛÑéÎ.“ÿ/»ŠÑ’"ªw©\’TD”koÕï¹ ß)ý`a‰>_õBŽ©g|bËηˆ-œKó÷‹DâO“Udyk,­¶óRL9»ÐÍ)´3FÏ.ÿ7é7æ¸ñ£€åæDv5Šò-tU©M¡(Ÿ®FDÌ’û|¯m‘: Ì'ÈÁjÀ%z)']*ÝÅËÁåí¢O¤Á°›÷üþfà}+È(ÓÇiF­Ž¿)‘v^Á’LÛE.Ò+…û["@½Õð‘9Zdµ>Å#‰MP}m`Ù,DÒ˨”ð•€è*•gµ="v­$§ä ¼Ñ/ÆtAÚ@†çµ½ÃOÅ—)½S;±‰ïSúŒgЃ  ~ÉÙ.3o¥½kò­7ÏízËúŦŒ„º³Œ™;f(|çuYÏÃzëGö14Ò„ù·Úñ‘Ï«\sd㣵£srÜE„i:„!OPÿÌeÇÔ”—¡z½dHGJ”;¿æZmÛUÚ…z{ð/V4¤ïT†ÐÞ2‡ €¸Ù^Ã4TB“›áåCÆpÊi'‰åä†,0þÕñPÉ‹øœå$íoÞ³”š³Od ±r@«!ÉÂX#WàE6êò•êÚý5d-^Î ûáÏÀ½?0ãsÉG•›&ñê æ?B}%Ÿê­Š#.Ee‚ÛÜî¿Ú'"¥·e¾Ž;äDò‰<#&¡£,µ’[®û¯ò –¾1ÄJ¤QÅ.Ú5ž|àhÕD°š3÷òcóÞOb³ +¯ÀtÅñÉdÑ*&Ð`¡¹Ï§Xýz3óñ³!7!³ƒ¶ÆKÝP åÖÒ%Ñ›î~F°F‚©0Žòe`WÃ\]ÊfwO*ÑŸKÌ¢ì8?]\r–„´ñÅø;ÄGøQÑÿúšolID³NßlZ1tNM©vkFAÓ†¯¤dL=0º‹ +ˆmO†N [9[ÖS¸š‘ç +d¼ðh ±USPìùùO<,OÒÞD·ÃŠñ4©ÛRžLœruÛKJ+ù”Dû<¬¥ñPÕ‹uð±ÿŽ¿Õ`SZ«ÁmóÔ-º^]5’$t–XŒ6MÍ”çß‘b0ÈþSONÒ“†4öÜÃM$ÜBw=¢xJcuBËej ôXúe“®ú@8œA3TCü'?n.œëåÿ‹e _úPò‰ÃÓ¥¼ü´±¥‘ÛZ*ôLåa®jšeN¦£Lå.ÆR …‘®°Œ4’Ë]ôѲd¥ìp³\mqñ¢iq›Ók^ÊöÖ'ÕäEOL۬߭‘Ñ…éÝo<™01ïr'zc”Ó–iø Ò¢S"B5)»gÁ3QÎc°,RVšæÌÒNQ/4EMÝ÷‚ÍÑâàb¼—L¸´âQ}Ì (\ªc;ôÆãHWæú¹¯ÜFÁœé­£a:ÁÈuŒˆduL± R(”q‹%b±(dí&ÄD9Ú±ŠØôÎÕiCD¨XìîU+|EÇþo½$&ÄüÖÈêãÞ›{äj +/ÂÔP•þƒÅ2ÓAd,èËF«L ÔηÆm odåz SA@|›ÒpŸríÓC«[ÖK V`˜ +'"ïЮ·LE§Ip)þäÆç(ŠȈ\}ㆮPˆn—r\T[Â.Ã+éŠ+²úUª+\'ø)¤BhÊkkE@üÊWih.¦ö âA,:]éKŽa&.} +÷Õ$Ñ*¶$uyŽÆ6’“%Š:I@QâTŠß†úT¢e<;êæò«/¯Ý¸ ñP©?‚7˜=€ý q ‘n±ŠÑ²Ë ÜìoŽŒ'm–0{>ªÈdZ¢nË«Ò„›[A ©n¶åòçUˆg"$Ÿ£÷@%çUosŽhŠHŽÌY[å4UGLþþ…æ¡°„v’XDB>ç#’Ý—•Btßš©£Çs†Ì¯Ã¦·Šä¼½Ž¹fÿVò9;OR2S2ï,¸ÒŒ°zü›&ˆCÉ°Œ©^à/gmÕB­³ +¼%§mÌå˜"öµ@á· ÉY4§£¤YöË$%Öž ¬dõ-þÜ<¥S‰ä¹àñ-yú ÅÂ>³¡u ”ÃÈuf3%ãyñÿ'`v­pP_"Œ‰“2C|$¤0© ò4=Kr C Jm¢P"$ÈbßCÞ;5ÈetS>›ð'Á`›D†òj¶±Üò:F)¢%pصF*6Ôü + «Ââ˘`K¡/;…ȈqÛºŒ¤ð9˜+ÎñhUEmnj¨²KGˆãZÖH);Ír¯a>½7› =ÃG¦Õ¼Ê+zV+˯ûÙ)ÚÑ–“†Æ¹òrÌXC+ÁMxŠ£ *ÉÇ0¾£lÓ0ºÏÍN nؤ†ÅåzÒmBÒ h8¨ºGÐÚÇÒڽϖ +jv‘›ÒÝOGᢪZ¾³+žÈCÄL<²›…”zDhâI÷r™Üê'Âkµcg/}"RÆŠP‹D;<“±2Qž[âòÇ™rA€,³ô¥uÚ?¶#4F3åœ6œ×MðAf iqaµËX”Ù¢uˆî)µ¥(‡[Ýç_4S§èèËN0ü‹eªs³9 •½è¹® ›Q3é*U쪀ÞtD£ÿB¯º0Ž¯%RU._“¹l°´ùʱ›iߨnRºÐaôêwçØʹ–a—¹„¡¤Ê÷…Žö`¼…y¯8aK€ÀX†?,æ‡@Þ$dîŸÜŽðq¾¥úŒM´\¦¼ãš ­.mÁõXFOéM:Y7؇ _ Øò½Kæ´ßN1½`¶œŸÉ³­ ·ù!‘ªÜ)ΑøÎçÅ…[ ënà&Ê?ÕwJD϶m¥ƒ‰‚q™É}Рj’>#ŽÜVŒ¤´Ø\M3`âªn¾y¶7Gb^Hó!V[ž‘ŸÚNKÑrÿl×ñÓ)Z?a¢Ë$•]ó­p‹Cп6.ˉf‹¶ÎŒÂl$fíCÀцŠŒi‡Â$±íc®(\Éh’Ýa¿´¨)DÕ}Ã’<òÿÊɶ8$½Z ×òÞnÝoyúÆq3q¹û€”[Y "­ü*`€öI 1ÀE]m²QÊR^9¤äöZœº¹&–»ú–J6Ï“!É WÃôu4þ“'Xu{¤'¢t«\‚—¼kj¼+‘K:´tQà¤øÊƥȘyyZr¾›ìÝwÔÞ>¾àÂìbÑxã¹;wœgŠÂyjúØ(fÀ¸wâjzð¯ÏBÐå€I%v›é+\NÏËñ OÂlu!ÁÂ@.0¤'È]RÒ‡écôíq:ÐÝx“¼f•¡§ E¥?Ì pFÜx€tåd)1×â†ä£Ê:èOVÒL«q¸‹ …ÙyÓ6 +øvWïÜT\#TM…LÕIÅ÷0š‹»0<Ê #”H(àYöxðQKZ2LÛiñþÒéû4ù&f²¶ž®~6Ç誮T#ÐÉãIð5}“…Yyoç´Z„˜ˆs~ï<Τí*éñëéÇèŸ0Š¤é ùßSâ]XûáÕϨ1ÂË[Ž™Š/ÅÛ½ôÅŽ§RžÌxX̹Š“[òYi»šâî”ìü£Ì.‡:¡¿®h<{@©b™Wyfuñø³\tÏ¡Œ³ƒ.]Ù,.‹nô…ïÊâ)·*Ó#›<ëBL’ !Õ-›T!›v‚¢n¢QØÕÉeÉ­g?þ¥:hùám_­4 ûõű† +e²Á^bX2L‚1Ï3‹*ÖÌñ8 ©Ø²TŠ~6áËL¦ÿ¶k +ÒIB…Ãü³ÂQ™’S)˜«çv’ÿTB/{åé˜ylEüýé¥VeDWhëN©Î2pÂ2“½g°lÅkð…ŽßÁ]Zé›@ŒÍª`Ë·#é» -ó>Cb*×ÄÁÚ¸ýQ¨kµH\àåoü%[&Ño48KÿtÝ@#Ýa󤲔ñÙLÅæˆÃ–z;ÊرܠMz„¡ôG3„ÌuçµsL°x‚–ñ!Kð ¥²pHxç–§ ¡rM3œãYæ 6œ4ïÙ¡Ô¸6óÂ7¬C‡¡Í¢Ñ†Ðµh!Z£Ë¥UèèR‘H$‚f¹{ÂåÐj^‰JB#P%Sˆöñ -”Õl’¦Ï„Ê1…±*´ÿbÓôŸøac âhPRÌ,èB1­øJ”„…üö©öŸøðÌ#‡-œ˜…›ŒRbÉB>‹FçRxŸ=ûÜŒæ!W&ܱ`]Ê/8s)Û'fÛÉbËr†:±óžx…æ4ìÕ¬{Î:D[ÐG£° ¿>·6å ñE ¼ÿÝ‹ÍiX`—G¶ÌÖo9ô×mOþU/UOüå–PÎðÜàÅQN®`SÎ`Ô]*wÁûod-¨05³œÂüTÅg÷œWΠ•'Œ˜=˜óé-§Ñ@Ÿ,P9C7ôvÀ—‚Oå À(w«²Àûå4ÒeìÛ ”aRÝ[†µciNý¹ ~„UÈ!®œ‰CçOcû¬¹‰WÎ@bƒ¥üå4öAë÷Þ›²«”úš+s^*gèsäŽÛL%ÂþÍrdž¡†p°‹gXˆšÓèp*»B1Å"RænÄ@ªrrõÃ+´·ôµN"3‡T +sÝ¢µJÏIcNËil]K±9êŽsãþ}"T tû'‹—Ò‹:›áŒ 7"`ülQÌ/XÝÐQ‡hõÙp†] ÎöK›A¸%mï«—t8@y¶Nc®T ~vØÊë4ÞÂMZôß“¼[{À«ÓÀ™~£]ˆ‚Ä2Aª®Â<…ò5ln ürΤÍpÃÉÝ`Ô±(•%¿~Òi¼:§>ÙXý~ ‰´b¼ß*,ÓiÀ%G"d' p$èâg29pQ­òÈ«J·«*Lyî/α&æTÊ°ú3Åi䂪9yóÁKB \Køk`þfsAî1PŽ>£œ¾eEG˜4¶’/œ†89Åi”ƒE'®l¹ïãÓÍ1À'.DF •Çà)¾¡±á˜¸føã4xß…ïP³¯Èâ‚9¾¿µÓ`µÄŒ‹*„ò¨~EâRúé¥'µŠy_Nc}®äéÃ\ Âjéq‚Åå4J,לÙ=Gæ ŒAEè¯QD>‘A\RƒëîN8„eŠhn0HðÄ¥œ†¿kÞÇ!Îæ4Ì snª“ŠG>>å4Üâ u\“ +R +ååñW|]Úˆ¶¨Á*[9¢›é& "æÉ@X–:˜‹ÔÈ,§!^+O9žS›]NˆÕ øêÐÝ^ÛeüÂ8 z¶ ´©rÜi¢]•Æi s²/›ÃЦŦY…¥ZÅV¥Û©§!¸*¤‹¹GûÁ¯8Û}%/âÅuÙ«ŒçÊmÆi´lWS:P! „´@}§ü"Z)LÔ#ªÁrB©°ŽÓ@¤,v£‹™?š4ÂT5œ^Ìov)ç‘#ÇFËn@êIνôu'‘"]Š_ÌucJ¡¤Q, Š*ozű6Äyt*- FeÓÀµjk™$›_µ yWûïd›McëQ©DªZ3ö¨víE£MƒùS!øè_(ÎLmÊ»ÜÜÕ¦áþÐŒs¶‚*ç³6¬…‡a'8h µ¯M£œlG(>ùñ žø¯Þ,€ Mì¤a ¬öÇ)~f4¶ÚÁàu–Æ\ý´ž=ÅgÓTŒMeµ/ùÁ5â%êIš•îÏ)C|œ©”_ï^‚ð +ð“âwG¾PÞÒ}=ê¯ó$ø­`ãÞ^Àôpkˆo/DDwV@Ì6Ø4vé‡$¿˜Î²Ø4xMÀW󬂽(!” ë…"JÀK j­ëÏüA„4͆ ²ÖYàYÓ*ùq8³b £³Z¹"oaÙâ…&-HÀ0Éʳ¦tjMC1jüF„t +½‘8",½³ÙzkøÍ‚_æ0xN†KZÕ4Æ+¸špCÚs¼Ð8M=y¬¢@\¶Pc/ §Œ-ÒjWÎk©#`MMŠH€L.žšwb—¸¦19â +0wß`8ü5·]FcÅà\$ +úš³\PÙ:é^ÓT|F]Ó8²^º°¹¬Òÿ«*h®i|Ù%@WûšFÙd¡1•È'ÇPÞ@ë†`2}ktèy™C„"¤ mÅl‰^zlÚº]lh‹O’Áß,û~ÖMòvÚÎ݆À¢ËŽðšF]_¦‡Jh+“P]Ó—œ¤ë1³“ìšÆ*¡ð”çìšÆLÓ<Š«ÔFÔ§B5€1Åì‚ýGlü‡2ê_ q:VmùâBµŒ£“˜™5Ø@—ñ¤8i½¯p›Ý"‘®uM#EÌ9‚\èJû{u+iB³)’aœMË¢‹m† zâ¶+I¾`i#\'¸ñ¿ðÚM#S†!f½c‹j¤Ø-9£Çž èc%¦l3Õ¨à®ßUjŠ°i$5ÆxMåV”ňaÓÈWؘ3DA“!л /âP‹y_O‚U‚¤›Ýv4‚áQ•†Zƒ–\Ƥºõ•æéŒÉ̦±°ÉwÀÁc-,ª ÔÆXô_ÚËÿºÖ³ihyå3öø¼·GjÁ¯Þ&¾à†iJߦqÜö*n™ Zó&A¾yf®À%ùºKCIRî¦!jaB6>Z¥ÈÐß4v´;Vÿ£‚*ZõUª‘›ñDbðÃr<Œ‹›FKõ‰ÑÁŒÛFy:ßÿdÂi )á EXgáÔúäÑ­µ§zž8ܾeÛ`(2Xmî<šPrˆ'S Æ$f4 wZâ±ÞÀÏå\ÿ~•ýà„ñN‰4†ÓèÒŒAe¾¯÷œÒ¶étgpêÆàÛÈA˪«¦Ã*›µ«iI$Àw3pÇM®ÊÃævRÿ„s(‰€zQã!à4lJæõhþ–§ñ|"ÓØ×į¯ÐÅB=]ƒÌåMÅÒaìÎ +¶øMcu°Þ»–S·+± †]v-%“æ¿‘F ÿ Ê‚‘ÔT(ˆöªÉ;Ä•&ñíåcõëÛC,He²Â=EUÀiHZ•'Pƒà4@À¯«Fëü7ôS[EÄøfFÕ|HV.G‰’ŸvaÊÝd>Ä‚ˆjãSþ èŠ,SóMãSS6¿­pô›oî³ÿM#'‡ô­¼ß4Ʊ þQ<""”B'Óíᛆ?Ò~oÊ[|‹ˆ[Œ¼†Ài\+ƒ@§ëf@r‡ådø–yÈÚ€Ó8¶‘m+(z.¦"Üì—3Œ×ÃNƒ†uÒ!ÿ³7lcäeNÛóp¿ìã ©‚ßSN#ªQ#7¤ëB¼÷ά½Û +#4÷¼Ù (A”H…°Kënš©æ—´3„èb8 ¦7öÙv<œ†4’hPooÅŸ@ ¶E,e¶J¿=¸G÷ŒOX¥]€›Åi`Ðb"c§Q·©èa!6ù>¶+6ÂïørIšÖ¿Q{-¡ˆÃ%‰«”Ç€1$ŠÓȈžyÇ ,òòΨ2QmóßɦÍæ)s™­Öq[8 ZãÖ”òÔ¤y—@XHö×GZô¸pRƒÆüÏÕõ8ÏhÛ‘ô ëk¬ÊNƒbi†Òªoli§a™È³¹˜ß4J"Ox[€°°îÎÄ”¿äx| +}AiX|d¿iô¯c—¡EuÎlý›´¦® /à4Êá±G×ýÁ!vÂÕOd¸Ôü`¡ã6(QŠÄl[š èÊ‘Wý‰KaËgw?á2oÊ6"*?_†ÓØÇfç³!NCöqÖAÿƒ jñÂðÀ2 ­þ|GN@~å%NC®,i^8óžØÝÁ‚¸K‰¢g‡©\ñNƒkÿAòDËä §a:øßó#ŸÃR&øÇÝÁ‚&¤Æö(æ3¯á4D ;¯‘E¹þ㪳æÅøJá4Bj.å~i–Î5¥Ãi ªU«V`wñÓÁ‚×Ý°¨eÔég r,x虚@ssÚÁ‚çÓOâi|à €+;LÊDÐÕI1Ž?œ††’³Ö¸È¡ÄÉw~ÓŒ™_}.ƒ…®3•Jÿ ïØPªÂ~ÓˆnýPzjÎÓ¥X* Ҋɪ¹œòœŒÛ=Yû¦Ñˆ9èI9K„²fMC +–4ÓØ℘Ft’uìÒ¡² KC•*Ë•JCfe”†Âõ\ê’Žb‚¤>¤Á¿4@;T­*Áˆ†ÎY&¾Ec.OÕZÈtW4¨`Ñ8^Ë ²Àe;rK—i ]ˆ†Àä"44 O> ûϘ‘.[¡]VÕ%Ïe–u†›Ë\{†©´{ËeDy†Â@×à4âÎà^gˆLgÀËemç åŒew™å«lí2…a—IhÆnFq]9›£2ôša¶kF-ÕŒÁÒ –ЌŸl*gW@gÁÌ9p³<ù)©¯Œ3Ô`ö¹0€F¥†î'X—‘9Qòehð2¾s™z˨î2X-ÜËžY†Û]æa´\¶º28E$Vn %!¢Ê82}ÊÀQÄ{å2[ÉP\iuPFtëɘæ”ãd¹lØN»LSai{Y¯Ž ]28©d@K2†MèVB2²ø26Gs?_Æ|Md |™Õ!CK!ã\A-ÈÐåËVA†¡À¸À÷JËJü⺸q¤›/ :ĺ%¾ò/ƒ¸c„8#⺒13UÙªÐApŒÁCè +pŒ¿1œ +ÚÆ8Å$]·2öÜ|YYcÈtGc\sΙ: WŒ1ÆFÿ¹ýÅX±‹1ÒT Ég9Ë~ú9š,FÒ†y-«±ÃT6b°’bÜÅhÜÄè×2Ð%†VIŒ%GŒ .óD }b¤1`|Ó¿Ãh˜Ã¨ß2ê†ßZ6«aÐÝ C!†añ.ŒèÚ ¨Í4ZU¹#ntïŠ5E7PþK÷ã^Ïe¨ÉÄ\–‹ße¼‚— GA¼ÌïËè#›7ÂøŒ.æ2ò2ï=Ú— –—uµ¯C# J6/óRI¼lÂ&/3¿ €qý²óÖôö²;ŒÏ¾²•%ìÎì`†@ˆY¤Ÿ<"Œìc–u(3ªcftfÊh&›i¦±j¦ÿ½f°Ïf µ 7{?o–0ÂHœ%"g°)g ÚäoíëÌ~.ÂáΪ" )xÖ°ò̦¦gê¸g‰ühùÌ&ÂPî³f–4ð,Œ@kàÍ9htCÕâ<Áòì!{Ð? +­ÕËИ[è*àÐbDò+Z!«´¥ÑÌ-G–q‚b´\n¤aÛ’V4»u¬Qi9>–&žƒô.Í-ÂÐDLë‡Î4¹‡¸B>!(-d„6Wí8Ø}ái6ï´ÐÀ§Ñ ¨ÁaœBM}\©ç¶a[~:WHwìè ÆaHlyÑLöô`TxšÀ6xÊã`p¥šDƒaÞN>ƒQ9WÍM3aæ·Z.ƒ»«Mu{k +–j3•ÄÚî¬ÁgaiÍý®5,ÝÖ®¹\sg0¢Ñ5º Ò®Ue¼ÖÑMì5Q_ó©M[²6ÛdØŒ±%³bÓ!ƒ¡ÓºrÝBH3-Û”ÁxC56“Áb±‘àP#øˆÍg0†6¦«Œ°F°ÁÏ\4ƒ¹aËJq ØnT&N +fó3ºˆ/å †¤âþkd­-ÔØ` aT2zæNkZÃ6|ɈÍË«XlÅwAƒÁ¯±1ÁWˆ1HG¨§Áp¼±¨‹â×`ðhz›ç3>¤cc˜;ǃø%ñ³`h°`|[lø FÌc±Å³nëÛ_ãêc³<0´n‡ÀˆcÀ@`¬1€‘ê±Ò¿Q¬¿¸)v÷!ÙA"c¿Ù2?¿P-$aüâ`Ñûb¡}q¼ú闀ȆõLôE²9Ï|1‰Èð_øý^([X÷Âçl/ȱv­?õëÅûéÅ¿Ñ Ý;¶ø¼PeÏ BÇväyUR–«º ïMÇáôðÂïØÁ‹T‚P*g‘­ðy’-OÙ4u¬&ÛîÂÆvq¬løì"ÑØų×Å‹Ö„*›ÏºØT6Su1 G©.ÊãYP]ô6õÔ…+•Í:•û”MÔ…¥l¦ÊÅ«øA¿8=‰`ëã\8h.V³lÞ— ²)V.¼”Í›\˜'r±£läï¸Ø¾â(›u\Ø¡lÒ0Š‹ë‡ .æ ’Í$p± ɦü-&’Me¾Åxú¼å[Ø¥l¹ô/x‹Ýu‹Î-jÊ·Pó¶8=Ûb\µPÙH-ÙÊh&«ŠÚdÅòê`¯ü÷Š_¡Pû‰8½’H§Œ¹D+Ôÿ·ÉÏ,9A˜-•Íï±ÅzÙä†-ö3›Y_‹Iq6t-6ïlÖk-F€6Öb8ÑfÃj±ѦCµxL¤¯hZ$Ú´ˆ³K ¡¤´ ­s´ð&Zü!Ñ‚+¡Å + Å|ÏBÑÖpg”³ xïq³P„m2y¶S8[‚g6}ÏçÝM³Ñe#4Èg¶JP³àüf“{6³/ì²ÁBe‹jµãQξdãûµ¤f!IWŒÊæ§Yø,5Ë;–fQ'§Ž¥qšEœÊæ•YôÁ,Öë”mÖ_׸, T¶‹sYä°,R”]Y áS6XQÙD~e¡>Xy ²H?Yø²dq5²è˧le +ï ²%ÙÙ076ùd±v±U´Ánl%.¿d³i,nÁXÜW,xI¶×‰…¯d3‹X <,íÒCî°`ÉÃb]²E9“ Þañ~ûÚM65“ Ì Þÿ&[¾­láÃèòg+ºÊF’»É:&›û•É€H ò ‹·gi1g²•_Õƒðra±ó&›€¬²É/Û=Ë—Ía×`¶]N ³ úñŽüÉFoæ½°ØжG5yç‰`dóx‘ycX@MÈ ‘M•a!M¶"Ù8ÅeŠa‘‡²ÙÙPO6/„Y;ÙæM¶Tt8Ùæ ‹ñ%¯ +æN±l¤³‘y†E*ÃBc + ˆe+ýÉ–!–Íö0aqR³Ù ôœíÙ轓6‡¹nÙ‹ÛІò¯ˆ?Úý +ÓöÖW¸AmZâ+Ø`¿“¡Ž”><>yÖÖ±WlðÚÜóŠUlSÆ+†ßÙ&·tW¹tm³Þ‚ÍÁ›^]‘o¯Í­ý zosEöÛÉip«M\‘õ ð¾êÁ÷°P㶹[Á!·Ám[åöËVЙÛ˵‚ynËÔ +Ö¢ÛWZÁ9ÝÖ‡Ï@+¬ni7+d(^ÖeÅ¡Ýä’¬¸ÛçXÁëÝÞÄ +6x› ++0°â0Þ8¾ŠTyKÞU„ê¼ewÅå*¶¦·À­Bêz‹¦UT•Ul<óÁ*Z™†BØpU¯*ö§·jªB{«*rî¥TƒokQ…å›-EL}ÛU°”<OEŠù­¿©Èï·f¦"±¿Å.áÿ[¦RBÀuM©¸ª¤"ºkA*B.ïõ‚|=q\û¢ÂÂs&*\ Nu¨0>8û÷ñ7!¸Ó â…yô XEY8µŸÂ1œOá¯á”z +“祧0õá|ðž q63ÖßåvÞß9E¾`âØõ, N¡y7Åò²),ŦÀQSñ™ŠÃËà-Sœ˜8jLá%Lñ/šGî¥Ë¥xâX-8ÃRl!NÇJáŽ8ÕS +û'¥0Pœ¦'…-gjRXdq’%…¡¼‹3ßOz‡¢‘"ÉŒƒ"R¨q"…•>¤˜ã¢?ŠÀ;Žö£@ðqƒ…% '×Q…Ü'Ž‚Näf6 +Gn‚FÁJrû§ŒOOfßÚw“jQ¤r€­(°Rn&EáOåd¿T<&ÊrzErËIê]®!À&æÔ‚d.r‘9¾kæXM¿Œæä& +*k®umsê7Ç9gptNž´sZç ¯ç'Š+}îzôçì&ŠˆŽ§tœº3DŒ¡9Q`¿sDtµQYGïrõ‰0Ô'F^>!ƇëÀwŽð+`€¬£•VùDµ–O¼¢u$‘u¼Ö ë Ì'Þ<Ë'>Vš¬#X EÖÑÈ'þãd÷½Àãø„ +Z×v*È'¬B¹ðQ¤'q­ë“O ®Ë=]WñìÌåò:u8.é:Êëlÿó ·Îë‚?rE’ò Ø›m{P×ÑåöÒuCp]®oÝSÐu ~ºŽ´ž`>ÑÐußW>Áô­k—OX(Ì'gñ0ˆžù)&~î ¸®Iì‰Äó'GÔ%¸®NO$¦~ÊýÖÁë„[½ó„sǽ’9O4ÆF¯Ö9Ü<‘TÙ/Wërx­ Wšš'p–öo—ÔmžD×u6^ç@Åø¨ ìÀ.¢ z.Åè¶Ç±Cl¡zÀyÝ£í$P»à³vfŠíȬ4ßÎp;óy»fÜÙœ'Š»M“;çNü}m'n_w:…¡pwõÖŠ Þ…³‚`þ<±¥÷Ï×=Q«Ü™¹|×BO0Vï­?êq oßMµßEÇÿ/xTôÄ@è lŸÐœžHÃo^àé„w<ÄxÇCx‘Ï:ö]%!qT +ôÄQè‰Tù.‚48¿ì#^²‘b]Bð*z:W¹ó—,ëK(ÞT7³ÆLO¼Ÿ=îÄ'EO<’o…š'fv )Š§™'¬¥Ž­G<êÉF…<¡Éˆ·B.âU^2ON¸S~i»ZÛ‰˜ Ù‰ÞÏâùØ ÂH‘/÷²às"›ìDgúªŽI2.ÄK­ººçï²Ù‰ì/•doa±ÎÖè81V+Õ‰¢ôÒ‰‚Љ§˜á Xù"sNPš ÞŠèœà¦Ë^l^G ¹yo9'PÌ8'lœ Þe^wsB>…—ùA4IÍ}‡gNÈD¼ÂŽ2œx±â„|Å+ '4¯œ§ñb}JŽ—‰.Fm׫›PòšpÁÈ°M¸ +ÚÄ-‚_=6UO˜Óï)oÕš8ËsÒšpsyƬ æíUMðʼ1ÔOóLœ›w?š`ËyŸ¡ æÎ[úLð=o 3Á 7Î™à ½«™à +xš °çkBFNê_½:¬ŒÇ*Bd"_e@BÄ›|iDv•Ïƒzù^u~æó†ƒ`®Ô3 ãƒ1绽 ŒÅÿ‰D¸ +bXDéÍÇ™ „F‚xA\ô×€¼wÿµÜî¡<ó=~ Žp>l]Î×ÎÇÿ?ÀÄù@:9_‡©ùÜÖ5Ÿ8:_Ú9Þï˜Ï·’û| !¡|gŸoÙE‚°.ôõç:$ßÿ¯:àv{®>íâdúFˆKQßs«ú¬ +û=wöù¦~öÁ¹@0`ûÔiÂ}b2u‘õîó¾™-L)ŸóûôHÿ¹ÁïjpÚ‡Ÿ5€î¿‚ñÛ¹£Ô¡Ío‹gȳ°âµ:ñ8Ø/ï«ý®‰î—¹ü~][ ø€Ü>äîžùôáŽþÄcýZûÓËåá¾?D‡Þì%ä/²BÿÆì×гÿaÖë?DŠþ§|‡üÓbäZ \–À°ubå,†!@µUV-Pµà.KI´`¶qºäjW FE@—NÀðp¬<0Åi@×fÈš5.?€ª.) +º +$œ¨°@œížY žÄÒ +Ïö´è´@˜ê ÔY ˜Ñ5pÿ0‡Ê½¬éÆôd°Íy-3ïR.6‚gˆ#h›g ü3ƒž”gs0 $U ˆ™@üC úÁT„Â#Ø'D¢þ)Aý„0@  îñÜ#Hó¸Íøpv5¨m*FîBoÞ@¼H¨WgüƒtÂ?ìöý!ðÁŽ:å¨Óvf?°Výp;‚ý6 &Û®22œ(=~ø ?×Ä1ií¤FìGpZû0A‚‚؇2Ì%-õA^Ò‡¦@JH7@.N—|æø$È"™ jˆ év|C#Á½{`ÚÀmî{¹‡+nŽÓ&eØ öðX‚ÀõW©¯p&Æð¢¦b˜;=èWz8Aðâ9í+zP òè¯rVâyð*Azólæá°ËC>•­/yx)AyȪ8ÇŠ~þKa<\p ñÄGu nV Ú»ƒBå»ÑËXÀÃ4¸üR…X‘e“íÀWÞwût‡cÊ\wXT‚ ÜS‚ÿÛ4•àË®¸gÈ¥<Îã.b²CÉíÄ€¤«'×WU¹æ¦m¸dJ¶:8H¢ê ž%ÁØ›–³ÏÒ$¸\ 9xü8I°lÙô䓇ÝeÅi…Å’°à ë‚£G’`4KÅtÎév, jà®97 R«Ã!?ƒVèG‚–²›ÀÏF“fù+¼$ø÷³% >µ#EÁ|Bµ›‘àåª%?P,ÔÖ ´KpøLZ¹Ò–”SÁh)½î ]¼ñ9"Á²!ÁÀ Aœ}&‡ægÄ¥Ã7(¦#8Ê<ÁaŸÌD’¢àé#˜O* â E‚*¥!ÁÛ4 ÁQp0ú€ 8|l ”Ý'CŠ~ÃïïÎd÷ 9l߆@‚ÑKCMŽÜè¾!„ƾCŽ5»ñLÝû†Û7¤ $H%Z{Îþ¤Âõ=fßPHà°}-'ö ió‡µo9ïReaÏ·Û$=‚ä¾WÆEœ9ì 8f .A/sÞ7dÔ„#¸­¬/P ¹o@Q¥Š|l{Âo€ëTzGн9˜8~ƒbŽà¿´ñæ74}R:¦‘ß ’óÌAáø<ñÃ7hÀ7|;‚È×ZA…P<‚|œ7„Ýãæ7o "Þ0ÛÝ`”Ý0$uƒºá{©.7´ì¸!.Ü@äÛ qÎm˜‚ÅUÛ`™ 2iJ J° ª‹‡—º½Úà¯NÜüE(ž Ë™ *Ãñ†‘{Š PÙè}TB6LÛý°Ç`CݯAÐô¾Hë‚Ä5œ)\ƒqp ص†%$(ÈYƒz©AŒ5x­b YĘ +y™"ÁHÕ G‚»uÂ|©aï¨!£‹ÿ¬«H%§y$(¥iKF +0 D$8\i°ë¤A+Ò`Ì£¡‘"F‚,ËJ’ @FCOŒ†‚ÑpýE“Vääï È4x h¸ó3ÄÂgøî<Ê;Ã- RTgˆ¡cæ Îá +“`$œAçI°]W?a38§ÖzfhWfH1f@t3t{ÞÒËPe\†Zý+ÃK« ©¦ ¢ Ô;6™ ñl«c‚ºªOÉ ßc„àØÎ{öƒv‘ÁsÈ0W¡ß1ÁÄI‡&ȜǓŽÁr&p Ù6†añƒÒÃå¶2Z14h‚HŠÝÄ°CãH x&H  eÁ¼L±Ãàg‚k†Å¶ —nW'Mð2=Á~ma¸à˜ñå§ “ óöUòb¶o4ÁÝ…gÉ„» C~®ž5laHËZ’‹º¸2 ApuÌÛÂÀˆ'XIaО`‰$ Iè”GÎœ2Âè š ë„ †ý­ à^¸'H7âÓ'x ƒUèœr‚¡ÇC«ÃAìÄ@ .Ö¼;,`ÈÀ0}§÷íñÄèÒßT¬/<Í*ˆ/ô=D?|Aññoýð…¬‡Bó òñÄŸ`†)2Ÿ|‚òŸ ¡ñ…Á¯·ð ¾ZŽ/¸‚‰|!þ½0ó n“ûÃ%U<ÁUŽ`{ƒ`ä¡ö|×Ë÷<Á*;Áпn{6_&oÖ k²Û \7´íUÕK((ìõP|RBA>4÷‚èÍŽ°¨{Á@19æ !÷X(Èp(è^Ø7# +b™â’ؽÀ¿fu/Twèûî^€t/Ž½ðÿ¯P/˜þ¼0·¼ l¼°„ð‚ß…GdÏ]¡vá »`b° ¦Z`ÚÕÍéBsÑ…FQ<Qp×™ F|“˜(H¯\ˆS!!NF.,̨.<ü[€ì-œn·°‘[0Ú¶Ð]ÚXÑF?h¾XÿZx¢ ÛÖ‚( +¦Õ‚(X]R º#Ó7-ˆ$-ìÚêZDÁùε¢àÙYàDÁÙf52 Ķ,|R„L²Ð<Ü…ÝÅB‘ }r +òµ‚Â?mž°`ªˆFb-¨ ÁvV°-µ‚ž:+hsÔ„G„Åí© D¬ .¬L L¤šÀGbK%¯HMÐ…Sµ¿¾‚üÔăét …Í$ö™j‚ˆãq-˜¾Ô„ÊSNÌW¨&ÀM&^¡ùP¨9h‚òœ a&`Ó‚ü— DgZ¶’1—€,™PK¿lÇ°üî‘ ÞQ-xqb‹L ÍúûÈó"pY ª¢îþïËÆÙÊ›Z°¾>‡0nA–d“úHÉ®œzÏjr©[î'dB™pm}õœ2»ø¼V™€O™peÂLÞ‚Ô}\Ð5¹ LX\ðkÔà‚‹LœL@2á3R&,éQOÙTT&È'*£*E —à ’åñî<¸ Ô}eÂb´zQq qA2ÁÜ_{[¹` ¥e_JV‚„1Á÷\0”TÒ âuÁQZÂX-ŠÒþŠõj„ì¼]ü×%@ÛCÖÙ¦ âh—à†•¼ì:öÕºB=Á],¼$®6Ì•€à–¼¼¡S–Àüj«›.È‘nêÁ”Nè–@d}]PØ`¸ƒ]p9XÂ|XBYpý•À +/h¿Šî.(äBí‚v•°+•Póº`Ñú½—ºm J ×¢„` JÈó$ ^N¡.ˆÓ$Ä,ƒIˆ–„Ó' OF°]=Î#áODw$h2öœ"A‘P¼0\ã›”u ¸îG¸º ËGÈ +¢ø.…ë=ÂDç4»`zGP6ê‚9?AŠ•0‘V‚.8ržÒƒFHˆy!ìÀ #0ç‹0YÔ\4tæ|̸º¨væÄO©/’¶T„uI@j]uÁ*ï!i‰0E’DÀ87"LW»àÌÒæ‹s¡T//ò`ðwÿÍ!´tCè¿ PC€ä~$éÜ}3€¢W†P~êu!0ÁÇBðQ!8æ)嬞üÖ{O.+!èÌa„À­@û¥¼Vž¢˜ÂPw!^/W#‚"Êdû‚€ bt3RËaª%´È%‰¸Y’/h ¬„ä+& ¡ß,ývýÁYâDÙvèFŽ_ö}PXû 1ëáèLóÁ§‘|Ì¢8ì£N +$¾¾ Þ¼ ظA±·A˜¶bV@^0mà°jÅœ<ŒØÀúk`½]ƒ§­A¸–J8Ârl4à%5XÔ ý¦ÁêÒ× Ê’“âh0Ç z Þ/4Øh\HÄIämà ,ÅáΟ‹‘_Á_ÎÀj$7¹œUº" Y‘†Ë@ß2ˆàWàJðó‚['>%hèëƒ Tî1 (ÇÀJc0c`øŠA³ƒuñÒS¢v/¨3 v* ¾ a`bÐ Æ ØG)p‚õ%¾UÊ^P½~_p(ƒµ(ù‚€¼ æ‘ô‚Ǽà”À€}[KØìzåm#¸®ÿ/0Ú/Úìô‚úe/Xù™‘/p+°^ðªÆ¸†ƒ­çºøF4á ÌTÑÙ +Y¨\И –¾``â 4l‹7¾€eÈ´H òÄ|´å ›/phà.ZÐ'_p£"ÝŒ/07„º<×¼êµn$¾ ¾àâE|Á á fðÞ/øoþ-S&¼Šta2OGã‚_0.èË +_ W|€¸`°(•žDž¥ÌóµñÓ½Š¡ÅáÉÏ…š £Õ¢˜™Ò5´|³•i¥îžŒqåkƒøH3^€OõŸï/ÐniªI0 ´Qñ:¤#!I`5´0ÎÆÐQÊ´ 8NŠ°º [¡¿ÌüqµE|µ†?Izßog.x½ûÀ} ¼éë)!~Éá‚ëHè=˜°±/Bc~ ð=´G2[,‹»XI(ýƒæ_ÂmA.C[3_D<#³(ESYër“ kÁY2Ç5Õ Uø]ÕCÿ[`iº$¾´ `B "Ëq ñ ã ·ÝgüîÒ©ÿYµ“í²¶áb¨ûe¦ÿ 7NÑR…0LGC²`|@þÓ˜|hµE´ ë£@b¼•AY‹çþ¯{²„²º@vêvAhq€¹ +ïàŠÏÆ] øh/Qå +RÅŽ& +#ˆ +÷Õ7ljk¸à™7=å­½Rè),)ÊSV€õcR¸¤¦ˆQõZÁ +dTF >‘èÑ{qw˜C1Ü+y‡± +¸W +B}ÚbR¦¬ ÖqcLL›¬.Á§‚ô’Ec›®º²m©ç7›Z% P nTi¼±kD?Ó¢^u%Tá‚@I÷deæâú¨Hª‰¨ §à4ò) ÇâOÁð•‚g¦—õÊ«N +üòاÇï0 +ûJýz¤HÊ®Ò%R€CŠËåBCP¨üwàÞ¤³ó!”9V©J + *£>Ç’3EHxŸÊKëȹš05 QàÞr܆°ª ÓG](ðêÆAۙߩà¨JF"ŽZ”°«¼µ¦‰?ÁF™>ÁnêL1â~Èÿ7CA]O;lšˆtö9~:=Awš"Á°|Ò.<#A1mûF¡ +¥ø¶¨ñë‘|†™ ¦ŽÎ ®ì²JSɹp{q‚]ñ—´+r ò&0Åé¬ÒPÃ,îûjgô{4~UMP¡~ŽTFLæÐjå 3AĦe!L=&ø‘äÑÛ6L ñä^ Üèu¢IèÈO—À·ŒP+QK°Ù(;!„%0ö(7Ó™‹2÷± ìÓ»VôÜñ…DŽ¥­w*¼ò‘b„`IåxÓѲ6GŠ$©£›(ÚnÛh–š¦Dk¾´“ +,c¸L‚í;ò·"çç)Àz.hàT¬ÅUV ¸_Fõ‘@Ö}¤dtÓ.ÕŒE(/ª:Ó{GˆYCü3G&áÜ ö—¬-q ûÌS ç ÌËú—-LÎ}\o¯âXªw‘Gð~w‡œU<‚Sèð·Ò°˜¦GnçA>.ô½À¿ ܇²*^`–;\A€vóœfÛ—Eµê +s–†ö…¾V['ðÌd­÷aãÀ0©êQo¢FsBh]"ëx*Ê0hŒE€ +FÃi æI¤¶Nœ"Hîq1E0Y©iÎ/lgìä¡ya€‚ÑÌYœ…µ~ub=ž¤¯î­¾T:p€0þ™"‚´«b–7}ž ßY:hIx¬iÞÈç sö°™‚r•ßOíOøbd²Ú…*MQòyïA‰ÀÚfÔX¿Ž¥Zt ¥Ix• ëìÕ3ìWù—;A¬Ê¿´¬˜ÀÞ™Kj÷™ 1ħÔm#„ ÄÑ9VP@9<–‚ʾiôÛ´<…Q_À äuÕoäXž 8*ÉÕÁ³NÂtÕݨ@` 9boe:–e@ßÚ%¨e—FäUAbÍ©êÏ +ÎâPgŹø/M“š! ¥ïc‰~ f=5oBS‹@ö} iP^ðÿ»J•–Ö×|NÊè]Õ|@;á.þMq>¤°“´9àAéú$€A…÷À^’K‹àÑtÌÐ0*Ô;º¬õÀËäªÄÇ–ž^…=’EÌÛaÓ‘3™â†Ò‘œöP4ò€rqÓT + @MMãÃt/~…Ê´QÅï#ñø;$Š–z_w Ež\d&<ó~ÞÛîòÀ Ùá=mÿT8u€G¥ÉQš:þêlùëÁòp®O:¥›å OÌ ·šº@´w.€zç!*giX—KöY]~è[> 3Ö°vccbvµ«S1„ëÎÃÏŸ‹buæϹÛõ©W|¸…ÑÊ⇛t"82rê‘燺 ˜( •S¥춶îÀwòFk Ûb)¥;°ÁÀuÈ.”u¢Ådè¿ÏJ“î@fq#…’ÑW¾EHw ¢”› ?"ä<¤úè Çéü_ˆæ€ªáɴæ@È>rP×ÃFÌH:Hú²=DðY®¼ÇÝņ2_hcªz£—78“¶Œ¡c “æÄôæîA÷U&1°biÊ£)Âòý^ÕÜaªwŒ)ĨìàªÓ+’™ÎPôã—ÅJ8åÉmŒêHLÆ·s’¨{1–»iš;0YÍhp î±r~+8 „ðœ‚Ü‹’ô¦ª»€–z*¸‘; 5k?™É7øËÀÏUÙ—ËtÜ ú@T×϶jÍÓ w€”ÛA! ÁáäÃíø έHÀ'Þ–Ìupd©.õrØ*Ü´v1 WµPFso,uÙîãúí—Wï3R¦¨ÍÞ:;æt1»@S¦·—´vƒ"ʃVUAY<ñD€èúûÛYMsÛpux¤(KÙTI!0ûìašÍÃ(‹—·¼‹Þ^Å Ãõí@ð"bJ•±ƒ_…'Lcl}Ü·«\Š—íIRÉëÉî ÕuJ7pPmƒ™WZÊÍg Ìw1ö›tL÷%Ü1‚%O°»í"[>çù:UågˆÃq.yS7’øU„”; X•]~hqÃ8êT”e æ˜þG2SVz8wàÙ¨ÕÏt¡­÷ÑßI1»ïweÓSó›XyÀ*û,Ì<82°r€Ê(%õÜÈ¡B´;[%Û}÷hå"æÜlz¡&vƧ3íínš;~V¸ê£|âh›;ÀQé¨ÀùLu²¡ a~Þæ±Ò²LÃÇÑ0®¦bXoEçøëˆôIÄ:À´\¾?8þv98Œ(DÚk:¦‰UF¦(ãT;¦O-Ý;ÿ-°‘½+ßÑtÐJôªÂ-U’spC×4(ÒŒlËì¶â`«t6SžCs: m…N858Ъ=H&…IÏ— Ó+g(FQ=N@ä'|é…•3m®9è¬qq°)< +6r:pêÞ%£†¯Î½¼c:°â½Ÿ›2÷&ï<Xm|TÈÕWS3xÿ_c3›Ê^çöëÒ¹´õ;€G"-xuHàû»mÀåˆÙÿéøe1E‘X:ð©´ÌÉäòñ,ݤÁU4å&Ã$sÅt Á3RÃU +7ö%ŠU¦b¹Dwà¡NA©£>2ý¥ðªÈ2: µ%ï[f8Óåþ¤AL¶›Â;tû'À)b +”bj«+xyÞt@’Uûš”‰ô0;°"G¯ +¾Â3ž·KÀWÓɾÜ8ÑŒÁu4½äqTÜñÚD!ßlÓ)SªŒ©´FH܈«>s±)ïx^ EÏ +¨é7‰EYL‰ ‹¿F zõ;Ø~kÉmÅÒÅ-à~Íé@JTõm2e2äWŸ‡ß‘ññm*)ѱç¤hA†t Ë|}Ý%ž~‡ mñn/OÁJ¿5ºÇU!Êé@"(ç„oFwsöý/KØNä©áw¿þi;¦w’¦ +~‰~ÉbÓ£øV +Íâ¹ÅB¹éÀêÖ”Í1¼šžVHëM8 5 f~š!p'S)¬:ýƒôl~êSñ¼ô¾é ˜ö- ›Œ±m÷-^ÅÀ .énήƒ|fàÖÆ’HkªÁ)k• ×Þ§*¤YЃõ¸ëé@ü&ˆJÔ“w7É~”ßИˆÂG}—Ôù,Ѓ|OØP¤JœgF%…:ÃYS*_.ú¨93·¡„þ—lÊù‚›dPjªu”ÒØR/uàá@‹+ ½Í© ŽÝRÊj… Ëì›ïO”0…Œ:ðÐF—©=¬$TfKnrñ»k¹Yé¢`7"Ô5¨¸{ëà +ÌzzÔ"1ø›âïn€±nNþX@lIPZ^D=m t`i9¹‰3¢÷s@ñ¦Æé–üý‘Kñµ9¤ß4^ÊçÉu®¦å@’2e9A˜év’NaºJMÝ#º‘ÁóvôÐM=‚Æ®d× Yæy¬‰ö+R< %pñÙ@–ÄúZ q£þ LåÝ—Û +°70”Ø[Aþ!S»jMü{,4]°ië“àÓñìjr—¸mDr€¥¸º.7jÒ„6°áDl?ì& G6 ÙKW€ø5þÀQFÅ FÙòŸ&ÃOäs«W(øð„Ü:‰Ô4o@>²ûí›Âº¼:BGÄFBÒ@ºX†FÈ–»û Xåÿ*Çdø®Yû%<Á+³Œ&C]RO‘0–h€ïPW:C «=™8’h€ß©–Ëì.pæÐ |ï `Kˆ›²€;RŽ“á¿jË àÓ‡ÑtÜY.\š¤¥^€ÓÍòËž¤O7¿³å +Œˆ” ty™“}è2Ȉ««££,ëæ!莓]`Î’|hcÀFY¸N¶**|Eh10µ˜ÖWqŽ©Wô0²ø¯ãˆ ¿åm I 4IîˆÐ®3Ò…*S»¾ÓšŠ:Kp \|Ó„j{ý½8Ui0Ýâ™ q”h´}se¬l{žv-ì4 ¬TŸ»?ùªØ|ö· }p$. ÐßœÀŒ.à~l8•ô–rgá܇iÂÚ+Üo[Zɤ—af áü`[4@RÑOTµxΉ“€•0굇Nìê6 àAÑî„ØËÚuš°†þn3f"v±ˆ, 6È[çÊ4¤cm•/köŒãRu…Et£¤,€>̪O—ÜÔ +$Š…cèëD® ô>¬@o¦|<¨ÿ6ïO°¡a9Þp‰×Ç,Ûi¡AúwÐŒ‹ž\Qî•ôôešUMšüZtDè x*DA¢™6w)¨´C +Dö–ÃU÷¾‹«£€ +ò)ìÇ»°0ê^²vsœÒ5(À,uù¬#<ôÌ÷€~#^IŠ¬jz˜pY P8a]'P»<¥šb˜]¬óšqƒ‹ÐSQ»QÐ&àf˜C¹àê#Q¦°iÐì7ª©ïjÈ*Ú~2 $³ûÃú_àªá°"YÔ`%¦1²(·û¡JVƒº9˜mÂLx”ÀñIŠo" I  ᵴʾŀu$št fs}¤Ú`Üô2õkõû”,3pÔ3"‡™A«æ0A$P-FÍŠ {¯ò‘4, HØ¥Œ´,zÊ(pkððŽ‹ö¬=Ê”¤LIJ3%ƒ—[X^A¥™þžþ5v?õ¾_nßyé_J®PtJ­øÝtSâw#›w2´ˆš±á‡å–Ç])îÇ[¾£¿¸RÑŸbWó"åD…é+—àËMxX‹WŠŒòJ4sUDŽ¤Fr“^E»›evs#!ÜîÇ}Aîõ*~}´Ò·z=Öð9Ò+z-;&¹éÂÉHUw‘¨È WŠm[Qþ^ç:D¯h×ʬ6‘æÈ1öº¬1Ê'»Yù†–-¢®“ÕÕX[±#³C4;S™®µâ9aÆ(TˆÓ¿œ—Ú‰%¡ÊO)Ëý¾Ä!ùõråø¬þõk{^å”b>c<1wuŽKc:wžAœÏw¾SŠo‚¡‡15-Ñ/S!zÉ´H&†è—™Î¬£I 5æï”ÕÅbΡ–†©Y¦†þ· æu¡v3gÛXܦêKqh+iVõsTïÜÅFÝüÆÜ&ILˆ>E÷•)ˆ^2­þ¡ñ%æqîU‰©5#~7µ9«‹q(F±ÍËôi§©}hZ‘DZp¸ ³Œ—F~ëõÕ7ׯ¦“}ˆ‹T ÆV'1,aÚ:n&ˆÄ‘G\Ã&͘U&uÓ%³ÙmÖìÆQ¬Nºf>urkºR#Ê› ª¿dM}5×qb¸Vá© +NÐÊñ(äZd.± +!ÙO‰Ê_A¤¯5N%†>ÿÞúóÚÛ½e¿Þ¡×ŒÚu¶Ï¹3ïøS!C¢—®_’ +=†þ¿mÐÄêeëÓFÜ¢TæÕoÖ¬L°+Ug6&Æ>ÙXiLœÿ¬ù÷ŸiOzZ5RŠž¼\X×é¥Ó‰þ¯¥ÿk¿vAž«;GM%oKúöCµg~™Òïögî£þÕô˜F;û)Çô³¯ý_Qô,"«Õƒ%»q“Sûä'êC*ÿPÏc$šv:ÖÿLM7ƒ›GZ Wó>ü0Ve™oú‘õ {%5ªº‹uËœè3Ž¹Îà*)ñ¥˜˜•¥¢«“%©™ËOSÓ‘Œ{KV.AÊ…¤ÿWb1T.Ϻ»Þu\ïÚ¹9Fäk[eµY‹/Þõ®þê–3ò¿#µÿÚN9©KþAe)/bk²¿§²X0dªçêbæŸN±Ù“"ö÷ôk…2 ›^Gœ.2RòN™ÆÄ°öùCâD¨s¡®Dˆ¤•IqAz•¢0\d€ ’€6yŠÊ«t¥¯}šù&c´ÀRÜàÜ|JáJîñ™[Ž í^°~­\îäl¥M $”ŸŠQ­e ðŸxƒ½ƒRñÍúuæa{•œƒk—@åe)™¯,Üö<2“£ÏvL›9ëoki¬F’Ê +IÈþ»nº›ƒ>l#0•#Mó„Kh@2Ù‡q{Èó˽w| ¸ò¦†žlƒ î[É·¦ôXµ ¼˜öëÐK$ +Î{}½(NÛq/-Â_L¥(—|²¤cK€3_&ÉW¬eªçîxïc Æ d mH(#½ÇÍuF‰<>Qôâ’1Šmh\BO>Lµ©©kÕ@e +ˆý^¹èþÅ L晥<øUv  ¡aùåÍê5ÀÛÕTáú|¸†ÐÞHäXèÓ—2ñ|¼¹çöÅаZlìé<Ò–}-0¦û,[xÖË.^Ê»®ìê1›Jb€”ø¨g1µºÊæ–kúq¦«wŒ›0 ád柫-lM +é= ‡Ì<È]îiñS9ÌcEN.Hö՘Ѳ]³ŽÅi‡^·3D É^/ÜRW¯9ŒÏÈ6HìKÈSxaTL¦Õ¨ý;j¨©¢H´ß†Pj;ô¤G-1ÊÛ cê,¢æ>‘¼}É0ÒãÓjþžPSüÍ©…h¼Ò5Û½!­“Þ8i|P‰ΩVÓ£OM3eó: gõ5I»ŸßLh=&9Àú Üa§ùëÇu7Üeg´])Plª‘Wûì`ÓÊ<ªtöÂÕ>[d9íaœÃ–0[ÙÝ,›ÏÕyŠJvm=.7ÖIŠ½41ûÝóò¬â?‹%›Ešh9ˆ”É,Kžš´"¤ô±¦±×j’¯Ñ8’œþ'š +R“º„o.¯S}úuMa$c»Åβü,S¼ûÈ´‘,‹oê‘ ?âbZ`€‡§\û›Æ(§ó¸ð×v¡‡ÔÎÓ·î*»d˜ÂÔ¯¦°š±8o„Dl§ýè¡°!†LaRvdžT­‡û©®©¸[ÑÓ0÷ö½cí<Ú¶ŸŠ ì¶8F! #}u \²gRlèÛ>$¼l­Yä(êZ®¨µ I²yîb×£ÑiVGerÝí‚ãØ€ÌÀO¯‚’N°èvç™™B‹!´¦›:GÎL +KÇh¯¼Ò°?oǧ;&$ÍÿjàÝr¹+#›"c?D˜Ì_ÐaCâû€†`´¤.ê`gÇø{—ñC¤q*™GÇTì¾[j#E22×e؆<í!PÞ<Û…¿Èƒ~ß›%Qmx¾7lÇ„è¦ éègYh&øÇî"¤27î;Z½o€ßeÒ£-ª¹Z¥ÏiŒ»÷ó6n–5õ…^jÔíž–WªÀtÚXáD Ъq•”Ÿû™;=3[ÿiÁTÖhlsLã ¼³jc-°ñÃó€ïVÎ9Àf + ~Ä‹tž?Š4¸æ´-9ÝMà,˧¶­ô$ŠBLëã.ÜÍ%8~Ù3<5^‹Ä¶jøÛ0Þ“(œ#kÍË¿kóÓ¤ »ºäôBTÔ†3f¾Yù.@x…¬Û&} ZÄï'!²ŸÙÀ&‡%I DM’"ð¤F¨tÁµ‚Ô»q?³ µ΢ªw¥Z€Ù˜å¢(SƒN‰?(K 3gª3.’Z}+p¦PÎ%(»±wh·ÅZx‚&šô,Ãa’HHYÖ[.Þl7A—OæÔ¢OÅ‹m¡Û‰ÈÔ—‰Æô(àmçiÎx³Ú^Z²Ñ—R‹ðcƒ D7šJ‹ºµ<¦ÑÙr?(þ`ÔpI¸ß$–®*/üeA>ñ€¨x>rÁÛ»!t%9CÈÇ|,DªÔMŸU‚Ó…ÿ=L~Êe_tTK›n~Ò±Í}éÂ)È8ÃßâQàsBuáô0™P½ 4S´ -áw2/LÒú7~2OF/Ó†Kj³ÉÃú—2íæÇüÅ2qó2õü µûOä‡èŒ²ßVÊJ®S61à9~¡Æˆ|¼«¬€^O–ÊeÙBOË€×-ÛsY’¡¼Ëv…b\¨ae|ÙÙäÝ£:fÍ*Ô( ˜kSaf71PÓˆp̺®Õ^ÒÇ…î„ÄL/b¶AÌ8C Yùö÷a&ÆLû°B [çÃì1jPXŠ¡Á†Ž³Œ¯lh¬fYf0{*¸ÎÅg,ó2ÔàjŒdFëJÄjT<1f´‡ÌœÂ˜)=Ì|”mµeÝPRïå‡Ùûj‘eØX–w˜Ír/f'AfœUÊ,i‰Ì.VŒÌ²rEfí^@72WÇä—•ðPc±pŶ7’Ĥn}N¤XË‘Yö`Gè82ëÑ+ɬ²eVx$3V +-³ÈD m—èT¢³Q?n˜„Ys‘€Ùg&PJ˜_v òdCKÔØfNQÃwã ø[Ô §0Ã5bÂL&Ùœ…®0j°fèE òÕ¾€0úI $˜•ëˆÙ›³iKc–_%`c&U•´¨Ñ£ºAÛrÌÚ‹#t%³á³v‹0jñ;fz«%ÃFFÇÌXMf¬è̦dÆ33wS +eX¸Œ.Šr1³á:³ÔÄŒ¼ŠpŠ¤™–¤f>¶fxÍäÙ6KÖn¦ýo&Sgž‘3“OhÎÎŽÎp[6Óu(k:W}g& Î3uf=ÃeÔÈ%>cjԬϨÑÏúº6¥-à?ÐÖ0hÛ¼”yõÙS†6H ãfhoK9Šürý,h£Æ7$DŽ¹J¸hötbG/4jG ¤ñU ÇIšeœ4ÏCf¤ùÊ-ͧ€i,’iô¥iG ¿MãNæ\VNë5jìÜi§Ç§2j`2£eƒR×EoÔ€1Ó(ŽŽ7%H7jpµÇŒÚÃDj|Vj×ijã5úÔ€Tk,dódd5y!òpF9jÔ„ÒîCð!UÖ¤N«æ'fë¨ÁçÐJ`¤ZƒÜwrÔ8³5'é­Y¢q-ñsÍÏõv€^¨^£G¾ÖoÔØn¿fí– ¶ùçXØñas'[[îÂÔ±ùoÈÖqÔÀhwV«l=jÐ~ïÎŒ‰Ï晬hW”Í&ÝiïŽuÔ+Ûì·5~“M5jDJd›ºoÔøßÚ±Yel®1ã9jtØŽ­lÚ(c+©Üpº`!Í^*Rlʃb»öÛôul9L-½îÎ7jHˆl$ ¸7ÙÄ-n¬lñfeÔ¸_¶@¶ÒJÙ0jÄ0[|À­EÜ+³©O€ÝE "Ɖ·te«YÙnñ—íCh¶¶°¨QÕž2ΦÓå¢FNã¾Ù„/$À `Ô貑œ«èfó»”6›0jë£Él¼€¢Ê3jè2Ûç Él-5D•AS¶¼I€2[1M"t³±âÎFïh‹ê âÿlq?Ûx†N>“ldcågóHóLÁç‹J +ÅRÆ&ÞvÐÆãž?>[^¹gûŠží7a)…œÍªQƒaÎfÊÙ¬Çr‡Ï¦¨A +}¶["¤ég{°ÎÖ1;›Ë¨!" ùis³Ôí¿ÒÉl?›D´q:Òƽ›¶õPg£†Pª¶I³6à¨aÈÚrlÔ€*­ÍhÔ(i~VÛò!]F õy­m/[[Ò¨ñRk#Çj µñ5m[DÚWÊww£†ð +É~³Œ´•÷F V¦M™„´ñ_j|Wp¸w¤Q#Yï}ûu¶°oC!ì÷³1—œ‡¶Ý¨qµÔÀÝÏS£†‡QŸ-»x£Fõ³}ÑB¸FÞûlëŒcDúÙPhÔP’Q#÷Ùƶ7 Ý’Å˪¨MŸ-Ζ;£Æ¤s6G5?Ì6?5TJ,C8a„ÙÜ©f 3jÐaÕœ-fèlµ¬QCXºŠ}å:#³³©}èíÞÎVÿRaL«ŠüÈÍ;› ­_Ô¨À4ÂüÙŸw¶ÂhQCÿ+jÄâ*! +…Üv6 0Bøí¬¶Ö¢ÆF´ù>mU´ýÒ¹h&hÃ)öÎWÔXVSÔW*a$ ¸’àlÄÞl¼#Q£wYéf+#j¨ygsÍ¿'up¢ѳ¡FQž$j¸DÔxÀáÙnïlß6ðl™D Ù#ª”uLÔ0@[0‚¶ì]´=,Q£7K‚¶VçÙ. +ÚmäKi+LÔ˜Nk0¦¶’kƒ)j˜¹¶Â6›W¶ÉÕÔ¶Ä@·Í¬Ímt¾Ûȉ,ø6˜¢›‰€ê ·pÓ÷‚!(nãK‹ø·ÇÐÉÍ'˜› åÜô„n¹/ÝPOu£S×Í£ÙÍœ¨±ò·[žx·&E &¸€·Í+Þ@—¼ÁøæÍ#é _ëm¤Œ¶7í¿79ÅñM3ô>stream +ĸN 88’‚Ó„ rypí.Âqï;ᆅ.ãîͨA£†“Cû§„#¢‡Ãs]Ôà3âè¬ÄùÅ÷\§8.XÔ¢†o.n†„qšÂ‹NÐÎ|Ñ0jÄ2î¾SaÜÓ‹œW.ÎM5WOÞ+nTÔØåz_ ó‚•`œYʸ̢Lã6Ÿ7f9®@;Î÷³ÇÃ÷›/ÕÆ!r•‘Ó'’;‹ôôêŠÌÒä<õäÈÙ(·ö”Ó+çsa9vQƒ¡–ã2]ð#O ~'ŸÎc®fQ„™ghn·ª¹Éfs6ÞüÝÜÛĹЋ@snjQ­s òîö‹n€‹N½]7Q)ýÑe?Ò‘$j襛5"Yº•ÓÉÆšŽFQƒ‘N‡*jð¶íÓáaêˆc¨Ã0Ôè?ꆘ:Í@uâSuß'«»eW×"±.áÍ:Ÿ¡†ˆµÜ·Žj¨fÛ [؉êÿj¬âu¤'ØÅPW…øÂÔëMªåuO×ñOêºÉrqø]×~ˆº®AÔ¥ëèà:框tÝíÛ‰¨ad/¯»w QCS-xÜ1]7|yõ·‰4HÔÐû: y´÷uNò:jD –5å{^7h’×U5¢&Ÿ×ewWÔøG½úºgº»ö³ÀVS° /…ÝÚ‰]Æñ۠TOì†cÇïÛ±kªÍ‘8-šÖ :vÓI¨2€(v9N좈ͅ¾Vq´¢ñ¥/ÿ »õJìzú!jhXŽÏ"j´­*QÃvºˆüº ¢†¹¶Í#j,ÅIag5°k¨ _8awŽiPD §ÄFÔ0Y¢Æ—CòŒÏ¥‰/w@üÿu7°syY¨ELð »ñŠ‰‹„ÎÑ®¨á‘ØAvc·d‡¯eWc<òǸ…´Ë¦ƒÚ¡â¬cÇ6âv0ÞN‡×ù¨dÜ©pä®AsGÿè.Œ¬»¢D»;¾w÷±Œw?ï,&jQŸ\ +LúÔg¸[ßnÏà=¢Bî j\½;õ¡È|}1ÏÒÙ»àuáf +Ï&ex6xxfflâÙdây\â‘-”xWih K¼Ü˜x®M_ÔRiàÙ9–æ‘CãɉãU_2vY‡çS±qåBÔã…1¤Äì<ôâñ*,§Ž¯™÷x4œ¹ÇñJ(p$4p¨áx’¾ñÜxüb @h¨¡Hîm¼êP#ÊIf†%YÔD„—ŠGX-ñŒð¼ +5èu<*©êî“y„_oa‚“†¡ÆJr 5¢|y³ûE¼ð·‡ÿÒ$â-ÚPC’5N¤^ùtx¿7Drˆ§ýä¡Æ…9ˆÇA‰ç…¨¡ŸâE‹'nmcor7ÞD +ƒ@ž"ïú“¼yqòúC¯”gB®¼[ž¹¡†‘ÿË«™÷6š—[jóò|œ·pgNô¼ö~^Ø z¡ÆŸz`ô„ôô¤Joû™^;*Ÿáç% 5rÛ]2Ó[u†nUHýÊ¡éá0 ‹éÆP#V 5 ‚ÎôZӡɱ‰C ®¡à6¼‘¡¦w#Mû$CþóQ¢ÏN¤¯­‰*`-ß/M_'F}YA,ÓW 5$¼B¨¢M1ÀßÅa J,pEúC]ÓGqú|4}ϵPƒÚôñV¨œ>锾íj•¾Èž¾O¨ÁÒéóø(M}Û9õ5« då´oêKàùB ,_}gÍ:d’u}.‡}àgö 7µO[áªÒ&é©o3ß÷1C Ò!p)H ??ãmÿ:~‡;0:M~Ï9ræ××P"çÇQ=ã ýŽõÒ`?AhíP£†‹h¨qç§\ñW¦ü=Ð󧼨¿†…íF\¨¡tœB 0ü#K¨1ýæë߮߿ܩB <úÐ!5žs¦û+/Ôø¢ÿƒxÀ1 ö\|w<€i‘ü¯àÍPƒêÉÛÕ- 5ÚPd©€6C 5« ȵB`v±m 6ÔÀoÀbð€Ÿ nHè~˜hvßÔ%p,° º@p†•ˆ3p®j`Moà¡ñˆíjlï@…Ô? X)ÔØBRbêhSu·7t _RœØ8‚×Ô_á4J( ¶Ì‚x'{ÐHœÆœßp¹á#¨|-­Â 8p½TØV*Àt&˜Ãih;ô¢ÄˆÓP,£âÞ#$Q>‚&HP¿IC‚³ATx®“àV$˜à4Bu‡Ó«?-$¸É"A| â·$ Þ¶O>1äÐt£&µTÈ@œ†=œF ·P5®›8²j+f@%˜§YžÂ•qÙHx/¯ïW‹,câÀJ‹*D È…¬»U‚m?IC”1ªÛrÅ•R Ìõ,*A¬€eÚšÓ@Pš( Ð[ BÃHXØY FÛ 5"V vê4TØœ”`vä; U'A~lÝiL³4@&Ái; NϾ»ñ'ÁÅNòõcœ^R‹ÙIpÞ$X_€Pt™(Ú¢ïŽHh‘AzDp€(`J‚Ç-¨Q”`“`°(±ÙIPº<Ç¡vPH‚ª¸ËÃu£{ØJR‚™½ ^œMPÿNîWYI°›ò²1a)‘à¬Nò?ÜÆZ,¶Oc š«â;l "A*’ (8#Až"TD‚b!Á|žÆdœÐX@ ÀMžˆžFLûÆx)OƒyY A¡$<‘`‘`dV$xW& fQž"*ËÎÕ|ò4L#A»Z‘à»æó4æî/!Vùäi`HðNS\„xó»hHpÉ&Å´ Án§aÌ]7¯,<ª(/ÅçiH`.ò4ÀÓhIøOƒ²D ÁG°ËÓ舦‡"¬d8 z'’FÊÂB:‚*ŸfÅ´‰§A« ¼ Nw·cHâœ<ÝiÄŽ ¾TÂØi¼ÿ€P¢µÓ•hDA¢¸jì4& E"ëvÆ4äA + €©;âÚ»Š"A“U €VÄéHD¨LõÆ ÚÜSq§1¨0OË 9rŠ CÔ°HŸ#Áá=$A]K‚Ä ¨†¡4òJBPœI0 ­“`wP‚šbh€§AÔ´@¦Œ N&ÈóNCJ +xf⹯-)…LpJ¯ öxõcš`Mž†@<­l +Oã76Ô÷*ú;ûÅð4Ò@Û¨vð4ã‘*k‚›áLO#X­¨Õ< Uzž pÅÓ`<»˜:žFߤM¡éjW4„—¡‡„O£9 %°°'Ø]ð4¦Z¬T‚KíœQê'82 €§!ûï4bäô r4xéï4jî4låvñ‚‰òñ´~ð4¦“ï4(àÁ’z $x5Ppï42Þ…§ñð‰ú Vâ;‹€‚b†´¨Ÿ`ÝNãS  :Ò¾¬^ü×)v4þø Þ\" „׌`à: LÎ ~°Ó0 ªžàž`Œ4l-;A¢@P§!" +rkUYCÁi/Ì\CÁ.*Ž‚s>ÁÕRDÁ}(x¡$ +]ÄÎO& 2ïW„Í’ÄA§‘4N½ +¦ñA: c9¤C¢Ó¨`‘€ ÑÒiiT§1©¢à¦F•0KÔi 5 +®ï9„óäÐ: ¨:b´Ä¶[ëé4°: ½(H#¸KÌ«ž”„:!y³$'Ÿc£à¶¢`&§à•™x,„JOÁê: ì4x„ +BξnxÜv½ïNƒã©TP>¡hô ‚ƒ’÷NÃçNƒ‹£‚gÚix<¨ Ð!vÀ;ÿ¶Ó(‹ÉGy +ŠÚF *2¼FB‚;; älæÙ: "QØiü/õ<*˜ìQÁ=4HiÙ: ø ¨†"LMÜRAâJ¢ aäµ?ˆŠâÙiüžeL+´Ó°œF«ôh© s¯ÓX`Ω »ÄnZ” +®H6*f°¶KƒX†ˆé4®Cg|I €%"BH*Øœ©àM*H}!‰·6Ìè48é©‚K:+ÃT04Õi`÷·NcHœFð8kªàõÙ‚ªàø$Xœƒ0NƒŠþŠä¢úFœFrש .§á—O/©àâÇœáP¥‚Fs*‘å4Ø¥½ŸÓˆ¾fU†±z~ý*(mˆ +ªL§qqt’´x +6æœFùÞè4VèU-#8V°Û ðýUðÁaë4<¥d§ñTV°³N8µ‚Ãm—ƒH+Ø:·/½ÐiYA&t•, U!ÆC…"ç4T,h«Ûˆƒæ4à\N ]‘#œ¨„fNÃccAa*[Ð: ë9_]gNÂÉ +ÅÚ~-ØÏi(ÍI-X6§áÓjôBawÒàkNcçÿt¼ÑilµÙÓi ~¯íß ¸DD@J"-[ðó +‹„ö©vZºd›Rÿ\ô ‚¨ +€/ !¥€7§ñš‹å4–šÓ8ës.X©ã‚(Ë7˜ Ò'ä‚ùEÅ‹¬£ÓàŸÓ€ÃœÆÄ…%D]9’ê;þ%ÉI£À×SŽÂƺŒ .X­&\— ¶›Z[Ù¹àc'Ij³#9 ¤ ®üuÁ•vÁ§ÚSl&ªqÉúaÜ’Ó€wÁ3»àcÄièó¼® šVc\Y‚¨{ùˆÝiX‹³æø¬4öEœ†y8N#0‘U« |«9 I`§±$9-š]°§ ÆxŒ×ãr·ÒssмàD©ð‚XMjï‚dÍi¡k9Í­#8£]°›ã4ÔHÑ» &̯.ˆ½‰µ§±¿úg-vÁù„kºq3{ÔzÛG.N£[‰RAº Ì¤<}ü¡@7Ãw Üñ¬Íû­îä4vITQ åBé¸vÁ.ü7§±Ïiä]p¸‹8 *$Æœ†/ƒ€ÁF¶Öä/¸Ld¢)U…CrϪ~?ò ~›4hEqT€ÓàyÓ°FŠâÕN„ïn’î–RlӸؼ­MÃV2õ—ŸF†Â6 —1¿UT |A¿ HÒ¨ëlqIlV×4¸"ƒÐ¾ 1µâÑ|Á“Q̦a4´‚þ5ò^𴦑\M£.ÀÖ4HØdØ îOB¸Ø4°«L®i°Á¦¡Ž¬aœM#‰ázL~†M#„´y,ÄodÓ¸âõ‚-6•Ù4æÏ 5iˆzÁŠ6 XTBm.¼S²iXÁ©ØNDQ'–ʽ Ñ¦Aõ‚˜ÂMf^Ð8›ÆŒ¹i|•nÝhà£ÚÈ©D}Ò°’žñ¦ÊáðÔß4ÜÁiìŠâ ‘£Y©'NcðÆi°^ðŠÓ6Çi{A´8†ž—„J^â b§Qõÿ@x¾<€ÓèÜ»b»Ò¥|ÄiD¾`‡ƒ_0†ÓðNÄì ÎëCß4|I{Á²l«lÁi4¢ß¾'z{Nƒ»1À½àXæ Ml©BœÆÁ^ïÓ9NãnFìÛ¨¶ ‘_ÔDª +_µ2Ï“FENã’qíU7qÂiHµ §!ú¦Ñ"9iÁœFT *œFšfúÐ%S›:hy§Žq`¬8×5ã4&ÑÆHZ 26l• º8 ª¸ jN#ö2X‰\Ð¥®ÁiPøõ ©ˆ>7˜KÃ+¦ZÑ6@ù[““8 +D{§Ô54 +Dëon^ðÙ²0 xrœ`¼ÐªFì(F +ŽEM%¡Âi8£ckqSïžbùSPÂièÌËy£½u7 \”ŽdEÈÆci••1Wø‹°‡BEá4öªâ¬ƒ¹²¿iìÈEKÑFpi–s¢}›+¡„úMã6¡!ÆoÖ1Çc÷ÚÅ÷lªCsâůðšü($bãK‹¹KöMãÉ÷”é7c;À)©ÆÎÍv…‡x5ä½$}XJÚãKƒ^¬–AòDÞµp¦Âi8ÑüpfÍÎÚÚ@Q¥á4(Ŭú”ÿ/…|æP*B”T÷% `&W² Ô§1”[$¡h +ŠÍCµ¬|tÍ%œÆÜ+À`…OÚ•÷¶4aä’ByjªÃGø¦á-,)ƒŸ¿i /^=t¢¢õM#{Âø¤º\ñ'÷m2p`q­^G}5+1Œo‚ÀiœZñÈÁLœ†|ý×cC/ÔЇhÜÓݽ<[æ'댽iÔãfÍ9A‹#¦ÖÅ$/&z{‡ð­z8 G0£2Ú¤XÊ'x-íM¥YعD°a¬`8¤õø·ÝC/ܦ%ráH'A"~L¨sJ +c8 ‡Så ã¿j¿Ä…Ó€P˜ 8 Édƒzùèë’K ÷p “‹¬³í¯Ê­Ä Nc}õé_–NC{TÆÍoFqžRìÂ8!§My›>0 Ì5™bÔæÚw±Tƒ*nn¾Ûn‚æŽYBq”Ћ›Uáá_¥ˆÓx‚%÷È‚e¸õ"ÁiÑÉòdƒbEÉ'!'§á×Àçü©¡2Èi8)}`Ds¾ë3Ú•0Ó@T‘29$0ê¸Ïfr¢t”¯ÄžfÙ`ÚmcñŸ›œÄÎ%.w™ÇŲürë”xÇÚ¹#„†²‘ÓHzY,Ñ‘ÓÛ|Sc¯’;{ƒÑÕð·úa› 9 ešëœ€K8ä40Ô%uŠ"‡•Ó=$„ÞP9ä4ÉQúÌA +u(+69 g%Y…°&¶™4`áIrYûtG,Áíu)I5næVîØrÛÈ/÷°X'=>˜Ùéã'KÛg@ŽµˆDºU¾N/ ÍËÊi ÑîXÇFÌ{Êið±\¡í›%Bu9 $T\8àÏç!…®=Ÿr+ó/ Ù›õIVNChíæ“\Ư‡‰äEãÄrð±Ñ3tþ Ëu·9 æ(þŸBq˜È6{ñ™lzXӠ̯\3þ9 äî…›ÁŸôUKBPôÖ…Çk:Iõ/œ£X‹”˜AÐcmÕYŒ[¤b” +¾s…‚Ô×¥{ª¬N2:‚`µ¾ô`‰ºqV¼éßÂÌp/űæ¥ð&=|*¡†'¦ª‰Òiœ==¾JYßN,z¨=´Ñ¼“}À(…c4†ð3É\§¡s“òHn.‡j—RÎÄ 30½Nc·¨©Ù‹Ès)×yøÙÊ zOt<5£Î–I[­FÑ[B†Á­l3¡vÃ}Ä ê„—!R£+;—gí4P`¬ƒ’ÆÜNC5µû…0º‹Yæµäaè0vÆuÒ´C–œ<ˆ˜ÃÆS£¨Š>Éê(*R ^!ñ°Î*òg'ñóÐÓ /yÒ®Xq­Ó`·°¦]‡Eêöu½÷°pK}ªgºuýYî—^a¬•--îë4]LŠ÷˜´Þ~¬Óh¬æeÿP»x³³(Š É8S^ë4üðˆYP*¨;ªôFˆºGóµÆ9”Å;ûñ0#ó Ÿ ,e[=m¹yÒÊ·«‹óµc×i|Ù‹±”³ìWòÁ^úü; {]q2âÁ|ZÅÛG/ E¾ÛJð»gi`žˆí4¶·œoñîx6ož…½HÙS u¸F\=™‡ŸÜcÚi°ö¶Ï·]þvÇ»h¤.ÅE‚´g阡 Öcî4¤í¿‹Qn¥ì÷°êNã(5¦²^ûD¹°î[zñ`¦¶cGonÇÜÍ([žqîéNÃüqåC?Õî4ZÊ»ô ê«HEçN#wí3æy2ôïvvw¢†Øx‹u´ˆKypE‰yÿ¨#"îTe©EB©FK=¥²3ÉèNc‡È6ºCR]ôc¼W,w¡?@ªØ¾LUÃsµ9’tºÓ™O#¸«UåàÓuʽxð; jÐ,±Qºî½xXlÉåŒB àãÅ$ÑÖ.F ÖÁréŃíb!arÔ·EéNƒ!ûãØ-êô`ê½xQ +¢$w Fî4öcR‰Ó¿ úP¿ª!fþÉnÏ‹+2ŠN/zÜi°ff4pol±RröåÝŃ»¥Ýi¼_:#7ø„±ëâ´5‡‹ÏÆyùGAî4œ×þ˜ã7Íþ\vBÞilÒç÷瞆só: ìâá‚¢b9árv .»x  ¬¦;èÌg~d)ä´è¹O‡.ÜNÈD1Ú­®_ØàäâAôÀÓÈ_¨æp¬Í¸Ú‘ÂÓ. Èž­ÄFW< ˆ,,½§h²Ðì•ë×rñ ¤¿ÌÀÓ_¿0·¿ïËFy("©c `™ÓFôO‚ÿC?¤®<]ÀÈÓUìÝ_¨TAx~™ IU%ƒÙÇ}ˆ8{b*¨ñ‹sFªV2úLÿ·½p'Ý•´‹ªx d¼ÒHÜpF»å^<´H ++LÌßRäøŒ§¡&ÑÏíȉ o.!Ws2¬ +~< çk$6+'¯ãÆWoUø€ü; 6™É@3±LÒ² ^<ÐLv¤êÒ¸Ó!eöÅ·Óš`rð4\)Ø0ON8ÝH½ŠQåÔEªã08mas”iL‘€Ü2Žð½g€2HãiÄbñ~S€jŽj[ÕÆCRx«ãl< ¡#ÔÏöYzÕݥOC~­°à…w0-ðÂTm— W0µÃÃ4©ˆSO,ÐâiÈ€ÈwîÉÑ;ž†£óY¹“·Ðó^¶êð B$P1<˜5'@òã5*š‚/x=ëtx(2Þ09A¡²Â¥(]í< àX€‡Ò.ähGWRÏ<ŸA!j$$–šâ³&Ni ó®â>Iúî4l·RðíÃ0–Åëð@p‚Ç0ak§Á¼MÖ®¨‘¦œ (`õ%g¿Å¶2±f#Ñááâ†Ê¾ñÁõeaH; Êïa‚üYÚilál¨îúôîX)(FÐp%:<"¦ÛiÀAnÖ5CA³b­l§Qx_û•= {Y§/Å{V|÷ƒ(U‡,ǺÓ%ÂNCÁã²qe§”<”Aœ¸³bÕáÍ3@‹yòˆåRJ±Ó¨µ@vÉ¿‹Ê¯Õ¬”ªÙi´¨IEØ#ÝGAÍ”Ö'ÒX§!tS*â^¾àô÷À÷%Ϩ®†œ¯­“ùðØ9Ãün‘…áu?|¡Üìí!R±Ó€W_ `Ž™üxÜæÁNc°O²¸4c(P£ï  +\eV~™5S®[9ì4œ¬¨90¸0ü7;í^ +c[iÇIó`ÐÌÆyû<øèñW©ùMÑؘWåÛiÈ  zwH³FRl|dÁ=´¦)Öí4ÈepÿpjŠŸ4÷Ù­Ú`£F|ñ>ÊèÌïÑNã?7­@^ +ÿKUç°ë2×·ÓHÌÏ+»±&2ðŒ·_?cW–*ºr´ðI¸ÓØ—­yqœ¹Ó¸ˆ-Vid’Y‚øPèNÃ`KAtR™"þ‚;ú]/@wfëËŽv¸X‚aÄ‹(r\dxî4@yHËZ®´ ÄÆ:¸Ÿ\l­ë5,”qÙ[w§Á¼ Cî4Dð8ˆ}´ÅJT; .F× µÈ„ýe4-ð_ÕUÚiô¦ê¶àIÆÓC*'‡& ßiìj’^]Þ‰ë ö%8¯ðB$ô` ³‚kØ; ìõ$ndÕU”a=……üJ‘ãwëÑ?L¿ýÛÅiuh—‰)Ž…ÅìZÕþ‚p¸é\¯¬ŸžÛKKÚÆfÙâé­N@. +•×醭>A‚ÿ w^á†:(hÅåî®ÈeÚøí[mÖEi2a|uÞ d +ùÆP QƒÇÝà—”÷±45Èå‘(ÿ„¼çуï4bÿ}‘Á,Ø|nóòâãC–ô‹ßiŠ,F?èùÈ;„EÉÁ7A*]èà;÷vÇ $pþTÞiHëàp.¬:¯¼ÂÞiTyYhZÇiIz¶?âbÃ1­ÎÓ; ʆµ<‡ ² æî +¸ds|Vï4¬q[“2Ü·bÜÛØ®kÅÇ.«Cäà46õé&'ÎaÖ” (B»9€L}¬ƒÆ”x5φsÆÂwûš„<©9ð²9 n~ÉFwˆ-Ý2Ï]Ö)"²¸ÓX£¨Wà2ß1‡{P‚êÒ'wê’; „ñc5¨VÁ—ƒÄîùÅz¥ƒCÉ5ÌvgOUÉzéŸú4i'x×álw[Pe¼ÓèË6m‡§±Q4z="¾H³ hÍ&[Bà^Êi‚§±åÔR”$»ŸSð4ø+3éÓµöL Ü(`x%9'+ÂÓà/êÏ´¨®¥m~rØ¢­$vSX,T’Æ]E!%ñ4’´YÈeñìKOD$$9(C7㉩ĪÝH¢6rx9îè.X¨xŽ\G9 í6ÚÑ ˆmr˜ØNþÇü¹ªÝl$¬ÏКëJÚó­ó¸Ô®´t‚Üi`Éxßh”‡+9^¬L¹Ó€QáÄ®ºÓ°±4Ë@ÊM@=N¼ßiìN €Cçï4@>Kñ¿^•ÁÅaâO¹Z¹ï,hÃúÊúåäzX“ÀÓØì¾ 'ËߤŸâ@oò{I'±êð4(’¤\‚n)ç>§8eÂ+lâ ÂjžrE"œài p¡)ÚÝÿQÑŽO¯œâ è;² ëË!ëʨ8hè; ¾ÁÓú;#£݃ÿbàil—ÞärR½/¦€}Meœs¶ø³1ï1?ÊDÅ‹Ê/Á¾BZgBÌ;ɆÑ|É·ùN3† ûŽìMl-K´¤SN»´Ô&ë¨8X`wü9ô«yƒÖ¨XÉ1*œG"¾Ú3,‡z‡Oê +ºÓÿ`úgçÒ#< r<4|ieò{ŠÃ’æE4’ÈQDŒ.x°±ß%°›0œfõ/ŒF§8ça2¹¦`$ž³–^q»Ÿ×"k“)ÿ“Ö%JRS +š‡W±¸œwçùsXÀœÁ2c¢íÉ¿tâWãè„N9}§!õâûO PÐZË1Éø˜â€@;ÀºvnêÍÿNéÎ`SVþóÆÄ–¾ˆE¢É|b íwøõ€lO|APâ,€sœü]É ”3š¾ZFàÈûr‹óFg‡(‚6{:t%qÈÝ4=6˜#Àˆžséå÷ È¢Öc\êqÈ$üçÆëv¡3ÊÞÁfF.ð¾X•óNƒ9u¿Z|µ8ÐÜF #ËÝ);šÉè"S‚ç×z§Q ­tÆŸYó²Üi¨R=íɺÓÈÚš°¿_,Î"z â° +û«&HqX0KaŠT¯£ÈºÓÈ?SVßCyóp3ÜY ïÙ¹Ó€›Xé#÷‘×Ú4_ +»Óã>ZÏ/¥åNãñ©õU¤Üid8˜"Fý䶉ª‡¤0/ÖAu¦T’(iV8ÐmPû« ½iî4ÎÑ *¯ +œÊ3m’L[*ãNãüÙâùR“SqÀŽ¹ô’¬h8Üi L›ž¶0B,Ò…)¢"çY‹õb§áHü›¿«®\8à=0Az#4Þâvp81h Aôw9(U_§á´gb„„9ªh\ä5®ÿ¤¸N›øã½Ë{F5N36¶@xï: ]¤PgäR攢N#–;>x•~æ¿A÷9'/Ÿ×Zæê4 ê²ÌÖ”81 Ôi°rqý•Áÿu5—ó†Ä5x5¦&g;Ôi€û®Gì ßÁ&¤icãê4ðGéè !“à¿¡5VÑ: /„–~Ø ªøo ‡ùŠžég×ü7´=G†$» +5R§Ñwg˜â¶(­ªÓ'´öÐìûjþšÙóÿ NƒÈ¼ª¿^×ǧ\¦˜á¿A¾n“ÿñãéx÷Ѫ¢L˜~¡¤¶Ìf£“¥:uÇ5¢,oñ*ÿ ›øz°!êÒiÀvøðÉ‚NãvŠÐEóßÐ]†l!FÝ5#Bä¿A^R• +Û“)'¦0¬Õ0°%eÛ„<‚Ø­òßÀà )ÃGcþì+ È /^Ä4Íil¾ðóX}2§1ºÝ¤Ü]Þý7äÐ<“ªMQÉ亷\s3 +Œf`ÿ æ壚Î>ƒnG¬Ñ9:ñØØxNƒr–;< rêàfcÿ —Œ‚F88³—ñî¿!£Þö:è¿ +ž¿s4Š×äúœÆÙ¿³ +ãî üœ†#föh‘ˆ—ÿ QoK™'ÂÙðç4‚žÛÄæPž*Q´ÓWÐ9 çÐL%¸W»Mç4J+Ìéé_6õÊBeòÿVÿúXs%}úœÿ– °¯@A Ïið‹$Ï˪8‚S%°÷g©ÄQt(tNc‡É@¾˜ã%ß@ :­[µ0¤³"ïwÔ¹ˆNÅ!ð6ƒÓRD8|q¥ûÿbKrºÏL?Záì'wê4š]§`‡ÌYa¼ (2Ôi`ÛNx²Šû Aݸž-˜²ÕiÀÕ¸áþìA¦¨Ó kÓÁ¸€N…~Ÿ: ž~ )t—yé*Fg„ÚpŠ_y©Ó(_:ÿ[ƒ6!bf{Fñ4±yk*D†Ô3»wŒê4žjËé]ðˆP§¡‡IcAvdôÔiä{ÖÛ¿©Ó=Ö…ÞÌÎ Ò®²÷…Öi0R6ZÄÑ95e§‘¡ê§ü ùƒqØ)’ ¶ .H!Ez; =¦ãœùWÎu€cúd`7ÊJH¹|w§AƒÀÒ“ƒ[n#¥UöpëN£†Á}]?ÃFKJˆÄØ“ÜiäË3h8™°KŸ#yíN#ÙŠ'aå ½çSø5 ~à!Ùw)É]î4h…Úðvzr; óKÅ$<ì4öûUü'tYüTšÙiˆ€ Èó\§ÁoÆ¿ŒïxùX§Á”ËÅ­ARàx€þL©p{»®Óø× öu×\ìdJL¡’v}3(Ö{luÊIAÃÀÙiô¥—ã¿M×O©F’çeTÁÊÁlB6ßÐOYWtßù'NB…DÚiÜ°„ +¥îMWÄù“t’>f§æF†š)îµ¹9;Fñ3W®¥Y°¾~ïk¾°<&R×i h,„©Óx®·/‹C,ÜS±d÷N6*ùlu —Â¥Nc+9º–X§!î„J×Ê@ƒ7R†&হ¦:ê4j‘ùóÃÛ-ÛBêÓÐÏ]³ÒO¨,H¨Ó8ãp°,/ä"{bê4~˜«¡7 O4ÈMê4ä­¤Ûc·Bb®QBRé;KM­¹šøê4¤ý, ºü{ý•æâ\nä£é¯diHãනv#Q]íùΡU¦Qá"ý³Tš†ðl0b”G5Øi`ŒÆL¿ È8ñÐvïQ6¨Ð?•ìz: Œ H›NƒÒWE ð;Lã>ÆÖeJƒc",¹(M£á/¯ËÌ@ýt¡_ŽØ“½úåS—F)¾r°A´´àO§4¢¿ |ðvx::}¥’ãB(í@§Qs7†¥n¡m1ˆT«ÑiX)ÏaQØ°ƒì4 ðˆ‡ÃÀ!hìWYx¶37Í-ZŸFƒbý´ tͱÙjÅG—¨ +ù;zíX ¶;›"½H£ƒ5é_ºf;X ~t>¤Øe¯ifÍNúA¿ð—N£—‚Ýëc`/$•‚x¥Óèk^áy:T©ÑÀù±ŒjG®ú«Õhx,·Œ†Žá4(o' ÁëÇÙš0¦$ìGC©H?ÃK|$Y”U´Ä §q´1"R$cÕ÷}¡Ò:Œ†aó¡M§Qé |« Fƒ‚˜å4¦‚«/»…Ó€‚Ù…ÑÐÚmA¥Ð¥æžÕDû‡dÎÿ§îè£[ûgyZÉ*àðÞ3xªSã4Æ (ö¡è3OjyDÃçZ¼Ê? aÕ‚‹€.ñ¡Áòó¤™FaR91K²¡…ׇÏ5Ù½¸±ßþ)§Qoï8¥.ë–ÓpÎéxâ( Áà_ˆÛ£A=k€Ëi 7žgÄ?O½ùÍSk9:[‹y%åájÀšzéjTNCZÉ”aýW†œ†sï“PN‚*««õw9 A"vQ+!)áK<ƒ‰™m`€:o£:ÍiB`¾ª›BÔ6§ñ„ÇTR|ç'vê•ȯ–V™ƒÎð‹nNìÉÚéœ"+àŒ† œb-œÔÒFŠHKá@+‹Ž7dw~ŸU%Ï0Æø-`pÔÎ =ÕÓ´ª»ÛÒtoÓ®(N¯m\¸ïâïµqòVêê¡ž0Ï|„tU¦Q5S÷ ÊæNæýÂø¸¹¸w7é»·zY¦Ë¼ÇËÛÛw OQYnmÑvé‹­¥«¬«¥´õï^ñÇ#}gÑ“~TÆ^2[î¿žÑG×»»^¯t¦¶ºôR¥ë\MÖÝ<#5ùn)3S›ðª„ÙyLLMË;^g+ß5Ùû²y“,«ðììn™…†tv–‡vÇc\dZ\&E†6)¢]‰ˆu;Äßïè +M¹Qtº´?®éKÿ"}ã×J4<7áQ_Œ:¥ì{þŒ_¼ëäËÚ«¢Ÿþ#ý§÷!üëK×F1; ÷…¼ ¼®–)a÷÷ŒuFü[¿ÓnKŸhZ˜¥ÈÈÜ¢ä£càÑ1ùš õûÅâk³)¯I‰”¸_$bâ—OI™´šr‘±hq‘OŒ†KŠ›_¬ÜFE*ݬ&F¶Lzãá~ñUå~‘FÆyj…t™Œ'Ú!ifÒŸ¸x´Ä,µÎNÊk$ÅÜ/Hõ”©whÇy©‹Jª‰©—”ŠJšH©¹_¨æI9ÕŠ:ÿì¼ëdʬÎÎÎ-Ò<£*å#Ób³Íº‹‡;Â\:SUÑV¦(àeílSL*Ån·ñªN†ꦪ"ïLzº  +TX˜@aŠ¶€Áv*ª_kî*4Ír_TYyãWÕRU7f抂,`h06L׎)•ªuM!QP8 HÀ8 08ÀG\ü`]ÝÝÓmcoŸLÇ}t̋ܶƉ–š–ºx “å2½¹›}vãç'ŸO]H˜ÔØØh¬j„xÄçñða¨{„ŠøFÚ~{1™òÈøýßýŽöÖß¿S]WëõeTz¿ñ¿1QñaXþoxÅ„ê@§'éÏ×[rÛ‘ö·+îõ‘š·öÕÕB*Þ+k1›æò23/g’²Z²²1óó¼GÊÅ«}´Ê»›´¼ÌKËû…nc-/-³./§-ójsó’ò57´y›õ¨yj·z«·z{{m™²ïVUëôx=îµ+ÃV$lnÊìíú×nɽ_P^rÓv÷’÷&§R*U±VZ±’ŸÔ8÷úŠ¯ôК®¸”OŠÈ«\fÛ»½J\ŒÞ»%]»Vq—ç3ÞÈÐÔŒõÖÆ_X-¦ñϪÆ÷5f0}™-ÏÆig|f|tî.þb‹_|{æ.þ"‹·™°8ù˜Å˜üÅ/]qÝ®ø‹ñ +ö<ŽÌ$Í¢D<Š +‹â88žÇyx0q¢Š%ŠDdY¨¨Î.íÌzæ`"wgfUeLÄ9³œ<<4Žæ£‡áÉ£©@IˆyL¤AÂ8OS¡á™Ð8 M3q ™Ð`áqæÂYC¢®!•g‡™h8;ƒ4ŒÒ<(Liœ *<&b +SXC"MÑPuŠªkHSÔ5˜Âv%@u<‘"ÌU%x@€ó<Œ‰D;ƒœ-˲,‹- /¶,ËÂK:0ÀGQ˜H…FÒ8 +iGY"D€<Éc²,Š=žÈÃ#y$0LäÁqš‡ ŠG‰( &"Q‡dà8ƒ€MD¢©T¨\\:’áM°¨'dTX=‘ ÈÁ°÷Y¥tE™·KE/_¤yøt‡YÄsúÔÒ2†R³Ôv#-ó N‘£V/ÃE|ŸúžÑˆA²³Íúõ½T±tò‹PlJ ¬¡WÖ . eæ§pµAƒ&©—Xt“¶‚éýcâàPÅ%õwLJ"<-;¥ +6 +jÖ_ÅÍ‘Ö+›‰:(ŸÁÜjÍ„pÊB$²º“žS'ˆ7ºÚˆGiÊjºÊ4Ã$™å|¹®*–ˆ7m%†ý¿L¸Í«­É>üƒä\~Ý`X’  Ëv}P +¬Ù"%þ •ÝÜ_Ÿ+p¹Ò67gФ$ÕÃΊbLè6›;nß–œÇMçtó"­%ZÃ2U‚Ý +Ö5‡…J0Žx¸´õš¦œ +ìýÝØV$ï4nÏ¢XÌq“Ò“cc\‹­Ïh~Ô5/ÉyWa¢RǘB^M‰u`y4îÍ¥è ¢ØYÁjpÝ]´Ÿ V¤˜æÇ^ËÖŽžC°uJÂONì‡;^ØãHˆ+ñIíSŠ"Õì/Wæi4{XÕภ+c÷HêÇ}QˆoÄ#ID´UO­fQÌH:RZ¼åÑAÙ[A}7SN‘ØG1{$Ô3’‰2aÏ40Úr¿kjäÎLmfOº +êû¿Ö¡ Z³r|(ø=Ÿ‰ëØ„7©â_c. +~ªy§-q™#›kªgP`%9‡ºC7VQÑ·_í7"L®Ñ‡µ­âò‹ ÛjÅÅV‰n[ê8–Š¤cÜ ™îHöB–£Èa¢®¹i«ø[®¹8Üâ ¨úíñj'úpëu>f^ïÖê5c|Èñ‚ª%Q†mÃ÷ž¡é”û{K1wè*†æÁ®ëZú.¥ÒÖnÝÐ+;ñ/³ohʱÑ'ÕûÅþZ&BDíÿ<>É;;ñ2Ù¶©Èˆ@/#ø7ÑïSp¨•rjû2Â2ÝÛ¸bÐp‰äñ„b‡:#ì@Ek*|HRš1ZW—t—Ø}ú {Æ©_‘Ù’¾)t#Ò†:€— „^¤†yêüiä?•ð¯B¡Ó¥p¥ãF¨’Ÿ+:ä³b}ØäéSìþ’<¼×YëÂ?ü[á+p•@°ò4.j¸•Ñg^åqg¦ÍíY :%üÓ‰Y„¤CÚ‡g!´ÕªŒæ+¿î)ð!?Úauçc㾯cÓö) ]…®>áéüí›®Âù¼C&J&çÚ± ¯Ã(o0ç¥ûÁˆÑŸ"&ƒÔi¤­ŸS=2Û6v¸ée½?h”íÂ(g‘V´}@€تS–ÉmíbÓÅ}YÄ/V÷dÉl[ÖvØî Ðzn­Û •Ê²,ÊL³UóB[ÄHËÇÁ +YŽSÌ=©¦åáicÿŽ§)ª_Û© +…OˆM·5u±RÜ LXÄW6PirŸ{ˆ_ +½#kYM N…4ε°6“ØÞi5ñL¿ÂW_q_ÈÙtÜl¼‚Â4í<îN'aºnlpA¾ˆ™nazôÐh…ãúïéLt-—:™”¼ºÓkTUZnôKè»‹ç ƒ~·NOÌÎåt•Ðž;LÄ;»Le/‰PG;ñ5-ZA‹â‹`õß«eyV·›Ü³IË‚=U]UÖÞÜ­GĠ؃G‡ ¥Ì­ñÐÈ q*zG∡=¹˜+;Ò3k¢­ dF!›Mž|z}ü7ÐãBÙd»ƒæÕ‹@IS¯gvƒ¸èšÇ³‡ªV%‚EÓ‘ÌS$G Q×,ó½`‘镱¾UÖtd~w®`ª7 2Ò¥‚ÉCæ@K^ÅT2ìqü4q-·àL¤rìD)«fq²]kn¸†GQ¬ •¶¼1¯–× êTH|»æ0 5 ©¾[¦ßãu(ˆ=¡tyžâ“0cÖ£Bo³(È6A5e wÚëºñ¿ßº±?fdÛ´°é—FLêêÊ~ˆF£Bõ‡ Ì8¤+qãNXLvjX:–ÖJ0I$¬,€Ï‹ê…áÍbݶpÃb0{ï7òOg®U&ßñT¦šÞÂx|Ž„Æ2Lªcüç–©'} é±´—¼cðj3éx¤xZpï°Ê´¤ž’?»ý“9SâCpÞ–ê!ä2¶b€ „ÎßV·|PHÆe ÃGÖáuß²Ù«…Ë\h(ùE¯O9l»e?ïHý ¡¦uóX¨s^”~JYup×…õ`uAŠz 6‘,<~ƒ4F[š°ïÆcõ¶»G®Ðqâr@|ìÍ%‡ÝY®,hÜGq}ØŠþ!Ç+ç\F7@v¿\nû )>íÍ1Xšhfƒ¨BZMѳžåNºÕ'Q¯‚ŠN7¦nÇ&^³ÎYƾwL¡âTÑCVÒ(HÉÍÕ(cÒ*¿jpÛ¢†"ô¨ª6—mVCVj·q@ XÂ,ÂÆÇí3VãUÞw5:ÖÃ=Eér/PgU~É•· -¼eÎðb‰™a!"RJ7T2 +©¥`n4c˜Åª.k–U,±Üjûb-?êJ +»‹ªÈO˜¸Û +±ªÆ- €~9 袲ÛMˆîŽú…ùD&KX +šá¦úÇ´Ç»oeòÓ_”ÿ¶<):?OÅR„‘ð³QDDÊÙy°hÍQÈã{8 F Èt P–v 4ûFÆö'ÕuØmF¥:øíõˆ­•TMظ°ž•©§ÚÃxاcŒ%÷B x¼Q¢,Ú2ÆnALÚQc¤e #6c hÑ¢¡¥síp}ÀÛØdÊûÔŸÅŽÀZÄÖã=â_£X©@`SèÛ#2à¥!ÊÉf…h…q÷Èô£Y¡@&XJèY¸ ^‚¢Ze@8þÁföÕøAÚp úvE¨Ô:—H8®<ÒŸˆÖ‘—ýVÉV:€£ Òì7É÷*“å7û³©Ôë–˜ËçrÚÿ‚Œ•.,ðæ‹™ä,ÐÓÐÈ€aÉä + ) ‹J]/„ºâ¹écX­#êèã;€Ôäï€znæ&cqß²Ã,ß²./ØÍ]ðø¦Æ êâÌÃ5N¼…z½-j}QmÏÊ—Ö©ä¾÷À) ¯ÕvQ'iÓK=rµÄ—R$*¸&GßÌ?+‚Ü_QT+W¾Dê]àðh\¼ÅÄ1éI˜ç‚b›Š±\‹€’˜t“¥,4·¥FLzNt„‘ Šò†-§jÀªãs²à¾ŠࢴiÚ¨ˆµ‡Z¡1i³£DŒ8´‚†Ùn蒈ݮ´n'2˜°Æ§ßOدsí{—Hýà¯nµ\õ0æ0%»x¤mØ«á¤:NsÚqä²®šä‚Kúk³í GßÑ’A‘!ØW•kÃÓ莪ßõ 3YÊi¥’ýëe*åì@yâèÞ"/Å®TnxÒ’w,atÐôéu®`ˆH`ÿÿ—ÖÁÛ2ëà(2”2n¼–·wûO$ú¹0™Û¹Ç¾ë@8ÝsAûÁEÓÞ5)<pdÝ"Ž ŠkýCEC’0ûZ]‡)q=^áJ 8 ›Ÿ;ÏiPT¯h懑˜°XT7èT +Ÿ{pÙ?œ83Kßè§R‚úÊ­’Ðå‹$òcéïÒã ±~@±•Aëí'„ ºoARé·¯¦Ú¼ð—m5ÐVïô;³Ë›àÐMê&n¹Òógž÷)×ê]Òöà"­º½8êZdì]髳 3È^›¤ë5:´³¶E¡5F“1x%&ÆŒaY=CiŽTGÁáHïxp…W£Áødåô¡(tZîÍ¥-^ +ÁŒ+MVÓ`H7ë1-E,ùG47Í~£-—T#½}ÇI¾ €¨øXÀe²§€¡¬EóAñ‰x€éiJ4žïƒ«š–¶m>tûb +‰°°/u…JËÃÀ¢ƒ;ø,B~9 FÔn%èÚNôSUfgŒpg<‚õÂö‡yç¥,–~Úº»wSwÏïP¬Ù­›²Ïª-ÇEë,à\šé*”r“²Ê›[äyËŽe!]ƒé·0§7þõbÒ‘g1¬€‘ˆ»0ý÷3 +qLŒÚÄ‘>ÏÎþ <–Åç§\G®À'‡ƒX}òþèZÙÎée*}dªó Ê÷òê¯ö­—ÐK.¸ì·äSÒzz±ª0Hbå‰UÒ¢ roÝÐ<òšªê“Þ–‰±‡ ¿i2’wÙwã>Ùÿ«;Œ’âA¶.þO½,(º¹q61 üî’Ñkß—m¡áƒåùq”„TqeƒÊ=Ô@P’Š$y\£ŽR9€Æâ™Ä®ÆK‡Ñý#  “³ÎqÿSØщÈeEîjVI3Ýí œE÷×,@ê~ï]ÀN Ÿt©z‘“‹`ž,ýãd_¶“ÿÏãFÎÁÄß»ÔýY;añQ>·ïjÙ^k_yz§ò +d;¡¿©=à@Íz‘Ì{P®=²®Šÿ‰«ùòí²°m¿o¨u3¢Ôª6e!ßò6ùÓ* ˜™M‘þÈ~wæ¬6 +¶a"Iê8 Ž°Ð¶€:Í!Ä‹„Bõaéå$U"h•*‚í#`4Å!\Šh…Àó5íFÓ‡}6wiòóùú,"ÓgŸKÖVsqïªÊÕQ_C9¸ËÎòHê„ßœp–®aè󨶾øF­Ê­BÌœ^üˆW¬GHè‰P©Ò ï“—#Š´;Ž‡( ô€òe%QŸ0 }ôröLã¬1Aé*‚¹îîÔX,lªâÓk*zŠÙp×Pa—%>Þ…‚š9¦lÿ¶¶×Þ8:Á¦N={0T1¸Ä¬´â– +ݳS´h&¸Ž–ñÍ"Øã#·"Xƒ8¿åá`È'6y*˜¸pfCÔèn®°€êeT©€¢SígL=6…§±€#;\Q,ŠÎ½Uh§TsƒZGÖþnö˜.H±ÙÎuȺ†á;ÙAöË÷‘€]õÕ®«O$½Ÿw~BÃ_‘ÔúÈY×<›9Á3˃þE;ZúvyÿÁŽm…r|11äÑWW• “hHD”‚»v@†0o4Öè˜"@䱓»ó“A󞀊C[/f·¦Ü~²y"(/ö…ð„í´–bà)|tÖi—Á)ë@ÖIl¤(S)(eiEµßOÝâü4ùaZyÁÀL¡Is¾^›ôKf+&–*“Qˆù°èÈr¢‚û¦T˜Ç 9Ý7ƒKé˜~Rs~Ó䑤“âÈøŽæ†ÀÐ*´ p´9$iÊG¡‹„N5§xNÑã÷¡ ,æQ`|• þ¬yOk+TþF#ÑWk¨5óÂÍCÅCÖE–ˆ~5n~aß +J»“X¦XO@õªáÝÔ€ã/"Ä þ¼iÙƒsð¼xQ™h¯ÒÉw\e ’ÏŒDSدÜòõòúÉåkÖôsC.܇ÁŽ(Õ= ÉGX*VânV‚î>¹€¤¹ƒÑƒ_f䔩Rè,w¿#ŒB}w8¹™Ø;Fz‚cÕ‡’Á‚øh¯|TYxïQS ïFV<VR¶y(¨]N-Nƒù̇uÄÜ×½æÍáFìãÏRq;+ƒ»ùÜ ›rVØ6V8Þ?cŒô¶Ç<ø s%•‹EGôpé %3`‹ãv—µ%Jä»*œb_ÐÄ¿“¨µˆËè˜`¼«ð*¼Éäå¡?=žè¢0Žò™f•-má<[¿hN’èÈÞó _ãáÚòÉÁ·]þÀŸcôš iÝ<Ú[¨;F@BkÜ$?Í¥6¼dÄß +å]2©IÍyrv‰tè ¼dJ  NÖPd÷ßâË”|Òø»Y2€›u:„AÅDa,Z†:Ü›,“hNh×í}fÿhI QxÁÃÍ6‹:{Þeñ)›!Ýà]ªOn¨/bõs.Käj’PÐë^aô´U{ÉÌLÑ÷š6ÒXBƤ—•Eº\êr‹‹PLcI¾¥‚¬ÑL¸¼…F>ƒÈ¢þ ¦ÿ­ƒ5Gïp…öŸy]ÿ3ùpyc?^ˆ@z¥Wm›7ŒÏ@ÃÂKA1ÇlÌ£] Áþ'”&`‰ºðÆM¡ŒÆ>6q Þ–åï-•kíŽXÂxÂt +èW°‰œŠ­^ì²&ªcÛi‹¸I[“{Þâþ̂ΧE`ï ¸Í¨ØtìM fÀ‹BŒ>{H*,l–T轺cèd¨]DB*e$üÚïε„tØpñðš(’ª˜S$þ8tƒENèäŸøHŸ?æ·NUÄâÌ's×ÚÃúìñ,ÑN4ßwó½RKJâ8йn ·è Ïpêi¹0³¢)¢­Vp±˜eåÌ+Ð?‘PûM°œ,£—;Žx¶ôOB;ÀièÕ»Áo÷Q…ÿ›2 +Í9®Žb°‰œ¸ >ofl°OwÄòË".°Ù*!Øš£Ýý­BŠZY††î&ìX_p‘žy0{¤szD +S +³qLX°däó %Ê©2!ô|T¿ñHæ“E÷·¤Ð@ħ™UÜe|©êu¨1OJ€`¯Ä%Bí¨€ÙÄ Kb3숨2 +0£Fš*±QÈú.ÚI6g ÎsióšÑOؘ.Ùhå "¬…ð±#di2Qvô×L 0Š&@ü¥’Òí+Õp¢Kºy1H‰í±ŽHËp ÖÒL'sý{—8Z!“ÙB‡0<’÷Zo¿²LHü~¤à-B­ÿ ¦¢Ó†g%8ÍÛĆ¢ŸQ[8ñî=@ÑÔ$@ bTgÄd„àZ¨—ae¸¸®Fò¾‰LXñ–—?!ÏÌOˆ.,ùË êÌSU.üýMŠCs(cê$#«î“V-¼Mð^uT±Q”ÌkÔq&L‰¤—ÿ˜Ý´™/Û kr.ï¯}4í[QBJÿм~äѬÓ49s/¶ß +õ|¥¨^ª #ÁµÏðƆZ†“ f¾Õî L!ÀØ2­nž»yJÐÀàÖˆ€Ðž*]ÌYR6¶ëq® ÞçÞxé™ÆðmY7Ù!-AÂÕ3`ëʼnfŠÚ•="üÄ–VE£NŸ;)™¢˜@57Rù`€®°‡hùúÿguQ¬]U"‰ÿ“¶ª “ˆÆxõ‡ÑÂxr5ö¦®faƒÔh¤¨@,—0ˆ +8FxÊrÍ­šÌàÕv¡GT™ÔÔ`ë}ujsɬL‡I'W_²E3رþRç']ÎkjÌÒ€M’Imî;”“ŒáE¿=±5p×K²…ô!,\~ÑŒn?‚ºCÊÅ'¢¨îIt”îýù‚¡P\Q—ݪ=´h¹ -?PÂ-¶ñ±U¨8Šf£ #’…6Ê—“Ž3 b^œIÝJ¢%E¬x“Janf⃕FõE©%4c¯’ð´&òf›²6¥|«‘RkLE\”XdÝÜ9E¨³&›p¸õ|/LS憄ç–Èé¯=-ôò®²ËO{?Ui@^°¼æü˜¬IkDÄ™å²tTCüÉ3sªÜ!ºgr,)ÈêeZßßß—j‚,U­¦‰sV=ŒÅÛÁ—á,o©Ãè¹P ë.,¤GgrÕ1=£ýBžç<;?f8š¯XøÑ•"æç3'Ÿ†sŽuÂMݹ;bW€eEP%Œ\pÅn(vWǶ%³¦Ó6Û— {r¦?õahCG Dzã°a>zÍ÷Bè¸ioß ÎÜîZ(êQˆ‚H·bá¶qB…UE±Åú±¥¹§ R4ó.ÃÃk€Ò×bÙÕkÜ1¶É”¨Š å¹7a6ÿžeÏ„ó¸áÈ8@ÂlqH”²Ÿ õKa¿u½Ž(õÚRÜuÆnµÛu²¯®ÀˆÄ°¥ÃQÈq£ËŠýžnåe¡fQš¬ãMcyÝÿÁ‰t ¼ª"œÇÞ¤Æ&qŽ”·*!6¿ºKh‚ç#A þgêðöâžÎµ´‡·¨„ ‰ £!•JÑVØ辕 > ¿ÑŒÚ¼=$b¡w  ÷1yÙü„%®ê œÙÕ:í·ýe˜Ðk7Om[u‚ùßiÖ¦íGÜ!˜õ£bÝj}Ú«Ô(<—<ÙÓA¤¹=OA‹‰l“ZùKŸ…ScðÆîô\Þýlf6&](€vhHäÈBj·ÇÁqÔmd»IÈ]öoa>à +¶¾Ÿ‹1¤×qø˜!5^;À:F ÈÌ.TÖ“s`E×(¼©¸-üLYKÐøXPU'†ü0‚äÖ²6z2Ë]2F¡ÁTè¢mkU-Z3Œ!Ê1œo'²ô)xj’t ë­Ýõ ¶´àñàĈ]<º§½¨›ì7æÇ*n!y¥Õ3蛇\óšë„.¥d–a3‘À¦Ò(¯n_ ˆîGÓw¦ªD¾5ÁXyŒ'Õ[жÁä&î¡lÔ‘¨]da2„÷8ïI‹¨š,-T¶À¢…ª” ‘™¶N…Ò¢½fSû%ôá*!%(}Q颳âwgcSμ‘Lº-È«ƒÑ±©yžîÉé¿’ò‡ÿ…çäŸü +kÕOŒ–ûàŒÄ­µÉ„ÿÏ~[ÞV´èºaÎ|@„WúÝ=9y¨Ùoia;j’Ê Çz‘k&(dx”Éê„’óDAƒT§¸[¦SThïâ*=¬&uµyê°±¬B‹•Wþe!—eA“«7±÷ža±¶Ï$*Pè:¯ÞPè[ÚÛ¢ã:ô¨Ž¼Á·îËÑœè°GŒž7ƒz&š Æ÷ûo¤ûÃfÙk ”#†  çq1Û&Zlÿ k=ÐÿTBÒ½ô(KxljlíòîtÆxJIâŠpâ›.k“QQ“pªƒi#t^M«„ +ìÇ`·|t^ÐÙœ  g¢&öyC¬6À›"¤¨$LýDŒJ>¼Ý÷ ŽLYEˆ Ó宿U *`Ö™¸°}Ž¼²ã&EÒ—íà¼g4òT·ÆÝ¡@FZ‘ |0´G%ÍÀ“cVˆâ­‹51È0ˆ›¬÷–Ç€“nH“H½Ó$z"θû°d@ÝOÀÙK»ê±Ÿ”à ¥Ø þÑË.ÀôwÌ:@5ªyƒüþC%8Nÿ=‰GfxàG‰ÓWÆªÚ +i¼ŸîêÓ +qzrû˜Ú‹êŒVj!' Ùù¦æÿìú+[ÇÀþ- ÆAÛÅ€?ÓVÏ1±F€å0_€<˜–óøLK;6ømñ6Euå\Š ìa¿:.µO¨´ŒZ=&Ù5A¾»Ð2ƽ3ص´ýz‡`=ª|;Š(B¡&›ÙË.¤l6'l‘ˆIãs‚úÞ¥ÛæѬ£À£bÒˆ»7I™3§¡½W•¶åŸl#õ»Òx[µ4u¦MÉ-(íoJÃF¤yÌ•æ,Û&—þstÂÏ~Òݺ¼WC/!&­“~‚?åZ¤>bîý§Ê¹õú“2Û-Z18]u Ý)ÉMžÉ¢~`¨‚hvãe8hîC|eF|¼êK·_»ÔR"AŸ)¯HPW ~£Ã±XI?€`¤1”ƒYEÿm¼þBéÛéì{$«ÎÉÅ¥¡iŽv: ­À/èŸÉ¼ Án3—¾„瘝jc÷Ùã²Üêä&0ÿ—ñòÂZÐΖ”Òy˜ÂsAë"aÂWZÁ;Ê"’ö5ý†éŒ4ëÈËY%'iUžkr|ÄÕ7 «5S&Ô+ºˆßò aŽl«¢ï¢™g4n0ñM–䃨«ZÝ¢©M>__;í>¤ _“ì(Ý›1ö6åPUhqg“’ß̲ ƒ7‡ª- ÏA¨£Î˜ç âü¤ÍÝاX4µÁ4„x`hrŽ÷F ‡.ísÕ }i4M ºÀ'¢JàЂÁ£9FðMNö2tî¤c|·4ü¯,µX‡µJ&¶cÆ?çu¹v®9I±>G“m~ã?#Fâ-P áýÃù Ú•]ÁÙ,½&=¸S¬Ù ×z<¿s6cIɯIBðRjúñÖ$j_2P©}Æß7LÓË3A§¶‚·²ƒ áJKÂcoâ†T°¨p;NëJürr®4Oî6|hã ˆQ‡:$«¹ S·LÛ_‹‡ŒkEò™rÔ†¦ö¼;Ehr±+1hˆç =b‘ I¡¬­û$¬u +y÷ÿh ¨Òx rrY`CQŸ1qÜúv ^yh ±†œ”ôA¼¼a¾L¼J7+)±/ä‰WÁïŠGœ6ÜWÀ» +7„Í(tû¨•m2€Eòê1öIÛä©0`·lT~üÌ/úV@8ÄF…h÷ÃÖ¿’X€ÔÌ™éô©SFßÛì ·Ù»ë‰IÞŽ7ùi½]¨!"!óH«>™Kºå1%8´žÀžî¡§ à Õ¸>µ ØI(·Åxï'a%»ššñsa¸\±£&íWùp­›ºñœ¨»S<æßj‘Ê t"äЀ$ˆv½µèÉZijs[BƱW”˜Ý]ý§°¨´VÄ]Ÿ“#G¥Ï¯?ÅP¿‘†Í©t™^%x$ìwœ¬š„ÛŠÎ4z +Éè õº½øž ›3JÈÎ0⺰0aÒÃx¹c´PÈn:º¹ŽfÝÞ¸Ò³Ý ÿÇ:ईyûÌÊZ9¾;àVAX^œúd I+3ÁMjuÇOÀî2ªÙ@›ó’?IˆqA3©_1£Éçz.d(-šŠ¦Ö9ÊàÙl´{!£S`7Å€þ'ñšüÞŽÔñÓàù àVžœC&QðÐé3Jã ÝÑm4X r»f§½æ ºïž43ÌóZÈu9Ī€Úv|m…æ€W¡™XF-Ýñ&‚ÑŸrxg~ê¢SâסƒB”+Þ5úH2KÚlf*´¿%GË¢Džöp€µÒ6ß M¾ná +2Ñš9gôE 5‰@+˜U _Ĥ’¤e¨‘Ôžøm÷ÝËöXáMÖIgu–Pâ{‘X7Âä•*“kèÑX@ü<šÁíc«Pö LS|ÁóuU£ÅX_pòò£^þî⺿]/S¼™'k¾üϯYLôžâ2_úd—Ä’k¤º3ÚÀ„W™j׶S|‰~3¹};º`4ý¢ásjê!î^…ØKB+”¿xüèÿéf)¤ Ñõª®ø²¿üouà®þ6|)ÖÀÛÎÂFñÐ.…­xBc9ã AðÒ-7òü‡”1£Ž-Z/NŒè¨‡‡G‹<ÑÄ«Q¼B7*‹ÑðN :¾ÉA¿7nþÌ•ÞÐõ ÐüŸ c6å”`ÄÔ«l÷Ìu?‘nÚÅKò ìT[%ÄÑzz㉰ Ž¶C ºq…qï*Ø+cŽ<¾P'Ýþ…U…ìîóÆÎÌÀM$F.\ûZa±"ØuÚz{ ÊW,Öqh­¤&sG!­óð[.ø‡º¢k +M*¢– #‚|÷n¼þ¹@‚hYí4ºÇ¡¨‡ +4(Pu€Â¹øðíG9É,}U—E\í›=º ÁNÝ®/3…P‡t¯¹·'ž×ÈÈï”àÕ +¶M…‚«kY(1½°”…θ§YøPq™û§Ü6è$I?è`ž{ ¼,*2Š‰Y-úcTŽ'¡×ä/üLfÿ)XDÙŒý™ØÒqH©Év d;ñì îâòÙêXÓˆ@ƒÀq!™ÔªiÚbmô¤áÔo+?¦aN PÝHîB'HŠ[¢¤Pj˜ß£yÝ‹:DC.'—²ê¡ig wså`mƒ:¥˜,/*ž}šs¯Ï¶Šé¯zèX9‹3†l÷špþå¨m°¼äC ‰£ ô­¨Þ-É´ñ€/ëîÅf”`±hšjéšZ˜è Zës³ú3¢.˜ØòªÍ¯ë¿¯‚À_Šƒ™+ªÊbÃí3fSœ+œ“%ñ¬³R¾5¥ùñ€ƒ À zxÌK wwFp„–íÏèF¢œv–§ƒf2»¥ÐcúIà˜óÄjPhåõ£ZΓç&œÈ^iÙïœf>PßØçÔ5¥`®'ôƒIàWR%ÏÆ&V±ð<ØL#”§0þºàìi†8FròŒ†Xy;’æÆñ\0˜:àVäÿÏÒÂ Ò +û¼uR÷Îy–Ü~èM +×G’RóßÔz1ÿ‘-ã»®u¬†€êÎsD,µÐ€kÁÔrà3_—Ÿ›ªŒ=Æ”AÎÝ%ÿ#Þ]H?¶ìMGèP‘‹ýYÔC—MŸN]êã-Õ4xÑÿX +gA0ºèS¨mû<­ +]È.‰ŽêA „4s›g—Àí\¦¥Î.›(I4iTéí»µF è™ïwc¤qF!%ɤ‘ç¼~ ¥¬ì`I}í–¡°¨ r—ëu ‹„×ò;Àr-8cLjËSS¶tÊÌŽg$ãêv¹±Å«º €†}=+t+ÌȤĵÚúŠ¦^ŪûÑX27äLjiN4¤+Ä¥â®Im”JFÓu¢Ó€ªžÏ>Ê;¼%‰,Y8|_R”7L¿õÉŽö‘x€Hjj8[x{×Úþa ¥™åÓ bí|Íš ¾.d©mÁvD>,M™¶†Œ`(,5É$â2øý)×kb=’r‰G~:‚x !è\ÈÕ«µ^ ãP35AâðOÑ+¢ÓWºB ©>,Ôš‡±ñ7J¾ˆ-%Ù}͹Â`ð«·.ê@4œ©SãdÄ;ãÎ÷¹hC •8gÅ eá0Lê¶ß(žb‹5ÐiÎm-;íCPNxÁr ¬ð i©@ë5;Î+árªƒ³e«¶íû¹£$\)jÊø ’ÒäSù1Ü}‚@Ô¢½||’ +Š•d#óž¬í‚.ú³ èjL…‡•Ä¿³ãWHŒeÒK¨ÔTÕ%4—³ÝB““õ4dØš¦ÏÆÞ8x +#obòÞ)G´G¿ ø!ØÐvŒXvOݧÞn'}Öl/BtªöŽB²(°Õþ4Ù' zË¢§‘œÓœjnTö†Ó›,bۨױqí«öØD÷O¢ÖÔAN¿?p@ªp¸žM®ßo^£ê8>êsÉðA35Þæ±£÷î¦Î‘óðõ¶] +j72qZÒ‘—Ï( :$ÒZBÓ±»Sw[f¥¹ "hÃÔì_<_èô_ƒˆ’€Õ¡\Œwt¨³–`@¯Ç¡ô‚yª‰±Û­É?9c½+\Íu–“½¡ÛE…p¢Ýá,׶`Ë—˜tã‡eáÏ—g×ÝÐû‡5 4âUWœlEø¦ð£Œ…M]㊸\ÿÈñÝ2aV™ðÑYsÒ”Ó×Û\DHÙñÌÛ/G´GáfEdÒáüèµ_µËØýBË]Æ”¥NsÓ M¤ãL.“yV ‚w“Œôñ‹éþ;šTs°”›¾ª’6J•¦OqÏ‚UsŒ5%„nMüëÒÝ[è¾è* Ê¡bêÃ$u—߆1D[‡’“TüçVì…W–…©«hþ×¾#[¦ª+¸W"ë© Èý½bå·B ‰jN…'¦”Ê®H/q¨k}+¥:EšS#´)¤Íð£«98ßi¡šÔä°\XÒ·›x(F-Ôöbº0*cÒÄÝRÑh?µ¼Bw=æð~.ʬŸÞŒ—µ³“U™»ú»œ…'dð…:y‰,žÞ'‹«`J”¸^é“âý6À köÈŽìü1®€¢8Û)óÑ°ëÄîs|þ@õ¸üškš'›ÏŧÍ”\3‘}l6bžEPál¸dƒ­M·¼Yg§4ÛVEEDµ Q]Š ¬7ŠAÈ¡EÅׄRoÑâW—#ôG…Êâ!48®]‰˜vAØKÝò'l¥x0C'>Ñß©ù?WŽ(ÍX µ ŒªMâ9íˆ:J÷îÒßÆ÷>×ÂÅT  S6AÈHìé¨*±éË·ïÝöÛ·º(RóG™ÍdàÉ1"èJ–¨ên¤îy‡î¡I©v÷ë¥ýZ9Ô¨w§ £qÑ1Éh>:žó-F½AÜ`¼"àÛ©#~Âp+îz¬$Ily¤ƒ%Ú#ÝS÷͵ÑÆ ×]åÈ:Þšœ€€žÒ~ÎÚ1ë&´«†ˆ¨Ú¼žÒli0ÎRêMÀ[&¼Šn ^Dø ;«f‹nµÞsËBŒÌÔ®\Jê‚{ŽP(»W5ÔTšr\Idð¼›âã}-„ÏöÏYøÉfZq +T†¡#ÚÇfãEËx/Œ¯4[bœ³ñlíbtWeëZÚZÖ…C.Àªˆ ’†$ý³¸6zÊš–ñ9ê Ó2xûÙdwxIš¦–KSEGG™%Ð}´ÉM¸éÊñèkpë5œË=IYHõf¦…'Y^L,ÌHª§ËA,I9k%纋ª&eiÚâ|a½h,ÙÊ<ÝÓmJÆF®½3AÊÊb$g´en&³ ŽjyÝ%¥x¤ƒ›ls:zå”no·Ñ®\ºÝrÃå›rêiµksvuÚt´×¶N’Usðvë˜>ÙFv´m;Wï{mÄe´jð’ F„D±3‹“,dZtëÎ|ŒÊx–\ºã:eÔ) SfkýÁÝ4ÖÝ£]Þg’ü¹yY° 2Êf™²?-WæR—éË:£_ê‚Ñ¥œ•Ë¤K&ÏèÂÊ0 +—½ÈÅ/TeNËuÚ‰ÆÅa~Óæ°`&öÍ×l®—¬¥ÙHr–WDª,k>Œö•Q‰á2<á¾y0kYèð@$ŒGq ñ@Pù˜í?‹††+MȘ>,³Ûp•‹Æh!g¦b2å ÆÕb~žÞÕ”‘ŸTYŠ}QÕôr Ir IB‹TÃÇ«3ª2¤c¬«¢€Q¢~ TÍj~rÁfh–9›˜=¹ Ö¤“6Yß«ÉüðÎþÇäŸTf쟷Á®¿>z£2ô³V7Ä»>ßø +8ÝptnÓ“8Øl=}ò ¿ü¦½ñ¬#áÚ­‘ûïéôš³I̦/×æª$þs¦L¼ó2gô¹¬¹TÒYeþžø<ýå@$x˜@÷¹¸äçòj¶‹Hd'}Á#ÆÏÔ-§?õ¯Ÿ´2öÉ”Ðùâ§hÔԳʗmWT'Õž/•õ¼§çŠËõÛàæ%Îõ' Z™þ•yÛQ¾”1YƒÍ–Ýh—aX¾Ú2ÕbÔ/ceº.÷mz™.Û¥­Ì¾ðËHü Åpë´%¹Ìäõ·ìñ%•U•VS˼³¨û²¨»ý‚ÇÓRÆ”å@’I®Œ Ëé/TæZ¤¶Ì±|—9˵ˆÄ[‹°j3¦æâ}™Ö¯(`’‹ªXAJÛr6M»šÍ¦HCuÉ>‰}9c6ææ^íU¬Ì,]5CëK]¥šjxùÑ_îò u_Œîe¯ûeðG]÷,F«eõsz}Ûßè»L–!ãÃ¥ ãHŒ½³öêhç´&5ig—O<ïšÞ¹­Á=†lª-ÚÉ¢“°:‡`òš*š%ÛÛŽjj®Ê ÏÖìÜò˜SÕë¨Ê•áȃé2¹t«t¤²2Ç}w„e´ü¥Œq™c®2¯2Ë›¥oU†» ›–ü4-+îÙ­s³žBkJâÛ`ßžôž^Ìb§.z´ª›™•µ-¥Ìü¿umuó¬½Ä…ww­Šö:z3ûu?›„‡cÇÖzd/,…sL ¿¥,2¶•›þªŒ«‹lMyÏ„Œ'3ºí ÖdõÒ}î·$¶Ë²¿$wÛ{QÕ­ÔŠ;Ú+\…S-ûH[¦m:íÚt'oøæVÖJgyÓ¢CÛ¢ß$msVä’óK#þ÷¼V~#FJÚÄwѯÐù·ãûMšYV_Ó$M¨g‰Mï¸T‰_g‡Gúûóç®è-û”§[9-óÒlE\ªÜOšqg•½Ñž¾7˜šM¿ÜÉS=jC>«?deÌôÎg‹¯èÑp@‚…C *("@0PP !±px ¡A,"x˜€h0€2 p II+gG––¥åÊó»r+xÓòë} ÏW&þHBy´üù”ô+üò Š +*ˆ ÷!ƒ„P–H¨-®ª™^*C¡¬-¬Z”ó}¹Üçíc$}Rµ†<~Œù­½GûDºíe@Ò«c:›ç©ÞÝPv ŒÎÓ¬úq5,˜p°H $ +,*(&<B€hTà0ÁÀ½&(&˜€„(H ÌÇcüs™q×]RI°€Rƒáà@¥Æ2‹Öç©×m/d|Ú®¾Ï'^5®7'×™‹–Ž®–R¦jŒoÇï-J ¬Ú±­µ&ñœA+k?OFo wËXù-`˜÷Ôü@*`8¤ŠÖ‰ YI« €y€°p˜ˆ¨Ðh8f×pskØsR•ÂjÀ7!áÝ“é6ÊÅ—†X9 ¦ö~RaCéT;²7VÉ,k8š¤jHl0Ã¥W‘˜ÁJ¹ÂHŸ+].DÖäf®äa;»2ö©6_kW^–°ììØÐ)×ä³n*öíŠ_·w=5lÑÝ4´+§2a9óK]IzQ‘rÙá5ív܆ٱ.;ÇÜvª45Ö½;†Më'®m&²"6S!g]!÷µfVÝî@&(0O’½¦ª$Ip“I¶FNr8÷{’$[òyIr-™XI»]=ä@’+Mx É$I5ÍUÈX]Ê{Îó@£,Gi"aŒ DNHñFD¢cJÉFzzJ’_ó‚‘ÜÒ$|C‹:ǹå&åáÏéß"±r±„Âø>é=ªž£žûþ@$›o«Š'9IÕ³5­XדÃvH}TI<kÔšÚZV[5„±ª×2Rý¾(¿\dC.˜Xy ® ]—î—2̸̄<ò±Ñ§¹ÌYêÚ·ÌwkPqXMµt–1-©eŽ…T»)u»Ljù™qÉÊKÊÿ-€ $"*˜X$* ò!¢¢ &"É]“{Ì|Þ]"ïú,éÄVM‰—W²†}ÓJœºªÌ;Ö^Dûõî@’Éãk5“‘B¤d"¾L°Œü,k«º»ªêe‚3}aUYª’¥eÚ¸ßeUV±üïîîîNÑ®ô7þü&­[TyœuU幯W5®0³àšýÎØÎìn†vü»´7ŒÍÌä|fæ2 \ˆxüñw¥§÷ëýŽxøíj½×x]¯wE×® 4CEEBˆ²U­•;¢f®Ö(_vçöë±’› ˆø/¡í¦ö!6ØnOL³¼m¹5µlôöåqÙž.¯*çÞÒ™åÑ°±ØKgºÇ2]‰»Ú¥…¶‡5–h¥¥˜œ„XùC®³TÎ'§ÙÖwå)!óÿL Ì×—áÂÌtÖ—¸¬ÛÉ›ºÛ]Èf­É×çÎSå³›¢%Äs + À®ëmp—&¥²¾o£}?Á¬Ñ~‡]Ü™|ZTÈÝÿóLu7FŪgþ|î²=5­=ÕåîõÈ«Uš¸ÙvÚü¼ýO3ëõw£å»·ë, ïeµvýíø~üïžmü5µBý©Ó”1—ïL¯ñ°–ÕŽª÷ôZí´P¹/qY¹3Ïø‡•Èw]Œ¯/ã¢UYZ2âòÍ”ßv™¯E.¼[•®%fáÝUëé|;ÞÂ>»V­qc¿”‰@¸1q벯§Ú̵C€ ¥›º§ê ‘D4&(=,"À€Cá‚ +‡F"@€°p° ¨˜ˆ˜@€ˆ  ‰ +@ Ä…„D"$(&`‘@4"@TLD488€ð@¢‚¢±ŽP¨°… +ˆ3ÀÀè&ˆ‚B‹ +ˆ‹‹@…K*($„Â+"&$&\Páð0(*°*hA!ÅJD4$p‚ÅD$X8D0\@Áá@"PÁÁæZsj[YƒgYD ˜` )!K‹ +LPDXTH ÀÀÂØTÀA£áà‚ ˆ  ( Ñˆp°Àt Aa ÓÀ"ˆThD°0 A >c‘D,°0`02òP#€… +qE…Å,Ì À"¢ÂÃEAÁá‚ÄÁb‚F",Ì$³0&*,@\DX$ +@H8`á@0 ˜(ÀÂPhûoRËTO½sÒ¡á ó3jssWùap  }ug¹\˼ÙÙ]þ%´ºü}­‘ ÷J÷ w_©’œFˆôþæ³R ë°L.U’!/â –Ïâ"ãØÄüƒÐŒÌzi{lïö2¶7ñcM1¯Ì•„QóÊNâÏÖÃ[¦g…ÁúÙ(Nê¤ñ ˆÞüP_XHÅ£z ¼zggÕÛ·»[n„±Ã­;ƽeu ãt-_uzÇjçz8›ó¾²|¦ª‡{Å˶ˆÅ8½ƒõD£Â2¸g§‹;³Š¸;¿ë–åb&éß)e–é:Ö,«ç´-ãÒßÒkÖ*•‰ÚiiÌœ£Z„q™¬¥l–1MDªÆå‚cØØd…eÊ0L©»uSŸó2ÿVÓˆD“éÖÚ0^‡•ŒŒr=Þ[\2µ%NÜÌ2ØzYFu Ës¶Œ•áÆ,›} Œ,sÍ2<Ï2iZ†ï@{¸³85W9›‰[O¥zUÓ ©¨”Ô¬ŒÒ¡S` ƱT(ÑÑÕ€ÞBfÉÓ$ ‚(eˆ!2E)àÖ/™ë“‰@çÓùvõWý »FÆ?ØA˜%êx˜ñêÒ -CaéÈÜ&/œ} JoDóa×Dب·vE/K ¼\Fz%¾È? îã +J8íU‹=Ò#D'ãÄõèuÑDÛ¾¯18†ÁÎã?«ç¦ÙòÈv@•P©}°ˆB!…€˜–*¹ _ëúø'!‘Õ´asLŸM|½Æ˜Õ +žm”ÆKˆlÔ@¿H²üﶾ|‘ÿ¸ºÙ»ìÚ#J×æT’ô¿ ÄìòÊl¥1æ:Íòö b‹(xÝ?ÒćéjÄY‡s‚éP`c/2 a\§'Ϲ֬Ùê<ÛÐpëœ]¬­„VH Rì1Ðçã/´Äo-l½+ j•Èð©k™GÛzL OœKÉ´1àsëAóãÏFÛáñŸ}æµH +aV°~äò-oöCéš—vÖL}~©Æat%r‡3 GAÜêî…èwËÖ.B¶€s业_ó'QAJ.‹ÿݾwY³èݳ÷~ÞSäÀa• +™¸û1Æ7DMãf•f¯šèÌ7%;4Œ|FNè: –Û•bÖÖšŽ£‰MnTž@õ¹mÍ?ôkÍm„Ó‡¶ºü# ¿<#®g”ôødô‰™=ëЫ¾9 ¹rÿjc#JeþÇßÜjÜyOvl¢(tHΈfL»]ò€SÓRÇ.8ð)é89eô®•¦M¸Yí½nÞpŒÇ 긹òƒ„A¬ ãR·E;{ˆËÏ$¾»áœºÜ â‡Nÿ7Õ÷Gœ#ôTI! t7¸Ú0:zº¯{Ìc,ájJw8AFc4.jÝÙÕ2þ\PÑ:!vß·¤¨â….S~¥q´µ†- j\xy¬O™È`h”$ª„ðp¹wù¹ØyIŒô®&p[Áè0jß=ÑùÓ ÎAö{8ý:Ë{Ë]OOÁ¸>ÚŠý( +Mk VSö{Qwȸ^ñ jýNzß`MZ²L"q5ƆFf–, ż• BÎ`Z4¹IBnÖ–XX‰ýzAþ [Y~[õ$³ t‡XÎÍ$¿ær8°åc5å‰l^*\ãr£™P£°¨_AIeΠO5Eø¨2||Çü%ÚX‘àR°$‹7ðµFøŸÏØß4®WCѰê ‚®£‹³0½àÍ›@*V7õµël"0 ¬ô B©1W©J”‚!lAÄ’ˆ·ó©ëe‹-dÓ!À¶#MØꥊ¦Ÿ±C`Í™)¯w킶 ,å”w©‘]]ϸeH"Í@‰®dM㘃Ã6dù»©Ž}c¬ E`K^õHW ØŒn ˜x”­R6LP¾Ð¹&eIÙGð~Å -û)ÉÊÇ ”ê|•E#måHNê‘ÿ3<*]ÎôŸ·nCó)ÂR5e{<º¬¹¨¡ž9#‚>@ùÍŠ5÷S'ˆ ÔÉžÃòÖ…|³0'­I=ëQAgÀç;c<ß NÎà›ÉÎàßP–ß Ù6 Þ°W }| L"ˆqš$@0€BcŒGªÑA0";¤¦|bA—(€ÒCùá#úHØ9h…ˆŽó,ЭLâ¯ÃýCôF+³0iq¥_sŒØA¨Íåy(:ö!=Ê°¡"6Îè–ËÂØÌûk|Ñûòâ{@HpÕ9e~T½­ìñ|G¿ ªH±l88ŽdKo·T¬‘¥« ¸šÂ2è«(*1c¥M{»*(Çp {ðÀÃ<éѨê%6B6B¿–¥àöLw áÃa  cÑ|Ÿ”Ö°ôCG.Ÿý@<)³9Íà®%`¸ý/zNyk“©äÏÔš~(SÓ-‚Ó ¾ XxvDÆêäü*•„ +Ä> ´éR΄êuƒp$ÙÆrm·ÂQ(:´£3Sõ~[/‚jÿ–/ÐÆ8X¢K#†=1HM`NX_Ô5”~£A¢HtÅ°6L OfÄŽbúŒ]€­= Eià°Âÿ€gUå“ÖÄUåªñKsçèJØûAP)0Wœ§KKNγPû†fƒ×k`ÍOT(Áœ.Ö†$´K¼«Š.ªÊµà!ò© +wKS%¿l}˜…VXªŸ‚4 +4÷bш¼dsT‚mªÞ}çáaÔY‰ãà06ä‚)ŽpˆNî;{Î|\¼ÉÐÅ݃,6ÉŸ°Dy\SЫ(´Çÿ¦!gföHÛGåß’umüêÎç•W\I÷½âè6)Ïšyº8§ +©a”EžFð×aÂïS ǹ<ýèe’)=ÄS°¨T}‘ÀÊc@Æ DµQÅøŽÚ A™~W ìý6 ÉS­°ñ”q“Q)U*.ÖŠˆ‚‚ûád~¼â¤ž#A/ìÜE[äˆ Ðð¥â¼ïAúÆBNj-îþD¶1}Oê]råÎW—Ç †ð–¶ïgŸt©ýŒTl(ë²ì°Û˜Ú¢wë"áân™~ üÕ :Dùw}`‘² r°°nd¬ºU4†¨Û¨eb|U¥KÎC@ e)ɶ¨dTÏêªÅTÔ¨ƒ@mÖ]¶Lõ[ÆmSrM™¬1Ÿ%á!˜núŽÛàT“$o{ÆúÂ))Éõà…~ýŸ`Ýå‚Ÿwí~FЊOyB +Ò+fB*¹ Pç³ÕÆNA*¦‡6ýiF’\Ö‡qZ¡¢vÁHŒ.š #²í­@ÛlV¸C4Ûز}Œ õ8zlµ + +ÏÎ Ú… ·ñF¡âRR{"ŠÒ7ŠÁ†î·yÇßàÓ‘™ëTç9%«’|óáZ±ìšëZÃÍ:êã1O0zkiA&–ƒÏ–@ŽÖ.¸ü,·™üJhÓ_õmTÍø Ô9ÜœO ͈Ÿ6 ¢Ó‚ZýŠ¡ŠX!Üù Ä¿tm¹û#ÔšŠGÉ]Tñ IŠÛøâ=ÖöŒ·ñHŒró-ÿüï+,Ý€Z8±¥ßC‘­ê#ô|Íš…¨1ÔÏüDAïÄ0¶Œ.Å̸Væ;ÝÿXòz¤™ð™  ÅȤ*È„e}Õ'óY!:TànŸè89,µ¯¦&ÀE1­Ž;o@Ê|Ç[O7ÕHÌéÜ¿c £n¸}%"ª©7¿½ŠN>·f¶R +´žážEN+7ÊÂb@Dð ÿìÎ *›$c—ã§HJ8noò‹r¥'÷ÊGè¤5x45¤ +¡h^1Øõç#£é—ä²Ñì ä`…/ ¹HULìktÜÕ.n-çš.¡Ü2š6Ñ쨴xeŒüˆôlߺ›ËŽ‘€8ám¯ ;,{èÉi`Dݪ)¢ Ú–~†þ§,f®jj°³’+M½ñ13 +!1Ü÷<#@Þ¾¼À \/=]¬1Ûl vµ–ZÈU“8híÔBÈÆ2Ð"¡¨‰ÿÇK…–qyºF‘‚y–7gSTƒbœµËa)Lö(¡®PòU„SWJh"ï»ü0”v‚Ä·Se£ž@·•)ár¹™ +÷0\eKX}÷ãÜÉÙÍ@s­jÊÈ£¯W:¯I[Â,¸ù…Õ%ºÃL%Zͳ¢°O~­†ýš! w¢ÁhÙ&h® Xýö K¿ ûë”1ѳ)õ‡4‡`Å gø²htIBË©c$³Zõ‚… šŽÚ›)Oïb„É,QcÉvŽÛóÚª®h2C–„ áì; ßýó¬xßdÏ?Cù-î’<Ÿ=Hm¬È†°¶ô &6‚ãQ›¤á_8ÓVz‹ß×}-¨àÜ¿ÿTÅÌZM¡wIUÿ8møDÞWdÕȤk0=xÖú›Ÿ¨XÙ,=ÛB­Y5tÂ6‡H ›ÒÏÐÑã e4—î[út¯Ü%Ë\ ¿´Éõ-¡T4ío^¦L¡HtßÊ“„ØaÁ° qûrC°X:üÖÝl¶iÓ¾JtrÄF¼«L•PS´÷—þLýÜØ=ÊGŸe[è%8ˆ8_ébûEh¸¾mªJŠ­Gmm«c¢³Øéü ß°½@ôJ€55åuΑSMg +ÅU É3óm, –$΀Jd†©q{©ýì™^ñ¯EX¼ÁtØN Ah%æßìñWË؈‹wÚ‡*¼¶u÷K{ðÔšÍs_÷[† ³’ –(ª1} ûáêûà +¡™Éˆnnß• +­`ȹÝI0±U.éÏbý¼JéÈN¿ Ó¹ª6Èw®d €ÀU|‘ž‰.º/—ÝƳ{Û8‚x4ùC½9ÖO±ƒ3šÓŠúãØùÑ4­ÆQX±`Øþ[æÊÊzíšnö@²@}Æ…HÖR3ídåsþÒ¦& eÙse¿V‘ÅÉ©Ó`ÒùØ´oÝÖ6V@eäÀs[Aº,—êd{ Z̲1‘lüBK›Ù‘Ï[Äÿ‚øò*¶¡W$©KÌm•“JªŠÛ"B›’­ ¶ ïŒ5ŒO²ßû·îîïWÕVY}ãÈf—p_ŒÈF?öRÔhSÁT¤ ÷E¡žÄJÉW]'ŒaÚøÌÙªÜ)h +¹0À)´Dhž\#¸ëó`"(ï‡Y qñ;,àiìá/˜ÏtÖ‹ fö`¶­²œ dšJ(úx“|?‡@ý“pbO™ö; +‡ÌÝá,|:ûÚïæÎ3[F[«çðQ_¦‚ÔãÞÔÂLÁèz™5>6 ¥‹=%ú Í8Ñ´êO[Í! ¡Ÿ÷»šõÍ×£¦…49@º°2ׂRË'fBGŒeüé¤/H’sÐлr†âcfÃå´ ‡«Y°vPïË—<Ý@f+0[ã<ü5·'ijË$'ù±ª¾ñëÉ»ıt/äs.ÍÛUÂÁà¡ &>,'à¡ØQi*ß DF „ÒÑÝ~ùP÷\ˆ±á»-"é'UegWÊ´*”ms Ai‹VÑ{ÇU6šø™f¤ÀÉÝ€Œ +ä›u6.åo'NÅ¥ÅSî1˜û w23u0rí«;Ïœ[ÌM{m ‘÷_lÐM\}‡S'Q–Üí¼·«…B_ yàÒ®ètÂ퉹2´M Ce/;Ü lžNŠZÒþ¾ý%:àã«š éûAò©ŠúÃÉ3«‹ oörX?t¤HÆ&ZøWáªÇ}š÷ؿײǕnà+ð$#¤¼aÔ^°P.{Š-Ö§M­¤a(øNâ}xÄ;¯ÖÔè°ÌäÅ´#nÓâÀ´<ÃQ|ývÁ‰Bqª†-!?¦´ÉŸoÉæ!ÈA­N*qäe—;ÝÏŠ*Ady‚)BvP[BmÌZÉêìþ¯{Ôl1³¹f—õ“ž’èJYÀIy¡ Þ¦bx0à`ýÞ}ºvÃBuô¶*;@r‡ æ9¨˜ <-.~Õfh vc;ÂèEÒïÐÓ@óËTrµ@LA¹^DJ1únv->Õ  {yù°ÂKð®¹Ðk^›ëA$ ÄÅ°=Qå nòZ;k~[:¼ •…JªíÊX/âì‹YµûŒfø¼“’ãÈ™èêS«9ÔÎCûä¥^€Í(/¤Bê7«P‡"·+Uý5;èqÒ4š0ÇïÇf p§”è$®Æì Ŷ½pj‰ÂU @Ã׬‰ó q CÇÛM%ÇîÕ[†sFèÊ ¨=qñ‰\àŒZ8úQK_dªµG"q*jUµ$å\ø†?l­J[\ +>kEQI%\¯@èþ½S0¢˜À«3×ìïâq÷h÷óxÿã¬ü:“ VvëÚî™ûã›ÐÿÈ–UšÈ- +e¶FþþåÖqïÃöú>0]×ë¸ +¹ý~Ñ©ƒ#ÑtÔøëx+Ëél澩|vl|›&ŠŠ†Sˆ§‘ŠžÏm „¡¯E³®-oh$"ôg~(IìýÛ6ªR²/K–<ÄpUJMñ¢“éð ÊÄ0Lƒ´ÍA’;ù^Ö|h %Œ’šW/,ì"H1—8ÉIŒ¼ô™0!ó\º™ ÒX*°in¶6þ÷ÿÁóé —±#ãäØú÷Î^Û¨m<2=œíÆ»‡¡£«—hc)6žuØ 7ì‚sî«æ7>»•œàif‡x,íËÄ)Št›/šW_ɺéB“Íéé•: +ÂÈ°bh1¤–€£0q•z/úe 椇“5P 觳mѺ ÆpÙ§]Ö)5S±fßúpâã ¬ldÿqc·bè¼ð.Dý KëÓI®Î(ß÷=~Ü‹—56Ö±s1îÆoç¸ò£J@)4¥¥ÜBÀ|ÝF!—í]hÍdË\é7ÿU¥ù zÞ«hÈf¿·NÖXe×È×H÷Oñw]ÓŕߘÖJo–n3ÝQqühˆÑoÏn@ž‡;qñSvèÖ÷àÏæ®}ßìá´»ýGêuÄçÔ@Mè»?ů­´™¹.V"I%}dÊðËC—£ÿ=ëÕkÒö€ `f½‡fU¤¿7$´¬‡ž’P×GîbáÝê«%Ý,Ùúå1Œ¢ãz-ð# >èŒàq¡óè‹7u»cÚ¨D'Ë—H¤ÞTí/çëÔSkBØa<-%Ìf ž}ìÜÛ¡œYS“ùж¿9ò†jâÍoá(®tlUÍuxÈ •ål,Ö‰b(Zp\ÓBªŒ¾XœÃYÜs¢ŽIAûÓV{Ô« +¦)µÀá¢V0‹#AÒXÛŒŒr‰Têœb;Ùç/{ÆC…N»P“C¡ðTô®›’“xlf§Ÿ›I?{¡ ËëÂX1NhD5„Jµ6 7#׸K¤ùš@sËC¤0Q…F2Lº³LçÁv(­2kd–4#WµRÓG®ºŠM§Ï´-:¼s(™ú'…£.k@˜Eüa¢(ÇžûÙ[1ýR·ŸSdB>mŸj§ð(îžÔ=ÓO½°.iH!€*ë×N—úý«@3Ñ1#eàÇØWN@ë{muOâ«[Éá¿‚‰Óð•aç[/”_âp®4MfîØð¶ . âøÜî®!]º ç¯*ÍÇW·´ÿ~£ÒÔ¬d¡O á^Ž‡òlLk´ÞK¦Jv“õ²~(ÛN/(TPÔ +6üœ~3deé>å·µ³TƒÔQêìe};w"€Z™€vµWÔZ±‰r†šßNÝ€ˆè¥=ˆPT½.%k˜º”W½À…0Yx¬¸#ªz?a4µ´þOC<øi!/ ðVã§]Œã/&÷hwp“„,l9 [ki¯] e:Úoî$鉒e,—DaÒÄecOÃZ#Š\²B:^íãµ…õÏ(’C×ѧ>ײª•ºä›…g×±šÛàMÄ'ð2ä®X'¢0Ê!ƒ.œ9/_¾}Í ÙÄh"+<× 5PÔÆÎV˜B<´rô5ýê4QGp°= š‰³TѼìÕR’ëJÒ‘£Þ~@Èá#¾\¹µÊ©îõÁ‚0ÜÿšGÒ¾,L‘2r®§C.G’·žfpvY¶¨'ºlÙâÏp~ïQ·}'‰G˜[ #'þ­ž«óÁ_ì‘Ág¢ÅüóÕ8GykØþQ:$X(` +(-З³5q;çfVuáó#Èt*УŒÒŸX)'¾² °Ïû™2pÉVA™:¹jÄD€c»ö?P J›]mWÛ9A²!‡éGSî!ªÌ6! î:F£§ ;ŒU—c;ã`ãi›³ý$w0ù,G¬­ ɫ´qô=è4àš$aaÖï#©¢‚¤+ âòROùe©~ŒÝ0Up}ðŠbY`Á…v¤†×››ÈÜgˆÐÐà¤)Ôhã]Ía鶠·r ·Úµ”¦ÔQr¾«Ù¤›ßÀoØRwV„á³ü2ZÐWáz ‘Ç*zz~vou.wrL™=ØC¾¼kp‘J<˜wŒ=e«mg!Ï‹-—©Å= +¢ª Ù]q¯oŸä[Tì²Øê–<¹dåð?H »O×0këÇÞö4¶Ìp]DMÞD*nüØ_Ãc£NM^ De]¢}H¬/xFøt §ª™Ïú‹zgf.ŒpSL]ÿϱîƒé{‚¸Ñ@¿»nR€Å9*mW\±Zn¦„*óVç¯îµLBIÕ  ý~;ûu˜~š^Ä:ì”%ô…Hõ=Öê;Ù€D$ž§m÷ÌÎÅQN8½Ë¸LŒ_ÃyE÷¥Šk>Ï üòm¯*"8°ÏJ*i…Æ%[%ÄÖõÕP^‹cE`ÂhCÍ`óäEqí^È(®ÉÏ$Ž0(.ΖŒ ÷öÚÐp²NÏ>Z}ìŸÂÿ¯UIZ'Su9DÃùRpþgÄëÃÃ3Kötöæìɳ#'‚(•¤r&qÜ”*=¿àH¿©â–¸ +’_IXà¸pɼ)‡y.~c™I«FÈÄð³eOÊÄ šËv¦Pn Qzr‡šn£<=q|׺ö„C,5à7»ò³¿ÜÞÚkBù[”É–±hùß e MŬ¢MF#ïD{6Iù³(îi4 Coù@?0ÏX˜z³ó¸º¹×ü¸½8DïÝÉ<#H„˘D¨„!ŠF0}yfåÏS£+©:ˆCMüyê™úõD¡…í…€¡Œ+Ü  ÿ‚U­ +#7,gE€›á™klDÕîûyÂ"Â¥ól&‡‹Š~5EržyÚÝënônÜô̦>ËÚ¾ªõpÄSÉå ‡kÈFa\[¡·ì€ˆ(ñö¿}Áûå} ö<,)=ü!Ç$šü«‘LÜdЊaÛEºÛ‹É:„ìoAlü¼by(?ó2u/ø¡ŸIä÷Üu8TÜ 0Ñâ+;4lô§~Þã;”0Ffƒ4€ FK±±%÷@š£n^m&ÎTÒôUC90̹Ö\ÖûîdGO3ÿÉN¾T”j›ÄpÐÕïg©t©t_ºöÝÂÌŠ5_c ô‡Âúm­c³…Ônr´r$oå?ˆ0ÕSZ’›„=]㬹zpD´_£"týd„ _ÜÒ®3}7%¤²\4psOÒµF•®ÎÆRÿJ¢X¦’e@¤_ªžOh@Cd#÷Pöóêiˆh¬†¬} €˜²Çº^ä<êŸLÅôÀ€3¢|e¼aª^'øÚßddÒ=h+6ïTi،ʢr.qCpµníRQS&¦¦2ãaˆÖpÁ6‚¬#»‰,ÿ0Ðõ-:7Á7Ѻø+B¼–­ÍPa\ÕªFÉ­Çe™ •Žœù;?jö.ú_ª@qD½oˆ04yÖfön° pK íDHY`!ûÎYs„´ª²Ç§ õeÓ%ÊÌTÂá?!ú›{@}Y 8¦²+~È}%¹Ä¦5ÉŠ¤ž¦çïa¼ ÷¶‘»¼zPgµŽuª£ê~☯þ-¼üókDwµ$é|K½Ä$â$Æ6i‰^dµ"Z¡ (Ñ*ÁèÆXÁïšKÙ8²ËÁ;Ïhk!I8'þAd¼†·/Õ܌Â5”±ºÙºp‡n³'-†FPSÊŽª mS19uºÐ«Ç1Ú·1ŒÕS _fökî{”b–h yZO™¶}‹©@4ØgG]Ï­mÃ4Æš]ÌXiÙ IìÞ †‡ñs +Lò;s¦dl]oM¼Û†ÝO[°ó¡¼.|º³”éD”§'Œ¤P¹n£½· ½ 9vÝÁRUVrªv^í‚úÅ&ab©}öÝÑ;…‘òõïïÁ-L%-—пàIÓ8þ/@Þc2QjõZtp`è¼R]0spfo?€3kŒ%Þbl-§¾ºb£ߤü‚æú²ÁM^tÿá/´LW´Ù/¨¸†ö&¾­l²iþ…Éô-põ‚¬Sî¶i˜‹nR2dv>:Áîuµ Â×\LH’Èù|!zïÿÂ¥g·@¬¼¦¾kåá ªÔôÿéÿÛ&;ȱôX /‘Uj±Î¶ ÆÀÎðƒÿÌ»¦RWS +1ö)êÛE¼K¼ó¡áKŽcÃR/8bIà`O&Ò_FAý~aohòpGrh¿G`xˆb²J’Uy~€æí\ãŒï`+‘å_>0gŸþN@tT4apO`8ø×À\¦TŒó¤š¥©öÉ\véè«r`P|ÉÿHVÓ0‡´`¸[ñ½¯"%ë ´ÔšR%kÖL+ð½¡Ã‹—ùj¯ý¿¼•b·C`iV-µ …m»=çgʳ=)¥p¿n‡† ð ÝÖk?kŠIehTŒ›å +Bª½d/Ò.(‚x™ ⸙ƞlµÌóò -ù¾íÊ- Œ;¼%oˆá +»:µp-Ô-B‰•o(}*짫ÃEû&Q;Z/ƪ9ñæì㌋$Ä<ú-ÁCc–UžrÖ·'©Ö#f¤ËMÒ!BócÊq7ÞÈ–Ô=µ‘˜<{’Z~©ã™6ëg^×ÉÊa‰Q«ñ9ëºÄ,(~Ò-ºÒOn:EL®û¦“(b€Jnʬ_/ˆQÏóÂkÁ±3‡Ì!Ȉ7ËÔÖ ‰côŶvi.{:OWËZbÕ§fš î¾GZП:ýx¶vJ‘õ4¡­t±}÷ë eqͼ¶$esÇp +ÕðŽÏ¼¤Ôp•¶×Ó1"§ó”ïÕ,·_±½n!²9cöhÕVP&}ßÜ4kbŒ ¶ÑOh)æ|½w°›ýeYÃØL”ʈَӤÙ/@&™Í ÃÊ„§Õ•Gê%ûÁ~èHˆ·¤×M² ¶äVŠZæ8UÌvË,aGzc0Ö P‹œÀÉÉe#\±ää¼g«b³=CzG>øXÛ—gÒ˜åØJ{|<ÊÛ.ùÅàÅЙ@GàASÙ´€öõuÖ f ™gLÃo‡°KñËÌ„‡ ‰Ð>I!îdÁÄëhahì*ìè«‚- +"ò;6ÁWéS_2{±­~­ëëÆ×׿úz©ø0=]nè+¬¹Dúv„Èôœß!|ɧVšÄÓ'Š;5ÅE<:¨úD\b³®‰BûÓª¬®µÈû#Tj§÷AÝVá÷&e•°´‡ÀG!½>t•”ìÌ ç@/Ìé–¥/ÿ&EΓ÷÷íù¨ËT…|¡PÓkÆ à:ù½à)s‚¯[–½¥\x¨«žíVmýÕŽ3ï÷©sˆ×nIªôÿ*}( åé7gÀ–®Ú-ï b×®™î©À 'ådó0sçŒõÇ1žr½ôBTÚ—2fmã<ÀE6±è-˾h$I°!¨8 6 Ø#ð3\_j7 gº %Ä+›vt‰~Íýñ¼ +y$Œe0Wüté³åG/¨nfò~b”ÕJ à†=½ËqÓ€˜¢CíÄ +_b_Cl˜_¿Í).œùØ<žN™óRçÓº î?ùrþIÁK+Ô¨ðš$ðGz·ó%Iy*µÿ ¾˜Žê±-¿èn?'×S~4‚pC¾<á9E*Fò@OìƒÄ9Åû_ õ¡^gáäþrÓüÿPë0;Ä{W,-/àцU] s®ðÇg´*{ky².ƒK•üÙM>Pñþ´Îªå#Ry‰ÿ)‹yw*®ôD“zx¶O44N-µ`½ÀFpm£àÁºFToÒþŸœw³&Ÿ Œ$¤É#(t¸˜ö«*aŽªîSùàòк©64¸q©:9©ïôˆƒ{PYveòñr x}jkiØh#¸¸ xëŸÓŒ[iý¶ù‹ÞPI‰,œ"›·Œ—2ufùz‹ÁMR ÁŽ;ƒyq,J@p6ÙÛŠ{ˆí”{xþ݆Š‚ ¯€};‘¼üH£+žàÒ>Iß ©5ê„O–@7<¢`?;aÀlÄ”ÐÎú·$òdnÉ °R\O“0 +IÀíf&À _)ýÁìF`dÞ¨ DÖf8ΫÙd8ZþÛ@…y[z«ž7‹ÌG }!èélñÄKY¦ÝFùÛ3à%«ö󷇔(@Œú2†#ÓÌOzZk+ÃŒxÊØÈS¸os‰à+@Kã(m.5Ý þc©o•´"/ó4¤…5܇‘o¯nÍÃ׿d[ÓÏdNñŸMÉÐ3<‹·zÏÒäôZ>⺺S&æYðÀâQx/s:vMøÌq¼l>:¥Ûq©?g<«sÔ•ñØÔ[]J¨Ã³ÞjðЧ@‹9-‡‡éN ª߉Nc@LÍØp#ß=+#ÙeõV!}½ÏY¦Rö·²Ogb£ILøb€D–½Ïݽ-¾¦õ8˜_ùEöæwF5°‚Q¸·b!…í-bkŒ÷6èPý¿kªYߌž>Û9}?Ý+zlØi µ‹µ'ž)¼nlN#JLÞèç4 ¢bŸÛk²+(§;Í ¾!:×¥âôñýÐåêÂ鏀úà4K0£Pœ./qà4=ì!ÁµÅÿsýb"N2K[œ6΄ú¢Ù΂ô –Tfq;!ú<íÍlnZj× ‚1ÐwÓÙßYªvºoº‡õbI¦má’¾Þtí1gÉ»im<8>2`GÑÔZ©^#èõŸ‰bgɽŒí_XÌošŠõsfw 4îNÀÓÊy½‹¿ÅÇ¥¬­†Ïn\~R¸ZžUS2ºCôÞô-R6 ÄÄÅ>ŸƒÓS…:çá4£6¥ îü}‹+¼ì% §¿¦w‚XÄÃÎrÃ(Ó9­¬ï\aÿÆ…çëO\Y±¤*Í}ö’’/€èKõ:ow™ÙlísZ9¥_þ[4º4ì.ó¦ÓœŽNÓA²·K:½•'‘~mÒie)H€üuºƒ‹àe ;Ä3es»tfžÀéûV‚ê_âHù(i—¼iÂ)¡´0Ó5©€ò½3úU±FkŽ:§DÔ`cywÓÞ:8Ü4a^îy‘íj ªéÚt™-Œq£ŒÅ-ö×@VÒuŸí8MCдmúIâ 5†g¶ðgKH›çì§égº¾‡–¸ÔDœn˜öJÚWqZlÔ•e`º§´ØÚ,–Wg:LVd0­Ã{·ô­xãkŸCe¼¿…]²ËáÏF¾Ó kãÀ(yNA½:‘ÿÚY9HŠ¾q –TA1ŽP·#°|ÇX(W ÇÖÒ:rÐp|[Z¨alºËTˆ 00ÖIÜ©ðò „à€¡¥ÿ +érÕ—þ©4׊Z.Î'À¿| +ø\fÑØUÌŽÆ)x‚Õ¾yËæÕ$‰€ž+(t…_ÒoWÔ m™Ð”r«îüå 0£½-eÊä‘œ/5¾Ç“ºyäz*ˆ6{m÷q ßàÇŽ³øhò ¬L|Ð.Æ8ëZÌvÈŒŸ7t+ŸN’+¼Ð.‘`¥ÓH@E¥LœP{·0»ÏÛ‰'iÖGü`ï0%G1†ðL×ë Ãø.¢ºnO÷»xrÑàT+F«´rà×Ɔ?삤M'ÅÔÿ³\2m’Çycˆ£•IÔ1äb/Fµ6$¥§ +±èµN³œ*Ä·×b¶Ø“Ì5˜ãpÈl‚¤e.<üÀ^– {4À—)ø†v£œgž ±_cfŽ@‘¨=wbPšßb0°ÎúøÃP/wŽ +]ƒÑMõüÔcôD2s¥WT &¾.  É¶ðy7böNm¾F·(£û,@TY0ñ`öÿ*|i>}0“²¹6Ýç€ôÅ–Fªº:Ú›gJ† Ÿ‰ò%{†V.ƒêw»žWoév®L1z:àWKzÂúq‡Á†|ñÇF˜dÂ'Ж Û +±÷óå°§è7S$@æK×ýh’×Üçð +ox´®ª°ççâžgÿ:SúÒß~rîà(™â&JŽ²*§»‘/? éö«Ò‘R0FAvšK…?rÏìe줮0^Ñé{l’£æ²¹ –£9k-Ò ¤d¬Éu ](² çSû5nDhõá½o¯´l€<Í‚ ¬ß^™î½ÜŒµÛÛ~›x"MËÄHl½ü×¼7ÿ¯”á…åL„¹Ä–\Mœxl”ê+ÛØG%ªpÏk—݈±O*±Ö}œòiùÝÓt{¿.4ëiCŠ¿ó¦î^·^²¹,!¤ oÒkÔR‘«Ô’Ù£÷ÌI „abzö''Pš­ñ:8Å鶒&šiëƒmÓ}”Ëø#Ԃݪàai©ð)’A(üŠB˜·ºeŠ3¡BAr ôâ+h[>vncˆ¬%p:ŠÒŠÏÇU^­ë +ñ˜Jê %I]Ç&I ,£AL}‰µ4,w§ØU³ö}8d`²+%6Ö­º¬/…*Þ7­Ñ¼;Šm‰8_š|ï‹>†ÈèO'~{E“b-4´ƒÖ`5Ò ¹g] ¸_iãæíÈ:<˜¢zÃ^i¨1àÛ~¾J6î'­ ÁõXôC¦j1­Ó 4kläM—ˆ71Ô§Ýèbx]ccüo…tÖÓÊÚzÑ^æ׿/^whU³†ÆwÂÙž’çŠÉl^·PÆúדÔÄh$n; b?ÿ²VIY_‰ÈÕmþŸØ:7OM&pW£cùÄòÛAëÓ‡غ®©Eefðh‘ùó¡–6Ã-?íÖ#úàZÁe¨bÉ !X•|Õm%´1>f*U 8ßÄ2/‘ëà1§–*Úì'â$(>i»X ÄÓxŸ‰ýFo)ݵP¥EuÐîü-:sÔº¤Y×-®yLnIÙO_€ R§¬—$KÃ5d¶D£Q2Ï34û³½¶#¤íbþ ŸFn§Æ¢ÏÊp.c9 Ñß_‚9}=ÚßåÓ6,¤óÎ4³¾#¹‹´¦“pЀ +žoYêÀXœÍ·jèñ(ƒ5×3¤&_;+W3n’—x€¡`K«m‡üƒÏ]FëÚ¢]£VÏ’^+‚0‡KTtëF!ô°=º4×ÙÓè—;ƒ#Y¾µk ô´Ôï:6@ƒW*1þŒ½Î—„jøgñYö\ñÐg6(t36ëMÚÏ{[Î½Ñ çG¨©<‰ë3n¾ä‚|‡&Èl(~¨¦óªïR#«¥26AW’À£²Ñ3ê±\kÁéZ$Z‘ÁK)]MÓ ¼³_e³›/~s-ž{Î ’ˆö'VÂ#è`{ölEjž/´wL¥-‡X'Ù¨bŸ¢w›“ô9²0˜³CVlý«€KNb$¨ŽÂ/™¥¿¶d1tñ3Z©ãË`uA.dßÝ[ŸëžªDå ’„éÑ.²î­?ùHÉþæÝîw[ŸàÁªQc…JÀ d£ÆÚyè±Ï‚q€_Ì®Vx«)ïMÉ#ºnu !9Ž%yòͬ¡É¿°Èá—Œe·M²äÊÌa„ufølMòÙg~Kõ×o¦ãe7¿Æ˜ðC¤& ‰¨¹áƒ-b""éZü#Å ’¡<;)Ðæ1ÉûY™R—H™âlzÀ/Á¥O¾ÐÀË£J}ã¼o—`Ö1·½¬o“= Pä)Mòíyfœ™åø]…=S!V„5“ã$>L û¾Lv$Ôèí€Î-/0Luܪ#À+ï•®4ˆB·wæ¥ÇBóD.W§b²y1]ÝÞ9<˜¬†€”93ÞêpCd‰È©Xrˆ‰<á>":ú銧ðl¸>aC¬ od©LBÍGÜjˆ\YæJo6ñúøa„=n*ÿ)ÂÆÚe t +ÕÈSb6g kcÑáî9O~Æů"³±ó¤fL¿˜¢ëÕó b`‘-ÖåFÝuþR!í-Ã":AM³J’‘/C©É©´G–·Í§lÈx0³.÷%vˆ»ÕQ?ÄQê÷bâú5?–4 +3§m.û»Y[7ˆÕ‡’N‹® Íâç@†’ƒhNƒ˜÷‘Hk`€­áD}ËX=秞æVé‹gÙ„Ãæn ,Ì1¨ >–=¬,5ÍB#=5è“ô€ñ—Ë:¨RûV™¼°ñÿ3×Jê'µ˜àÆòÃÄR—l6Ò%\3¨ˆ)eç>ºÎ’ùÀOÁU_¥á嚟AÏvq’çh/ò€Ó(Wù;Ò¾‡Šú‚^´€¶Ñ õ˜ôTâ.‘s:’ÐPÖåƒsÌb£Ÿ0ÚB?zµv¹øÊœ^GÜM{’–ŠÏ1 .Äšüp (Y®TÜŽöÑBª¬ÑàÜœn?0ŸßXQo¼ ~ºẌªHµö€öþ^@†? ±9L½OΟL§ÃéCyÍny«O÷8¦úÚøŸû¬ð|cx GÙâ­@ŽŸé 2~ú|‡ ëª} ÔÜO?T…td9ÿ@WžïðÓÀC2þeøÕ)Rµ]ØSÒhÐÍ -8ŒÒg +´;% $]ô, •6‡á ÕA¦L,4øÄ`¥ìp˜f¬É0¶\Þ8]5^æ:¼»Þ<Чü„-é-— þ uÛPl²U±’µ¸´ ‡I,»'ƒž/RR|ê±…Þ‹ Ã3æL±=%ÛPŒªÂ¹“¾zv­[ÚðíûW”ºÁ‡øznòÓßAÑééjH ” +ëIÂœÝQÀµdŠM*ý`’@Ù‹‡Êþ±¨ÏNu‡7Ö=êÒ—k+nó$²ìÒÈ‘M5Pš==ðˈ7€«î9lë¤3ñئ•¦ri¯kYvȼ~• A¸b¹tœ‘Lk¥žŠ 6 +Α6ka +^ÑJÐX+†¿ÀCÀA «Q‡ã•={PlÄöTAòŸJ;x¸¯{\A’LG}c)oyõÉtu0Vª˜Šè&”¦„u8RɪF#ñ€Ù·Žrw푨–Úkd˜?é›-ðZ Y•‘ÅRôŸzÎ!‹«º;õ¨SÑ´,n"êqq;‰Él $jòªSÜïù¶û²ëèÙÊ>Y‚G TR„Û­ö.êÜe‡†J>^YŒÙX)€[Á„ZLÙŽµ~Ïé,¢ÅM}='‡Z9ټͥ ~ÑW8ÐIÖ ’ëÜóCÑo¤»á7Ž¹N<÷3óŒY +TÙñ¿6‘6ÎÕ¥Á¨G¬ÎÞp‰•ì¢f€žÀ +FÖ²F˜R°ij°\=e™»(+eî™1ž÷Šnƒ7  Ä©£õY丆02ýp[á/ƤÆ@ÎXW,çÁ*®òã9XX'à6}Ò²7s5s÷yîRÄËrŠ¬t¼\U*"¹Èr­\ÐÞ¸4̸K\¼¬«$¥–ô3¥Î³á·=%w³€±4­Â´,>ùEe9¨s )ÚJšµÉöÁ=¾*<¶ÙvV(Õ½ÈN?Š=Á–zeÁG׃—k¿Pj=G†£ÛRMV» ="­hà0F¹îå< Ò7-[“ü"R“xwIfÈ°¾™Åɳ^7šºS±¯×‹ùI#qs”Ti[‘oª& škú³ï^ì ¶Ó‘ƒ¢¶ÖÐ8|P4 ^½º.¥×¤Ñ‰3bòÑù5é`9y÷_Ð=ûA 篽• öÉÌšÔ'[Û÷Åìt3ÿ&<#Þh ýR/òÊÝ@«¹bT£ùi1Þùûòe¼}‡kƒSR.lFÞ\9 ÙfXÓM—°d:$ãá¯v „8Ç +XZµ5ÉÆ Š€œV#üë~£îš²cûmQ„²3~ßѽ;|É„ý5íùPãò}#Òhb%çŸT˜ÕåÇ"T‘Fé¡sëüPœŒñ ¼J'-î,û!ÈaùÜ\»©Û´CŸ¼­[í¶J²lÖKÎBÍRš„ÂTÉ PÆØo… §aδŒh£†Œ$õQ‰°Fd'môý¬pk×Ù+F­2ü° ½ha—4w…*—†ÙI¢` GŠ§FJ›O´iF¯¢™ËyM/Ë£N*xj¡^À?g‚ Ëû…“Ñ 8”¢SÞáV ëÑ5ñó¹QÛ5?júebˆ +íOnì¨1ÿdR¦8ëá\W’…÷TëCȉ±¢à¤GˆäM¨ù“̲Þ",¹ºÜ¾à,®Ä€®H`9qˆvpFjêa•ìÐSñÂ߀½ÁU2X1Ãc+§vjÚ–áýÞÕ!‹s¨LP®íai’š4©7Ej\ÿ ûkÖ  '‚V3j†Bòõ$³”jlpö¯œ4‰YvZú=ßœPc¬ímwËE [3. íÓØgË~E™^·4¹ÕýäöÓƒ¸¾),áPÂÜGõÙVn#µɨÍc,¬8OŒÁà]ïË“)ÄÖ¤Öy=ãªÕzŒ2ÑHA88°àÏmFgïo±›jc€´Þ®k´VÚÑùús”"û)"~z² +৔´ +¸å„J½†¢Q?rOXŽ eõ wYâî®8Ë¡xôø!`Þ’Æz“^¢IYwvÐûÖ!Ü\Š]ç‰C»”°%0ÕÖ–çà"„ñ|Â(a´ÊЮí/p :–<êí,­ Q=Ë:â½}‹qô†ô˜%V¤%J•îÐúÿšû:ùZã „.C:™¥ðš~ìôR®e¡–S¾£€…ô­–#ÓvÕô-Q©‘Ýö°D†Â^7^§%øNQq©}!XEWjŸkHÐ%@¿«ŒëŠvÉE_•}™)‹gGx6i3_ÇFëš¼&<~Ô´3Šz8À∳Pš]ž‹{ÉÕ¨hkë‘„›*øYÍfø\’Rû€Âÿ±¿¡8Q»~q”£ÝÿkËò¨S +ø½jôì …3 ª#JFCmˆûêñ0²»·tI˜YµÒB¦Çâ-@ï°ƒGdÂíñZ0€FuŸb'÷(¨nVBvG¬Õ7°«+šœ.ýoÎû +L)"j]U§ µ†RÀ}vÌài1!i";nþ…öá +}ú¶›'¶ÿÚ§ÎdñTš‡êBÂÏEÕ_ßM(—“n¨ŽÞF‰‡6÷Éà>À‘I @Ô® m:j”%©†v nt©oq?^Y«¸ÆƒvŸ?h¦Óyt9Kz›1”‚ÚòGý_KVõy},M¦~€?…ið(?A ÿi{’Dµ—9 €[îRÒåèËXUŒðþ)¾©I²lõ ˆ»‚ašR5Õ›÷ð;IIhdì‰{q YOCéc,Y} t)Ò3¦E?3êp×!Nð« Þjª^‚ñ£DðV33Žè¿¶Î@®YKÞ¤¥«.M*s¨î;ŒØëÎßmŠNVŽGq!¼šƒn°O£#fGŸçê·Ëö%µåm)‘æ°Ð5~xlSóTm +!–O“ÉYÜY³¹ÎÌ©õrRr§ ±7-ç¶?IWëŽÍaÇlzaït¿4¬s;ÒrwÎÛ‘èìç3l¹[^)Œ™G’e™Õj8'é•ðxh6màVá-º¨¬-|5ͺð*$_w”¼|^=½i^2X;³t” yoJ ðòSŒ|ñÒØM0²„êVIûúCjOÂfB¡­3_X¼OÔØ8Ùk‡­dn èn×Ts3w¾ajþg[Í5\™ ß-NÆ·CÏßàÃÂ?Ñû2ãš âÜw„™£•Ô¹ÿ¨ñ :ª$˜‘òÐëe+çÛf¡H‡[&5ï)ècE'õh8˜àÈ¡týº½4^ŒúùQ”˜8¿¬íênë4“‹@œ¡NËL2 }åÚÌKˆwÙÂ;Öp@¿–Q‹vcV¼W_GÀíä}]àmòØÓKÉ™`„-øCË¡‡û(q +»¼’DWЃ¸vÔbx™ë·í{Ãû;MšÞ0Ǿ Á¡CßRøj»3hù„:U^ɨ®l¿ýæ2÷‡ú¬™k!%ö$’3C3µ9Xš.kË‹jUºO¬é  ZiD”ùÀŸj¦« vóVž`ܺé9Ÿhþ™ a~Ù$=B`tŽŠ)àÅÌêtÃÞÆ¡ÃfÜÅtíYÿ)2sâtç b㽋gãÖBÇÖÁl‰«C4JoìühhB+{Ë(hôÏgéXF²å]A1Ô£‡ç`~ºÊerãltµÒv%xwÍÓ}óeïY.Ç­÷'^Vã(¡Ö‘YÁFúv @v7†V´œrÙ=ÓÃBˆÚ6è#Š*q«h”Oks%Qo¾1@ð.ò?ðS|*—)ò)ÉÏgƒj ‰°+GÍNßšÌØ -‹XØ_‚êEøSO„_iÚrM(éuÛ#8tñ÷÷îŽm;Žâµòœ+³£½5Hcì•ÜÕœ5ò‰Nà!’D^ЮÉÈ·š¡Ò4VŒ~ûås$mÄ„±~ª™Ñ+\€æº‚ĉ÷:¥(ÉÒ«ˆu„R†AÑ à@•é‘ +è^’çÃ/±gØv¤Î[ z³ô7ò?þé¡øãnyfYܸv|À(|Èóð¼æÎ:uLš…a”š7X~âÞ +¯Å{½.ðÙ© e~±y&Ô—^*¤ðÃîËÏSýÇœ§¸ Ÿ°[x$ +šA–`Òé°U•WÏžw7eÖPx'#|Àd<Ú²ºwAòp2­§”'°îÏÃØGÅ!Áf>ÞɃ]g†Ä”;ôEžêi­jjBôP(ºˆýÙ†sN»ç)Þ¼éOõÍ]Z¿ß§•,#‡2šŒ€d»ŸJÉA`™Å5x.ši-žÖa1aÑŠ¿‘(¼âËÞ˜ƒ¥§æ}£ÌŸç¨,ïKœÖïUtQ™n”Žð“ÕÅæsW³ƒ2I;År«êð"}l(QN‹Ýùçlò\úºXÓ-%a›¾±1bÐCÑz;7TeŒ%^74·üÛ¾ð0#K«HL Uï!&æÀ€ ‚iZêiÜÕàOjÛ)nnî~üJªquSŽ'ÄÞ¸_c’;_.БKŠâ:c¹ÕU¦6º¢d%ª¶xÓ¢ç*a¥6E‘ã8Aéba3µ­Š’XF´±W¼G¨{`°<ŠÒö{Ö©ªYÀB?ÜÂÆÁef‹úìŠt21¹Š† ´;ù OyW~îE¹[ýc³|ÀqŽNo¸1s7E_ROõò%Ã{âÒ5 °lÑïj›xðwƒN3OË°>ßó€9(Ó7ZB¦~ÕtE"wÂèöš¤x^qÞR èKåQ2KÈz9¦y•‹´ñ$6}(>´üžÊ-á¦,6yÒ¡8ôÜÏ-Æ YëæH48ãضôÛ4!—¯Ôh·_MôîÝ——†Hý¹Eº3N`BâdÌa%5òH–…âã'OŠ’âl|ß—8|9Q9Ã5Z¾Æ¨cÝCà ?!Àâƒ2·âR˜uíV+”V¥C{À‘ŽÂ©DeÓ«D_îŽn áÉÆamŠªžëFJ[”Ñ,¦ûöÉ,•Œr”<kDÑ'Ñ 3: ¨Ú1ÊÑŽÔÛjvGq(b˜î CvB„öµØàécX™nAÕ(=¦Húÿa^cöeÉ*MCð=ö•bç‰?Y\’L]CjÓñ¦¤'Eì]—£œZ\™„Ȫ¸¬ˆ˜ÌŸmÐÅd…!³>DÈ[õ{FÉK¦*ݸ€ÿ™xê>w.ÀÂðh{ôÌ5C3S㩪ÒÚûGÇaU>ã½Úæ£æ"JÆ€ÕJ£å…¢WÁ.ƒTŠšæãjð¹=g‹©×o³œÆÔeE+¦ð_šÿ2«P)È2tsa²v"'qé<÷xüydã“æªÚZfŠ„‚Y¬˜¦é­ hu²%´!ñLRÒ|n[€Ï娖†3³³'í`.öËr-(‰9E›z¥²—Á‘ìÊ¡dÒþªÔÖ½`„¿ê>cF‚$DlÿUÄ"Òœªœø´EL‡éâ 5y¬Q;ÖóŸ¼Y­0Ko€“¡Áx”Q7µ-˜^ÐöÒ.=Cë©–Ž`ÈR˜È2Ðl ™ûUÁÞÙ†«0¤|¤ùÏwöjÎ|;aµsÆ(Hj%+]Ñó·]FŽ¥rßj—•õ«a¥‹z;˱KS¥—cЗ˜BîyØ[t3¢è«dˆÒM†PšÙÖæ ›;?]ÑÛ`³:é—ÌA#qÜú–žFku¦ñž\šë%œ2è–îÏìØeâî2®Rmw w›Â¡bã–µD=!õ\™<˜çè¾!L0Ù‰xœj<à»èËà ¿F9Ä;Ã7Ê‚ºëgœŸ]G¡]R +U—„¤Õ—ȪûŠû™´Ò>´°OŠÚv‰8=áïWh%(É>òÆÊ˜Ü Ä,£ËƒÃ¶¤Ô¶céÑèêý¯ÚDbxºƒeMëQÂx½M&Ô-Åji¦zbW5H°w˜"Á´/]Ù’"ÍFíT¢®¬-Íòc¿$ÍúK"‚j…þ2â8R{E–±^ ÿ<¤¤0¡I¬‡ÉÊGa·ùöùšŠiA“åW½ÖÿreÇxÚ%ˆd¯‚‚óÎ&Zf/ED>Áé’c¢q‘¤ Œn"î%h°y¸áå$‡¬ð=£nìp†Pÿʪµ¯Æ$x±q K4®E¬-áÙ‚o~ á–¯':rÎEË o9Ésm?4ÁÄs7£ Øà/¢ô/질¾Cœñ•ó¾ðc]µZÙojz#ìT¢?@ÕÂ2ŒÈ‘ðgϲПx¾AojŽË)ž£:g{RÁ´]›£ šÏa­AuvèšNTk¢Nf–åÂ4ÌÂ-éÕÌÇ;±:¾˜·äfÀ&‘—Ú’+3HJFÑ$Ÿ¸Õ8)I2õ2oƒeíäO<«h–”H¿¤ò÷K]ÊëÛ¶ù’ô&Ý‘}e›ÏÝOq«E2Ø«SÊÜ÷d¢Aìkk˜>,7m;ú Üv¬GÆpPæ+RÅ0QÚ¹r¹‹ìa†”¶e!JWIô÷TÒîÍIëÂSê&'èÎe/q\O»0º×A¿OÚî¹–/g,j–ˆF fV²*_Šj1ÑIVÕPZ»M°Í=xCþÿ×ó…Û¢+;™,Ùbæqæýc[pŠ¤›IUfz\+W|ü ?¬ƒNò“S†‹¨B耞©IÂpGfì@9Ð1]Gógãn#¢ëÐx™Q+aºEjéärQ’FúÖ(8¹ Rz|±ÕÔ(ˆ-Ò5`ܸ¸ïÿ ±¤ÒÑ%0þP?é%¹ÎÀ´xýONDP¯@¯¬äÉ]î¿ÁÁæAŠPŽ7v £¯¨`MÄž÷pøs]礇éÐ!üy‚í:ƒH¨$-ò-–+R–þ\9ôó |›³³pÖrùêPã}N‡XkÆQ`2´ª†íö\ð@ĵ¼ut+îùtCÈß„ ̚⸦ yì¯!!ñ81,1~HL†¼¶ÁƒPÖô•O/;tK³!¸KQÌ÷|}PZ?ƒAè 52—3xnVª›f +"/@[f‡,…9³•iê© Bóez†b¬èÑÖOÉ3Žm§¾¤óèÜEŽæˆÓöMw0/i3•Ö•IGêO‡TbtF*d‡f”µëû• Ž²‚þ5Ev)†žÙq*»ú¤Ä¤¢Êÿ«7»Õ<·W¥}Ú4 +`_ŰϯoÊ° k/ûiº –³`߈¶ñ]ÝÙScUŒÕ¨DWÏŽ®ã`×F½”ƒ<00ŒI±Ë*Òè\)yt·²"¤ëÂUST('ÌUdø³Ùs€Ñ¿æa»Ðã’ðt‰È¨¨û¼))iF!Œm¤c&EýRéŒVrZAàKp~p³‰2-Ô 2ĉ„ †:?Y +æ)‰ÓJ/¸›¹É¹†OÜïšHë8´•U•q²òY'"Ý[Åõ?°èú†X´­žË–Ó°ÛŒ#¢øG¡FdHkáVç… w I!ïf ¶ÒÐ~ÔU†(9xÍ¡ñïÇkYl~ƒ¹Ó`(Æ?.+d´üØ<Á†S9]¼©».)e+õÒ 2nBžÇƒð£cHÚ.Ñõ„3¾)ºmŒÒõZ›Èµå·Ç,4•±ÌÜ‘Aý|Bùjú§v‰½<óÛqNH(WJŒ{Üá"®ìâ'ø7ˆÚä¿cž0ª$;/ÄaíÚZ7d: ¢Ç(œ‹‡P¢‘CŠL¬±9U#’kUÊ![Ö¶&h¥ÖH¥=¢ÓF;h%°ðO“.QFqEMpËyG€(i¥7\ñÛ¨ìfên.Ý43Gáüµ#G{Ap¤Œ™˜b§0”c‘¬6hz4$mïÏCP Zb:”Ñ2™&×D=à)¥´ÉÏ-õ|¶Œ4t¥ØLS óåi\9C2ÊN_¥Ñ\Kd ð³ñŽu:Ëcež²K +‰ w‚„cG¡Ïôóe !&%IèZz+¹®©ò&tÕ•¿›‘é¼ÞïƒË‚œzR /ò5ç¥r‘ˆ™²cœLÁ` +D¾&˜­fþ9c5ôZï 6Tn I¯¹¿|Cš›wÊá/5ná«n Ž²U—v)KÌ‚Õ«f`86o@±!`ø퀭¯©µºÊ*N>qôåƒÁ‚n¦F7袽Ô<ßñ¼Ï±s °WMšƒé¬ª¸xBÇLø˜š}f—ŦèjZ¥þùO€eЭP0;4½šJmñ'Áþsš.‡VJˆñwå‹z‰²ü] R5†iƒù£#&PL5‹=ÖµU‚ÁE5æAƒM•Ÿ•YMáõ™@—”>ü7É;Wé°vaVq?ãm†¥ÏÞ°ì9qgF¾œJû9óì.ÎèfJÈ'jmÁ¶Jªx‡9/µ>‚¿¤Œ^éN1ç¦u.“Ý£.Ë¥,dŸ8Ñ2à´–½¶QÈNœÉøÛÍb@¯PŽø¶õ@=TIPÛMv…ãxÒçN™?V2–ª²PÍÚÏæSý¥QVýY2~âÙÆgJjI1é]M L +¥—a„ ‹´>o`žì$äg+ksÛì”ü;¢AÉ’”+  ˜Yeñ@¡•Q(wÒ Ÿ­ñßZ´‘q»Úªƒ×,`{Œµ<¸ÆÜ)u BV$ )·>E"¼Z³ =`¢ íþ‰µ§EÐ]oØ ¬†‘&]oßj±cU‰YÑóÒ¿ìÓ¼Ad(nÑIÛ‚?N+wÓðÓrpˆ¼%ô&ÍÙPªËÒØFé|Z2I"Xs^w8™t"¥wL˜¡VÐFêfKŽH¡fc %ÖÝ%΃“²;Öwd⋲)óóÍ ÐðÔl÷¨ÈrœTþg¨”–#ô”Ió³þT÷å®6]æu9ðrY̓ìÅЌʣ¢7¢ ¾ºÇÐèjqW(0ìð  @å"‚ŽÒ’ìJl?¨ƒ!ðµÌº +âoÙpòÅŒ  j>ÆÓý­¥p¸ÓøÁùNy0wã0?ÉG§{¡rN½sû¥ôbýÀÝ?iS Ö@¢R7ƒ‰tʉ¯iMçºeEÖóNA3ŽKŸUB´^ŸŸ|š‡öŒÈÁþ\¥1ÿ†ÃF†1= +ié“ëˆ9Üœ (Ÿým8õ¦ôÞ㩤®‚ÐyÒ|›6zµ¤Ì5o6Îϱ+ä’X¡]#8Àé—epŽNÓ#vDdRÃò×ü¶§•á,ŒùºÁŒé€€“ ßGœÝÓP@„M4ß}q ,¥5 . ÕŠÀ3åUcÿɯ Þ³ Ð\R„ÎÔcŽ„AéLˆËšÕq––>Qòòv(Ó£ +u“~‚ŸIy15‚º «L šìeÂÌY†j:ÆBye³|‚üq£xÐÌÂK4½P_ qØ‹”Û‹|®éS¢8ÅÂ¥PV"2ôÀ7uÏš±ÄÀZ‡Ó :쮓 +jU@é¥l¿PŒ…†I >:—m®¦ù®Ó|_B$è`ëwPýºÜÅIðþ–Ôác0³\Gä«Ï’«=5˜qÿmÍgx¦•2Ž"´Íðz=¹…rÄv·°SŽ‚Dt–üø.÷Þ:sÕ8ÙÊu½ÄæÆ‘| +­SÙE¯¿,ð¤½S0ùîe~ŠRBzÇjª§T²}œí#ðЙN\ý•$ÖhÅÁG%÷$Þª ‡§¢0ýhx©š@Îot׶@èÒ“¾1»:·¹í 'ŽýÞ©²>x,~R·o.s¢Ã¹û0Њ6xÍ\¡VgãöAèºÜÂAQub¬xeßE–Ô:¨«1']S)¨e{à}mÔY½ßÑ…uGÊ–µDâM‘ÎŽ¿D·–…€!¡Óæ}ï&æؼÞ0,,¶C¸‰—ÔƒHHÌþZ&z+Šš‹“²µ„s”úqF†ù;È“oŒ•%4Ž¢@RÕ.-!wAÇ#™fìDýWt œôB=ÐC]âÌU×b +$ ój“ºŒ}·êêï‰â<Šž™‰êD±@N¿úg¾¥7ŒŸYðe¹0òÞªy¿|Ôš5!fžË(¦ÜÇçlT&Óÿ½F™Kû +­¤Âh`¢oRä+¸BŠ„ÍJ&÷Ãd8+««˜k ²Ÿš¡‰…ÅÕ5Sð`Æå¨paÖ&äJNâ0^ +{ðrªß,üî "CððѤTédX IœK +Ì–¹%„ÇFûl€ØyË6ìéRµ|‰nõØýáéAÕ 1(ývBÊ <@ —Î,4ïÇjP¥käæ-PòºGÜq}à‚Ž¾mÞ=ô·€/ÀÌŒ!>ûk…ÅôtÖ%ˆµ,!0P®C|N;ÛöPZˆ¡ ÈWÃçŒòªaúSâ@P ›ƒ3›ßHžîÇ‘ ™yX‘E®þ=¨¶ÑÒ{â ø‹Û¤HŽfˆ\8УÕAÏô8™íM‘µ€è„q¡˜ÕÌ”Kq!ÐÐÎÑP‘"9ôå•`rû¤ÜP×h~Ôѧèä(-ËsØÝG3Š)!U¶˜™ÿ +Û‡r¹²<ÿ F)<žë’| یט›Mã~S^8»O¸Ì  Ì_KŠ ßçXa ¡“j>ÑêWù¨y ˆ±ŽBOÊûõ­¼ë; +é?Á]®éýøäµ";èR° دw,0‚šçU›†1Ckƒñž•ÊÑ7õ…åW5‡ €EŸpÌûIM“tCþùÒrÍ ’¢´o8~8[PQ~ %¢¿ºÌ-¹Z4OT LHhkIa2*M]yZ"_ôŒó•( Ä´±þ`$Lgµ9?˜ÚK¶Kªî»{…@jXâ°])Á™ÿÁ¼[öB’e‚ã<ü%¦@[ØsåîÍA¾dz AÚ4z $C¶n䊈Œ"´m¡?òÀ’@!¼å@èÙBu½´yÆTä\€ì °-9˜«VpúÁ×Hï–H…ÓMr–·„+cz E鸴Çe‹‡i(»þny=ÿöÍzWWÍZ›3£®Ó6׿‘%*•ˆï¸!Æ¢¥ÒÅZ™Ð<¾Á 5´zÀ›¾ÝD'åé©išHhA¤Æyfµ¬«Vìj4WLÚøä½åˆÖˆ%öºqŒ’¯• ‰âôZؘ ¨â»õDÔKFëÐÏiMÉD²B¢áÂ5 +çLw5 i£ß³0`à-  åù‹˜«RZ4+2è&ܵ'ŸhÚ‹iћ͜4þûf2y9¹êãÜË9%ÒôŽGpÀ!”z¨\[DÉ̃:Î k¾öx˨ÖìÖ¡ôQ* Ùu´Sgbš +pš¢ª›Z*È •, ÀìhÚ‘%XîÓÌèŽSqDR__ýIï˲Æ4—6BRuSóä(½&óptSš$Aå#R=Pn\£ˆ«·ü k¦ÜÕ•å¥ó»!³¬c&©ðõS®z<Ú{®ëÔP»QٻƇø±Ÿ—¾‰X Ð7ÿj„h±N «³k·BlœÊÏ£4zî³±%ŒÓŒÌð ¨ §IÞzšb” L1•êÆ,0—/›—W¼” å¾/“G8ä')>T!@G–¤gB_NÞï„`ÜiëΛS ÿž`ÙÍNdÓx€X=6*ðxîè(Û¨f{ô³©g>*mÔÀRâ +\kñÿy†¯ü_Ö½]ÀÙ’‚WÃÙœ¨}'óIʽ†7&"D“ K¬"/†ÍC¼N Ž¡Ïp몋ƚçcѧQm9j•ÌˆÚ‡N|}YçÌ4üÊÐùþyF¼O²Xwg +5BŸ'IÆd'Š29Ùˆ“– ¼nRh?=,¦bÖv ¡20ã%¨‚pÖ{;ã.9ôʼ‚‚Œç®@E]Ñ{^‹›§Cî›e²-ÐR¼ å«G%Ǧ"9‰D1åÜ›„w׳ôä-Hš‡MÛÀÇ>q®Ñ–ƒÌñë >x8÷KÈ!íZ¾·¹ÐÏOÆšI–írê?ÍÕHÃr?`ˆcaàÅë0"q'Æû6Gî¬tðg±Ã/XÛòEç[Î#‰7”šn»æظ¯¹Q–»• VèŠxâøKcZd)ÜgâéX³Þ_~|ÿh£2üýÊKð#b'æã™"âËxE<‹ùÿ ²9X´’ ²-n<èQƒy ñs’¡2г,b.Ì2¡÷ÒÈ\eæ@„\-¿„AÚ€Î|›u. ä}¼võ‚dF ëH™ ÁÅâ6”˜$âC’¬Í79Ä•3D’Œ‘ºz"p:HÈŠü—)›ï.Õ`]‘늾–§È»«M%xXù£çºy cX±îˆïNf^Iù&JvÓ¿ +|æX¶¨!+rîâµÉ‘û×]ˆ¢ês .&ÁÅ/Š®|*EN¨{Vþ +÷bƱN€³roÈ×¾çŽDò’.ý™…éê+‹ɺnÈË%Pç íòÌèEŒ?`D"ã}/¨žmÚµªåòÝ|ÁAë@}¶šÚC%T²“£´\¸©¢KÑŸÐð“N$à°òz^¡F‚Ì(ç#¼ÆVïŒ:DÞ2^Ü8äAœPO‹Z÷óÇf"×]LìôLr9¤O´™Ì†£ˆ0@Ï™Ÿ¾·Í‹yqè‘1c0“üKD—®#ILˆŒ^¼ž=ˆ:Ñê~›º°%ôÊÍ€Ê( Í[«ÔÈÃak7ùµûä®+!Å–Á^vZ/+‰qùñá?Šò¸±¬rcóÝ}ÿÄþœB(~y¹#×@I@6ÌÃ@ˆ!6x^X† ÈO…m æÿz“º¾ÙHŠ”_Êiìnª7ØÈ£Ùãñµ,ÆÍææÝòuÎtÞÄ4ÕI †»jlYDJ¢ªn€`ARúƒ{ؽÜ- Ë姴nKôÜ¡[[5ZÇ5Œ +#š¢ÜÌÊ^`Ø¢$ä_ró$Dé,ë÷`®î«æ_®‘€s¦þÿ€€­îX<磀ŽVY+HìY‹ ÉZ÷úÁÔ˜DæSfŽfèôáúáH–nߺƨg›<ñ'€õrÀÁ_Žû;®5Ÿ±ý}&qéìÂXUŸörXF}†ì0:úv2W7æ¢M@‡¢}µÉöîóaw ‘V1#f´ˆü'úÝx€œoì%ø©6»„Èü…zp­ÔýÖ]³Åaî’SºÊŶ é ZsÞïtæË#ñ–*š}³E[i¤heM¹»»õ{ëå¼ +ÄÐÜc[>a¯åxуFz~ÝäV¦ ƒ]½ç÷<Áan¿pRQ¯^ó!ãÝ’è¥k@»ˆÞ‚im»ù¢Ðõä^WvèUÞ˜Xpc¶ï‡¶ß¹(êýÃ…A?®GâÊ÷V?újÑZ˜œITvóÚ¸¡j%ý7¨|ù-óÑ2ÉèQD”KWµ¤3Õö–û+hÕ[O/~f¾AæG×¢—Âĺ·Ñ /gÊ),â^Ž®{÷%‰ýUäâ~±j'ƒ1¼-ôä ›Ä]O»;™  ƒ4µ•a¥÷SÍïPšÿdˆ*c…ÊÕBúàS:²†fC[YÞ+_ÀÌÀÝËA÷;EÒíÿ¬¹?ƒnò‘qqÿcŸáCð^Õ-¬ÀlÀž7„ËúÄ‘ÒK†KêD/i…w‚Û®÷ƒ8Ð¥GGº:,é+§£ #øÞT¹R+¾o:¾zŸëek×Ý2qËtX&‡q½Õ£^Žr½ù|5·ÊRä²P/DaäÞ;f˜ðªÄzZ-¼•N6z:«¹­J|¼2‰Ð€ì6OXÒE}dOŒŽ¸¢°…shõm­C飛¢·†êƽn!_3C 4Õ“è7€1¶„•e¶³çøW©ŸµUÁKjä-*¢2ãlú¦ä¡·ÎBöªŽ…ï yM¾S7Þåž<±fõúÂ'¾öø¾<þ–3¡öM‰ 7|™<˜ûËPª»Ï±™˜:‰È«:¿hådvýÀƒ¶²Þ•jwÙ˜‰oL‹ŽO+ž#½Fþ`O}Ë;‹ðwÓ|%꛸¦4ñ×ó?r>¨ž'J ãuÉ6±”p6Éy£*–‡Žè-wf"¯ÎÈ4Ñû;ð'Òû†9þ÷”ã>eÄÀk\ýè­ À%’¼Ÿ›ÇV„enh³5³ž•4BçÆJRÖL{h-ÝE?ÚSêW‡ +ÍQþ ØÝ’ Žç[`ÒTî=È*æ+G߈bäÊ +òÈ'@œÐ}¿vÞVΙæýu»Ì‚ÛPRR"Ìh#Yµ +Àš€ ý Ì ÏãïaÀÆ•ž7`À€À!Dq4r"z–=IVÓü/y.bÀ‘6¹²ƒàPÙž©lšgHCp@à,¾ÛT@LBV!p^–•âãlÆÄra)‰ä„3">¦»ŠÀåˤ—…æþ?²’Q.8ÿI¾/ÚžøÓó¼U¡C]±¤{YÊÅ¢EÏó`Q4VüK’àÅâyžçixžÇ=6P}žç¹ Ïóé˜PÁYxž'+™©ÀçyÞ§ð<Ïå!3…9`$É'ò0ùä¡ÉÀ\–DÂó¼…ne’xÏó<Be%Õü<Ï žçÝf«2%yžç½Ïót&ôJ¶çyÞ€…ãMp@ø4 +Ïû Ï«¸ž7`À—£".´]êcPu.µ3–|¦#¼|z=ÏóàCG6š@^p½Ï}òÓfµw¢(èþµ6ËÜu/ss/÷6˼͟u9–§¹MÞËìó7¿Þ¦ijóÜŸç3{Í»6¿î;›Ùì]onú²<Ï2›gŸÿ.yêžyYzŸóùýiîþ7/ͽÞ¿gž¹/5ïç7wɽ³?çÞÜó¯{?ùyö½¿™MÞ?÷›g¾¹©Ç̹Yzß¹.ö{g_š}ïœÍìçšû²Ì¹kÍõé½Î¾{“oÓûŸ7ÿ¿›]wþϮͳÿ^æóÜù„l +ù4wÎÜ{~jÿóöcî›ëÒÿ³Ô§ÞYÏ@]XÀ@‚ Uê²ä>›:—eÖÜç¼¹Ög/;ÿ»÷³ìÞkSw_fž»ÖÝÌÙû\žß÷ûù¹6sæœûß³™ùËswmz½¹éóçf™yÿc>ϼ˲ì_›{{þý×eÏgù·.wÿcÞYŸÿs³÷Óù<ó6½ÞgÏz÷ÿ!‚÷™{¾·©KþõYn¿¹Ïúô~ëϽÏ>^v¿{Þ½,¹ÞÝsï;羟¿C6…Ü!BîŠBï_že™?Ï›óžû˜Ï̹É;$Uð{çÌOžË2çÂÿæö?ï“›_{Ýýç\{î÷¸3?5×~g]î¯7ß^ÊͽÙËr›9½k½Ëì9$UȼóÏw/µyjÞ»æ?—ÿ—üÜ<ïÒûlvÞ¹÷å÷™ŸåÏåÉÇÜÇ\ð›ïsôåè7dSð;ó“ó­¹ßüëîsæ¿„¤ +>k¯¿Ö¹—ùä^Ÿ¿4µÿeק޻ïmšTÁ›ý—Ûü<{nz®³/Ïœé=×’*d3ÿÑo³üççyëßyßYåYŽùkýsÙÍR{^î’óÓûÝË<–û]þýõÿ§™½ÙÏ}ž>û}j_šæ¹¿Ù{yr³Üå˜ùצ.ÿÎÝÔ—ºô[ï2›º4˳ܧÎzû1oÓÜ|NÁŸ¦ïÜwoòÓ<=ßœgóÿmþÏsÖÞ,µ™{î§Ùý™=7{ Ù¼ Iò é2ßcÖæyî_nßÏïͬ!›B>!©BÎÒ)x ¡(Æ\èe™ù/{Ö¼üÜäÚ<3×eYjvw›¬Pr,ûª1VTؤ:… ¶Þ&Š“„/ÇìmÅr–Ö™IA}sqµÑ*¾íV[!Xwìv~ÙÜÛR +ÿïžÍòû­ýÎÚÌÿ,¹?Ënj­5×:£ +1Œ½ÉXÑ7ÇÞ¹Þ»gmžyóìf6Ïü?ßnaõý?Ç×\3A!/Fj‹­µ[{ɼ{ÎwÉ{ižzÔeijßÇ\ÐzÙëÂ7K>z>æ…î嘷 “µ‰eß[I„Œí³Z­B¬–íT6‘Sþ€Bü©-Ó§q!’K äwcÊà€ ¾\!Úh–K˜‹‹Ù|à€ð-Z£,d>b7<ñ‰‘M2§íˆX^èO)Tp@XpÐê=Y^)C»HèÀ&mŒšI-FxêÈ#»dV +²xs×l&— œ›[2tÖå Ͷ@¨ZtK*±*h^ì40ñ¿®Ò Ôð«ð@uSûª “#8 p<ÖÈÊ8<«TCªÎêUÁ÷…9•"–Áá¦z4,xèÌ®šxÃá©h¸39|€óê¥}䣱Œ°µ¬mŠ/&La[˜¬u2gª—?Þ`Îáäð‘öE069|ôÌHH¢Šåb‡Näb·ãÀN=ˆ†¦-ðÈãGÆCRf‘ÊíÐMB&Žº>‘^pÀœ(8 ¤ª”¡' ŠçEwÐ… áv„ºn'!)¦pQÓD ‘ª¡ZÊ3Y²Øûô¤\²:6Ù·“apa2NãÑP„…‘J#"Ù”à€0Úׄ€F* ÃcRŒ¿ Tt–×ÍgfYþâ…-ã(3j:^‚ø\ +¹Äš\)ˆ´¶G[—I8 ”j¹Yº8(:zI¢”²²:$bwIy\ÚRþ +hhšãeØüÙ;uäNíÃB ŠçseÊÐkè"°Tn™e„À-&£©#Z¤2P9; •C`“;9Y *Éub•G Ðœ2p«%£õšV£äeGî½ßš9é<ê2uà€ ñÂÖuµF ×ðÉÛ@_´öÄ1š6ËF É$,§Ò K.q*9Íe³p¸‘€‡âPŽÕ»ÔÍ Yn«[ÛÊ´b²ÅnˆÓÉÌÜ\¨d` _ Ž'W#p”BµÇBÕkïk»ÑL8D{äBœæµ°mÖBŒ>³ ß|Æ%2›l‡:}d0†ËDÊ @@¬ÏšÎ.L¸Ø›a¡XÁ¬ñckfÓÂ4<‚‚)ö˜0Ñ‹ñ¾@¦˜é‰¯(³[G ÉÀz2I"«ê•9¬°˜€å²éŵËô…+¤¡`­R+2-K©4¶JŒ²Æ’ x–á¯UÞHíŠ3 +•V/‡ç±ĸòV¨ûR©j EåÁ úbÅX3¾!`À2MÀYXp­£AˆphŽYmõÌM°J½J«Jª<¸Â¬c›¡Šðƒ*Xa>Ê‚ÊÞÓš§’l–žÀ¡&‹S!3uPZǤ!,o²=Ho)$ ”‚IÉ`‚1J/Y<#}I B2I¡êóHîâýD¤:*!Xr¤)yp¢išDeˆ2ˆTˆ?”…ÀTœŸÐ×^ÌÐN¶òB1+nvªHdXi”°YtŸÆî#øì̪|—Îø©¨pÁŸ<–Ÿzh³2ôxIAg×J¤eRC;&O/:&‡‘&Fp@˜ì„“zM'®h›seEk² >Š Êšhš4Ò„Õxön¡yÕ³ C”2‹EšA7¤Ffvcà0¨…†ÂØ^PCÙœó.BÚ+BÏ"£²LÑ«…ÃSáøóº”l¬çrÒÈÇ.8%.)£fI=Uɲ`˜K E–QÇåVRÚiUǦ{>e%¶C*&õ%£hâxDA9;¥p†g¤€B{݉A°&ov7Ü.tN +l‰ mM¤²Óì”ÎR1KZ#Xyj[±¼ÔµábTŒ$ ^¦Rý’sußH¥qY# Žû‰¬^±QD1‚pk. +eg …fºzU è»Q(÷²9˜Zmõ@1 +¡¤""Ä5 Ó ,Tæ*Ñ@µŠDÀYà”×ø‘ÐœbΪBzÀ‰JåqQ)ýÑÞ6‡8 T ¤šÃ¥±)‡ù4ï»8¡Táh˜î¹:°®S€f ® 19AvŸ[ò1œ’MÍô<гO(ÓÅ`ÌÓ§#šé+—Œö•¶šJÔ†¥–à€p‰-Îòw£D¤NÝ¿)n¯ß‰HDN.ãÒæLŒ_2:Ÿg"Ì/>dɃŠê¿òtú˜a =|àp‡)¶ÇªaJÓ¥¤‡”˜ìà€?§¯›3N—Î×LJ/¹<…»ýóܨ²à G·ËÛêÈ@틲Ù7K‹î0Ù;­éÀmarº¥è—Þe[Ò¼f4m1‚hráÑ8Š ¨mb'—=½ò©23e(ÖäËê#Ùf(ñ,*ùÅéHŸ8ÈH–¦4@òØñ!Y˜…ô†˜I¾D"‘àš¼å4·SÚ¬ãA€cO +Óf„Ò¥IÆÛ ñÇÁ$óÀl'ÒD£MìœT.‘«¼Ile·xÒyR1¥¢ (ÚH¹O<Ù^¸†…»` Z¥öiÔ.°¥AЊ Oæ²CKÑ„RôÅáhv +BþÖðEuNá­Òð„²ìª„¯ó[­¹ÃÕl± ZmÌ XÕl Õz{1Xl›b¬-"Ä`ðuL„¡«¬>Ã@€¡®2¼yÓ-À!BBk‹×º'ŠµˆÙFЮèÔų" ”Ã) B.`ì$±ø6“Xñóä¦à¸š˜¶ªT +Ä(GR •°£@=.El@Ž~ê” +‚&à€‘ØâD+ÃÁ%ÂÔ—%:´+á*j§DU±Ñ'“èíf’P=Bĸ2õŠdÅ„x‚‰ÓBÜ!ÆS%‡ ðì ÑkyE¼ÔÐÒ@9ªƒØ˜¾ŠEZ“ 4Èx|eG¸8ĈÅl D_c“¹ ÈÕ*­ êl7ˆŠ¿(˸€l: -DSÁ‘,O@áÃ4þ`nõhP•Ø… ǃB€à€Ù=ç»eV™¯oX—o$¢Q>]ê•|kýT9ÇãS$<æ§qøpÍ¿ÇÚÉŸ „Å/G(áWBÑÂ0A|‰køvkÆû0»{ºLç©ã§õt)ÎÃd„͋זæqИÌ;¸¨˜7™^^Ó\žêbye ¥òÀi x–F‹ò¾‚É+¡rÉëÌÈ«¥¯—ù·#¯®/݉Ïà~2žÊ1žDºx‡añZŠWa8&^¨so1E¼E½ò„€xà€Ã`啦_Nw$¦÷¶té± ïiÞû¥÷±{l k/ÂeR›‘ÞB|^E%z&–©áM¾Pè ÃkG–…Gp‹(<5;8 øáy´ô^á>xžJÃà9VˆÏCxžç¢çyÌÙ–„7ñôd³U\ žçy+…7àó<Æé`x:”gá}+Æâó<Ï‹5Ï£¼Ï맊ç`bŸ‡ƒ‘OÔ ƒ7`ÚéL*\%Ê*ß(šé+A‘„â«SU¶-“æíFiËiŒRMèó¼V§-ÔØ›rL–å ô· ¿Inp@Hdb™ +Aè'aŽ”Ê ©Mºkä—åƒ3NIàÊÊ6¤­ÀãL.²KÙʈªp%8 ıY’“4F©C“+ß ½ÆFÛø÷\8–°„EtÍéXBR£˜`›ÿ?™‹Àe7kq©Î†ˆÂgÀ€“ZJ'iĶ‰ë«&*Õ", +endstream endobj 21 0 obj <>stream +Z YºÙ¨„º´L‘˜( ¤&ª=*nFF¡I)Ê$<¯õñ<ïáøj6ÏóX›„罚ƒç „VèyÞç †$†Ü6êœDæw2J 27|ÊÐäDf“³án¾ 5rª,šq‘¹%‘yç•"\‹T2ï +&‡*Ôd3ˆX3ÊaÕ ŠúІf@T2ß@&ëJætöX±%K„…œpãÑðõ8ÉŸÅÆ£¡ÙÙ–‹¢£š\k”‰kŠpÕé´I1(“%$Çq¤40 Ÿ–e[ ACÆ£!óŠÆ“FåQÑàÂäF 5Ù‡ +K#} +’Ø‚Eë¢ÂVÉ`—ب„¬Ói“Âœ—š’øäðña¡§ +åÐ[mᱤèx*zzšoêÔi v—ƒÓ09$¢ŒfÜ š«$ ½*™¢2ÍA^¡¶›ùéX2Ï÷ûùV-"Wµ÷ª,ºÁ)E‹ŠÅ$hþwg‡dF®ÄatíÞJTtÂ34Á8‹íÁpã9yžŸ“¼à€ ;”h£®ol‚3ÊÙÀŠá4:8>+õ‘¢Kê‰t“Ä‚ƒ¤ '½ TbøATd¼BCÚ™šõçΚž±d¹5:) ‹‹ÍƸy4Œ±‡ ’H—ÔËuvžk÷2*¨‚×qvp@È€°›Aá!Ïïm4„FÀÉía@(Áðf+“Dvz0L¬&&âý¾±¨“Ya¶v¯)§wï£UÁ/iyß7¤è¸rÅ]9æ¢V–¡X¬ ŸïµÊ‹Rc +Lâ)ï ,S¾ÅÙ”e4¸”Mµ )Ÿ¼3Ê4ööÉ3Ž“[ž '‹gHÍœZ{É•Ðæ’b%“Knm5R˜=ÂS‡>´2zà€@ð^ÊÐ ª@ï]A¤P#“’šMÄä!8ÖI¬‚«%ÕCf")Çé7‰M³²IÔøÕ%±L¿Ïg?dnGWgG£©hXú  Ô%CøE!GŠ8RdyT8d£Â!C¡P +ême…%êT:•Šž ¯oÕ­oÕ”OË2ª± TT– +%!sSª›rS*ÊÐ%dÞšBÌ.l0›l›7"–Á˜‚ˆå_´VvÁ­u„¡F¨vta2R7§Ø€¥ç×QŠðDÈÙ¸a)Â79&E¸§ÑÎwÑàÔÕltD3:6:jhp¦ g +´L&®("°ƒUÑ £1‰Á̶‹µ][³¶kCM<®{婢0T! +*ÑSKÜ:ÂÔÁpe +'!ŒÝÛAû°/%‹%›6S„1 !õÔ–«âé áƒ6—<®—oÑoÅ‚æ_D™€·™ O`6¾2‰Ò€Aånjµ•Ãï0ò%k‡k÷¢=E@q ”2`QÄcV>4±c +¤†‘Ê]-H£0RiXCå¤!'m« ¯Žp¢ÔåØè$o*I2²-½À°PÛzÛ!µÍTK!ð‡éoq£¥Š¼Ú˜£²è üRÉ‘¿RŠ¢¼­·¥S©\ü´øiñÓ:ÕétB‰¾USt*U¥šB“ÉdR©,šë²„,'d)µ„mÌfÌfÒ³™·&51.\˜¬X´V6'Z+›7®KνcãnQ¢,ExÈÙðKî4ZƒÓpÝyØi´§èŽƒ§9( N£#1DÕ9²œ)h¡&3ã$sС&ª‘ŠÄ^d‡Æ¨;mh¨XùeeTT+*‹v)Ô²„•E[Xb + -, UYô£BÀx<c×ZT*ŠwwÁ)Àæ?( Kgø9ÜŽu²¸Øeq²$q%ñ Ÿ'8—o¤• +6aø&WCíðåƒW)ÖƉ†¿!®Ý›Zà:cŽ&3–‚µ4aø[\¾T7|qtí^ˇøöD´bQ¥áiFXèI +h T@kx©S#z–S#z,žŠž…ejx­PNôfð&Þ”ƒBbcvjJì–@T6{–Œ‡Ôu\/­ +€¢v½ LÃ}à&að $ü®’³É‘BŽY†_)TªÁá×H‘åêm¡¼ÂÑŽ­pìXĦ t&G•øéðÓnÊM¹)7¥¢nêK©,šòQY4E—™ÂRêÓ²Ö¤¶&-!( 'eh +|Iš"Ú`6—PŒE˜©# Gĵ“¸0™²²¹µ²­•Í8Š.ø‹á…‘Dw8ßitDƒÓè΄FG4‰Žp NómtÄ=Ö舧Á«§Á«MƒW{‘@(â•"’)ø,™ñ¯6Fc ŒÆ`ŒšìC$F5©™mÌ Fã·Ts#Us‡Ó¶Õ˜&£m6mj,¼\ÖËõ­Úâ©hxq)·‘²°–ŠÊ¢ÝFêS–z¢–FŠ¢²hp@È5R‚‘ñ%@Nâpª)ˆXÆi< +ÚZ»×ƒ|>Öð‰í<¢¨K ý½- "six¥oPy†oÐÌàבÕõe"m 8 ¾³ºä<»|©Tw' ƒæarô”nP$ ™+OcòÔ«ªÞS?ÖçODÖåƒU ›èðR¸í!o “I©ðlmiC‘Ù·6¢z X«áí +k5¼ËÔðN¢JÃÓ Ãk‰¦èqlhè +h ¯<ˆÞ'£7gàEpƒ_8çVCpÍ%d·@WÙì–¤ bÄÍl’F@K[2«àW +¥ž(âKMFŠl¤ð‘BCMRb +5Ù&HbSP&Hb…ãmñ·¥*²@{T8 +‡,‹ÄO˲Îooý´L§Ò©TTuB1tœoÕ¡ú­:ÑÐ%d›7t ™›R Ê„2¡Ôͨ,š¢KÈ2C—ep@PL¡ f3$li$9>R†)CS¸ nM*8 ´¦P Õ:R†^ø”¡7"–) EB‘P‹ÅBl„ &+.Œn­lÆTH¡.L¦Àí$apar‚{!8 lÜ ÜN¡ƒ^ÈÙ¸ H)²q7ºáW=QXŠpp@ˆAŽH¨ñ;Ž´& 5p; #ˆÕÞ!™"<¡NÑ®Á!¾Ïqà\ƒãŠ"¢S©h˜¡Á«})˜0t\QD2‹Dg +2†8S .¼¼²¬‰XΨ'*ÂhLP8v 5Ù¹5¶0p; 9›Çcf‹E 1Ÿ 5ÙþBCÌÆÌ0 5Ù:4FÃ…C†Ò%d&¶€Í¥ÖÊf5¦Âí$4 “ƒÍ 9&[)^é¶IV­ÆT Œ‚$68 Ø*¯iÓõFèC0Rdù²†ŠI Cëö‹ZÐÍd·Ú—UÑè@;±keN^tûåjp@€_*ZÔø[ÉRÌfÉÛ"+*‹žˆVÄFånãdƒÙla!p  -p;X¸(b±Q2BÄ8 -&•¥á0Ôȸ‘B,\ª˜BC‹ÛHµ£ËÍ”¡ÛÔ§eÜ”Úx¤ á£uKÆ®Õ 9""´„†¥† K#“#‰m¨°4¬°4rBIz"ZÛN´ hRá•EWÔE­ Â…É ’z¢ V>§¤F‚zd„h 4 ‰r!i ]jhp@ðQ''¢õ¶» ûs6hÑ9ªþp4†Pª¿oëÁs•ÀcA…<&£±‰Ô^òÆ]>8ÕU»¯3¾÷GíÚ½‡QW|7õÁ¡¡:Æäpè€_˧¨~®µSû€†š.™Œ¡R½õ@Íw>°G(‚ ‰ý½ GÈ„-_ÊЋ¤F:2 <÷Ý¡ dNäÌIpsKŒ “c‚P>êdvG à 9¡P`ã„rÉ1),Rê2PI•Ò…’ˆI ¨#ï;:™:UEŠ†^œº’ÄAŒH}Ú]¸—FBÉ,²Øà€» o/66œ@­o]Èì.(ž¿. +A"–) ‘’2T ãFJ‘BŒ(,Ä’1Þ±8l/Õ_dkì«“Z Uá!8£W¢Ð¾5 „Ðd¡.4Ù&ÆŇ’º ŽŠêä‘/ðôÚŠÉñiYn¤y•Ÿ–åZX:u \À 0&æû>Ùçµ#¹@ìÔð¬•fºDŒmªP‰úh9Ó“ZÚü%[@ žáÃ䈨¤ ½]˜L3át(åhYˆÆÿÖÊf’‹m?Œ9†‘‰£ÀÞ¦eÓ÷͹×ú¾O`Cq¸ËÓÝÍ‹ùæÆ1sù`p@=´¿ O £S“½ XUžá‡‘‚há^"–u­ÝÛH Wæ¨0 +4±+g`±à¯VÙÚ½à€ s bÙ"®$Þ'zL ®fÉ¥”ŽÀ×ðsb¦ÅÍóƒs <â ^eSu‰ùT8”½»iú“Ÿã¹÷îåif³ÿÍõöúŸ\smî^þ?潩·©{ÿz÷S—Üë¿}ÉMžÍ3{^Šþ4Gs~r±àóijíKóü¼Ô¾Ì§8 Àrù}.¹©½ÿÝûsÜgÉó?=7¿¨sÉË\rµßÛ4»Öºo³kîýî¥7G¿y×ûg®KïÇÞwÙ÷.¿iêQï2ïÒ—¥ß>Ÿ§9ž§Yžå6ÿþ¾äæçºï_öÑübÁôe/õß½çÓÔ÷Óç»9öó,õ˜³æ#7õK~ö3kq@€y½{ïZ{íu×æÙsΣö¹ä'§ÿý7×|¿ô½Üæižþ4³©5/}ÞgÍ’SvÓä[ýÔ|ÿoêÿ·XЦöº|›;ï®Ërû½w¹ó.ÿµÏ¥.³Yî‘—ÿüb>{éû˜Íýsÿ|ä—æiòÞO~vó÷óÜ9—ù—™sqóñÔ:w³ó±<·øu÷?omîüÏÜËrü¦ïZçq›Üüe©õ/÷é·¨Oοïûìâ.6ËsÌ¿ûéõÿy—¦6æûÿ¯ûîÿÌæ>ùù37sÿ&×Ýó]æÒožÇ|šg÷š÷³oS‹…ÿ;/µižßssû2ó½ÿyú“—¾óñ<÷/ß%÷äÙçÎ{MŸýþ™‹…®KýO³ü¹gÍËýOÿMs—%×9s=žº4Í>ò¿·Ø½É÷æb>¿ÏÿÌæYþñû½?÷îú,pógÍ÷÷9¿oûæô¾õ™Oͳ©·ç¢©»æ¼äZ—çÿ]ó2óÎG½ ?›g/Gs›&ß<ï-¸ç;koþž??Í]Žyw®ùî™÷ÞÍ2ïÿOÞE}rßûÎzg ï®÷øÿÞ¢Ù{öúûQÿü·ÙÏžwiöÎK^ØÛ<õÏ&ßbéÏüÏr¾ìg?»éG½7÷å.¹Þû ÿ,MŸÇ¬÷ ¦©ûö}ëÍQÿ.æ¬Ïü5Ü?Kžwï]” œ­Ëϳ×[ü]ô;›úä:—bîÿÌ¥©÷ùw.¹?ý.Í¿ûÙu}Þæè{ÿ^{‘›ã6õ˽Oÿ»˜½?MGÓÌcYöÏ}Ö¥×{›],Ͼwïyügµi–{—Ù‹…Óï-Lïͼÿîå¹µÞ¢™»Î§hžföÿûÒ,Ëžy6ÅÂÙMÝ»¨ÿ×åΚÿŸù6Ç“wÑäyÿnæ­ËÞÍnfýËßÇsó­÷ïºk¾ýx–¾o³Ÿ›Ãùyò-ž^§îbÞ1â7måÂB· +ŽB%SâtP¤…SXè‚b@xq +¹}!¼/œ¥^6€•ËY@p@ˆå,- @®ãQp@p@`1 OÆŽ’¬sd¢Â!Ë!Æ£a¨K,0 [+›ÉφÎf Ý9²ŒñèÕixW×ðP±VÃë …w*²ŒAÀ> ݽvºWœ{Á¶륽Ø×Àë5¢™ž +ÞÃëÛy/+—BQ±-ÓR¬—Õ½R(å¶R;[Lx_m*gònª˜¥T)–ê`2.Õ_n–ׂP˜±Z – +¿T +¥J­Ü”*Â=T­IM}#àX î!àÅ"ÄÀ¨ÈùUÄdÖh˜QrDUbh(¢*‹Þž.£ŽA`v!x,Y³ %ØÂh•Pšo¨23Š„o¨âé` AtB"çc´‡£.Ä‹JÔñãÜlç¯Æ ™56 ţبh86±¬3…šoÕ‹Ö…ÄiÀcЬ(1Ñá¼D þ ? uW‰¸0)èþ +Œ˜”˜t““¥ïáH”‘OE]4¹¶GJ+áxEäÈpI88‚Nª‹_2ñ«K<° Hò°YZÝŠk®©hèWí¨ˆ€„ƒ²Ê ƒø–05Ù|•EiÊУÁ…É$A,€¹”"üåúÌöÚ«ÍÏ^Ýj§b +ÅðÉn˜¾U7**‹½VÎËEBÁ›Å ˜ì3—’RwSÒ(®¢”RÒÏ¥0³‹1GŠpH¿àÕÎ"™>9 2ïÄ (¤€vI%sèS†Î•£’ù-A›âEe…5s$¬Ýj¯.è +› ®µ¿ÐÅÑ$ÔÍìévá.ƒáÚK$ñF¨‡ŒI»9Ÿ€Ö)™G¿š2×Â[%B/½ì†÷ +åDoÉ"Ë REðë¢58 ¤“Sʽ2¥iËÐIa‚?"殓†_B'9q:¡e7yÙŠaI¢t;q!G¤ÇpI:*&äà€ðFTã~ ±P38Í!)°Z–}/m,Wz YÐå/40-B:\U‹†âÈRuè‰hr+ ©dŠÛÉ Ú8X­p8Š1…šlEGÉœ3õQOt¤x2‹p@ ÄÊ&6˜ÍŠÖÊæÕrÊ>eèÆ’`°2×ñûnLƒW[€³á~’ˆ/KBE¥°ïV¹¬H”OŠ3ñUm­ 8ÁÁKÁÉUfòÉÈËl* ”2fãm"¢x'׫Ä®jáÚ¢]+sn­ÝîŽ GÉ@kû“GC?Û­uaÙ,‚]e—n•}ðXetf ²O G¦²'[l·p ¸kx£ÅVÄVeêé%ô¿R(°Ô™äÑ$yô`Ê kq6,à足yXä‘süÅ&…—™5Áìƒé¼)6†®âoÁ«Ñ„šìÆÈRu£ò­Z<\|¨,ZapaòMUÔR +EúÍàåÒB^5‚~OŠ ‘%¤}ÄRk ¼… þRÊçµñEx¿/ÌÑ™<Ê‚cðj³†¾ÏTG,¼Ú£AÔê·ê±d#³HŠRã'EfVtu:mRp@!èjQ¢8Z¢‘1äÅ“ù%@Mv²èžÌà€0$¾R„4xµ[P«uÙ¦¢¤jm"b¹äl¸w\ŸH_8T2gÛ ^m…HAÁa’´DCS"E%aÉ%™@ zM 4èY%:…C–ûè[5£ôdv!)C¡=™ß҅ɪ¸åÑpTÄdVtPÊ£¡¨[Ý‘G©Ê¢)ÈÇÕ59!”z¢Ý"p)%r3B`%+âÀGCÓå9)2ãFD,‡&›F¨Ds§³±,•N©™ ³ å‚ÑhMBèlD(L–žR€6!¯ºVf·Yû¤qÝF*(³”¯ë?M"k¾YcƧ„ä"["Uµmäí»ÇŠeTr~E2Öà°@×:£Á… +aûÝöS‰¢4âùcËYr)Y{¶ Qì¼SA t“ 4§Q`—EnPï’SU«XNL?t…âíxZS1¥u¥5ÃüÓåeΙ^|?Í-+´v²¡·œÄ¨[Ë6,µmÕ|¹,ïhÏ€ŽH^Ä:HÆ‚†+ôà»_{Dµ43Yä/ܪÌ×s…׈âù„´¯%ÙÞÂ6Ò:ä÷±Ò0$0´Ø‹²˜ê£5–ÆcÏ4˜€¼¥0$ ˜ÆûK÷ìk¢i‘þT‘®Þ¸oá+Òá›s˲O\<¥i''Bwg¶’éG}n¨ÎF4_¥D¿ ŒÜ`öNÁxI"OPU¥ÎCüs5;ü.‹•aHJÛ£h‰BpÍh¨%cõ…“vù1¾?Éß#Úüh—)q`ˆƒÿ/Êá”)bˆ bcI‘@_ÆÆʸ5C¥ž×ºäN­–R…#ˆC¶F >.Ö-k“^ˆ®Âé!—v€¤h#l”ûYÞ8HþèÛÙ¡³Õé´õ­×ÔŠêšlqÚ¥»*V´Ž|eV„wʯsrÒÖÝ;K^%¡ÔîÊ6Öe2\_ÏÍ9òé>nRS‰7£¢ I·z!!x vÄm‹ÇÖZ8âÁ ±½×¤ëMÁ€ÈIˆW¡¦±;öxX£Q˜I"FLÇFÔë§T w›îÐ E`¬¬B»íE38NjˈçóT>…ê†Ìþ¯ Ò\Ë+'µÝ¶ãÂEN˜G1Íó´{^DD´uÒ‡Š³OÚrh²Õ¤œ°¦¬-]Väö\D<̦òé–Ç÷a³Ÿa¦éMÈቡˆ¬\¢t…ÀÿPn6=÷Aæ\ùq@l‘+¼«V9,æ>ºv©\„Vsgn¦+èÿf¤ë¬ŽŸ&Sn*“LLÕJIã›–ƒ+–ÝÎì‘CJðzEéÛ TÉ&Õ;?rAÓIAŠš®ò ”}Ln¤ßÌ­IHq0Ùušx.£ò1|¬V ‰®š0ƒƒÔy8„ITýÜ2™ íQ»P3Q8þù˜o¼xü¾ÁàºØï×õ©r溟ׯ+¸³\7íúu=Nô¹.z¥ß¾nPÃgp]QÌîëz]1©[?xàV`q”6°¯óÀ"ROï`@×عùsªve·íùJ +Ø£¼=dâÒVHq8CŽñ TåNx‡~Ðkqðľ#=pBÚÀˆÆ/!Š ‰öÙ¤ðRip!\¿áe© Vᙞ!ćr* ‹1ðì]¦¼Ïàn]ð^ÆùË°0Núš©RWÅÆÀÀõ¥šhï\3µî¼K¤)#é…xö˜·¬Zë´:‰{ÕH¨jq¡Á´[±†FJ‡P”‚y¶OñÙ´T˜±4ÿŽ3ˆd`½AýPiZ“´Ù»H õðL]•U ŸÂ,ΣŠâŒÎˆ,ªWÊùsÄ–¬ü¥KàÀÂGÿéZúÆáÏ›òʸ0 Òòd÷K[?ù’^F{BøÀŠæ΃úÑ,c·ñõ¨AŠHá/G˜«‡]å†Ç²ŽvØQ,oòy(¶áΕe郑ÞÀ¨Ô¶ÔdQiä2ç.dC­ÌÈv.ÁÇñ¤òîmÞ‰\#jrC$˜ì8cõ411ÊÉÛZóärCFo">~”?x 8E_Þ“†-L‹‚áËP‰ð“h¥OåÊÆ™ jGö5`°PFœ»EåvÛ˜"«™Zþ¶½Há^žýß7¬Ìµ¸cšV}@ÛH—Îà]pkløvtö·tG\S»çÿŽ«BŽ·¼lº•%­o_Ö˜8k@sÿ6É–y³[kš„TµCh:x{‚xÍ›õC2ÚïÑ p*@!ÒÒwîâùÑ`§{™å>^[‘ÖÁ($Dv»Ÿ„k·N&VÀ§ …N™oÚ±.¬$ÀïòMÊdPù©¹{- Q,FÊ3i–z›/p»ãÈ%àÑ?Ïî²–>ç%E-`Í,›90¢ª@šèÝËnÜ}¢ Òä,«R™ä³Èl›è*¯ˆfú¼U·ÂrëêŒk÷æì„O¢±€.Û$*Éë kò{Ĉ¯-·þ´¥œŽDWéTvÜíðf%ஶ#Ý…á/‘&Kµz‚— ¹ùÎ*a7&Qmp”K&2rIçÍ„Â+§×*Ï42åm`N­qT$úˆ¨ÿ‹ ®œµÙ€Óêvü-g²WtýYýËŸÈœs5ˆÛuétå)ÎZJŠš»Ñgì¬dU;u¹¦P(nb€±Ù#»…àB< …a¼ùýPfÏ,ÓÏó‡<$¬-`&ô(Û·cƒg rÐP½srÚ*Øj.˯V‘¼¶ +·¶Ÿ&%-Ø9®EJØÊŒ_ÃÚùÆ@mŠ|]Q<†ѨUîZ.½nï·IÍxådx óHýùÆt·E’šOSŸCª¿Ü4©ë9¸ðv\¶M·‚õ¶+«¯_˜±¶£_×÷ÔTIÉ@MøŸµ¡_Xªd•+£ùÔìþLy/^¹³C mÅ0áÒkPýi°kc‰™:ý``÷ú¦áR,LmvÉ­u‚¢C%ú¢¸”3ÖàÆ@µ$É]$ÿÞ󑪃­Éäúú†þvž˜Îã1\ÐÏ'MIŠâ'“SªíÓ^Z¹gn°ûéiû¶‡ñî÷ª¸Ì»ÐªÒ&Ž¡&ÃŽžGš8¼ç£ðM¸üóµ›ÑNÍç!fÜ0‘ñ˜E“:6>G÷)ÈôKÿÇð’Ðð²|ˆŠÁÁC?xïñ/̤˜ åÓ)… +_ýEïg_â"iü@5\ ,³* Û¢ú× þr"J±ëypýú#ª<Ö`úç5q6Àe‹è›´FõpfcÅRÉKÛD1g¨œž¨šÖ'@‰߯›:{šÓ 4D/1֬νx‰BTÃEãQ¿l"ž]ΰ‚¡»vc]°ÌŸlHûC†Ê¾Jr¬˜Íw€™Sp»ˆ€¹+õø +.“.óÒþô¨n/Jq¬müðïBƳ|[Ñ¥Ž" +òƬ¸ì*Ùˆ…Ú&9Ñì^Ô“ ½¯ÚÕf^spJÙîËÒßÕ¢X¤_Ò8£ R¶(~Z©wÊsÝ“ù†C¸‹Ž¢¯Û¥ ”Àyǯ¹~A vU‘t‡'pŽO\Bÿk~¡¨^x/‡]ÅVüÚgÈîÂ=cé-߶¶ oΰáLïûJk_u®bï€a¾G+0fœVÝž°•¦Qýa%ðHÄ®bÚVßq¦™~ hÊU‚’šÝ{„ L>§ÿ˜›ZxO7˜°H 8öuãòé½æ§jrfO¾«ÛdE> +u-òˆQ4±FqÁè”B'‚Ü,hõÚ+PMº€Äá +­Hã9Ü+´ê¢/ló¹6ø¦å4º°íä‰j†·U’¥p˜›¦Ð7Ä`ÄÄÝq÷èWyò·¦‚ôÖôMñþ¬2B¦¡‘à§'pßä‹·vÍÕr3ÿë«t+bMëbE¬\æóͺN<ðí ܶ[¶Šú÷…‹¨ QžyÆr–çßV_aƒnjÀ+ŽT¾O ·¼¶ }È(®—´\¿éSicx=š¿ æ\¸ž†ät ¥Xgçd}ú^¯aIáÞLDˆçÚ†—çÁšHúKŠæ)8hÂ,)*ñsžUn}~9KŠüF™/)b:…?ž£‡â`à'§—Ô¨Œ:\1K'pŠ>¬l4aþê“ –³>ºƒèÒH5="CeßÃs™ûÏø3ÿÛ.¶±üïT1™¦B‰wXÎuœU~@¸Ã‰!©Bi©ÔRŸÙd¯…OOZ[ ðŠŸ59tÃô‡Ù„(ùò fO&ð60}ž*ÑÄ¿þµÅ—5¬“L¹³• ž¦èÓÁJR”Š3ŒÒ ÐÑN P 7eFEu‡×!T²¥àŸ +Y“£WŽ}ÐD½F4§Bªï­Ó[‚«]#¢¾F‰A@‚¥Eô\,ÅS¯j +ŸÊvÅ)£}‚4"ÝÖPá)-O j‚µ©»•#Zö5†P˜JJÙüOS+›ceZùƒ'â:W‚ÊB½OŽÔw¨ç«9Ùb 2Cv]\âfŽŽÅ¡¢ºv/ÑQ‘hRZNŽd "ÈËMö2Š¶gdhR£ðñ!œ(M\«dµm8öΓŠajÖÕREÙ+n5îí{C‰ÒÛÒ&(&åik¶VÛ—jŒIq0¤Àýüך!þ¾^Þ(Y<úCä^¯Uœ˜Ô™¹hRÞÀ]?(Rc|à·Eôã\­g^TG#»ÛUß\V®S5ž_”ÒyZ'º•Êþ +Ô9£»'™óŠJ‘ú\ø—Åpô,eo›Ä†oæ±\{êŒ; HÐßMäÛc«´W±‰$Q”½A–Ëñ° tš•*+_ j|«Maìâ9bü¬V›8Ï Å¿"ú1a¼}oMa¢ìt¸¢îpy†6ÛÂBà£ß™;°úÏLG&‰·ÉOÊzˆ8Ž¤æ‘3NÅVç°o®üÁ™²Ñ7g›—òûN†§`tÚ9Ûe+_‚¿*x­NJ:Ý"Eï$ŠíB Ù¬fTŸ+MÓG*àm>Zì6>©Ý~ßqÓÿ¢Cÿv]ÜúGpì~©íòƒ” ¬Ê+írƒEPo<úëLÚ…š:œÀ¼cë žyCNCÅd¦’t+HXøœÞµÓ^‰`iR‹°!GPXV*±ÞJå~F]….æcIß¡bªžq|œ}<"úã”–‰`;Bž|{Çï`NèöFñð´â©Ã +0UmB–ß +Âà´˶ôÈÃ&UÈ—H˜_¹+3&ûЋ¢ï§Ÿ‘LjνË!r—ÌŠ rwÀõOä?ÖÑGÑKH°p¥B 5×9…±Àoi…í'8lF‰Q]ÃÅûkλÁ±d Îî¥.Ä úGf¢¬wWد¢Å +&\ДŠbv´E)É[ Ø!+*îkñºÐ»…2î;'0°ÀS%zFE«¥ÉdXe?î…Ê+f|î[‚XÊå¤BOó&°½)AXìá ƒf¡ ®Ý®)>KÒ°à’§Õ¡ÓÉ+tœK‘úÀ['VÞÅUäIÔ$àÖi#lVFi†×l¸|¶ \«ŒBÖ•¦$öpœS)¶ßÍRnÌXŠP‹¤ÌÄ8ƒ›òÊ0›Ö lYø8`÷Ø'rº¼èp¯::¬GƒÏÿÚÁ¿F!¾/ñcÙÓC­ÇøÉ/Ù!Æîzž¯‚iÃZí$BŒÜÈ©|˜$ræÅÊy ‡-Š¹Êv¢MˆE"jµcTb8~bîIÀ™âæ”ÊÚ1üå:BãÐ/³Ž¾ìÒ6B¶ŽfRŸôZCú*–‚è[5߇ú¬ÇQ%^}"Š8¼°Fšñhè ଘ›€#!î¿w*Df±–Á ±Ø‡9èÌq%…‘µ/Ÿk"Äø}€£R|ˆË|\'4SQÆ s/“CŒÈ^7ì“ä q_P—Ç Yà!Í )x¦Ds¿þDºŽáwºh÷JÚ£njÛ‚…*î„x×qLË@÷b¦×£Lˆ¡çõ ˜rùÄ$ñ™/ÅMRÏÄõqgà‚Â|r–4aÀ¼4a½þR}1¤>ïÎ=ÚR?rYÇp³ŽÊ!lâ›<ÊvNKS y6s?md²£'TA»¶¦q‘1®TÌF.â\Ʊ©Í¨ùvˆ½MÛù»\)¨•N¤GÚT3'ÃØP„&ÖW(Ô$Ôâb±+'V¯ZíD%¸gø.´<;·x"¬œÏ:Ú „y¢¥ýë/ ÷2*öOØQ›€Eÿ8“×4Wñ©»M‘Õ(‹ÇÀC¿ç€8 Ç{£wÉÊB†U¹[ëèÃQ ›©,=~ÊxÊ€¥ZQBçfí%½©žÌ©E‘ãû! íýÞì»YcÒž~ö—A1‰’ÿC¼ëáÓ¦°Ì²½Ò$LDTä΄¿Ÿ6Zêºþ©®Ý\hkÁ*÷ÅÏ`VË‹ø M£a™ÆŽ–+ÝýÅp4ê«EŽUÆâ«^J3ø ‚ÌÃͤhM¥ÇO·FJ¥^ù,Gœ‡&$릷 t)R ÁÛ î¸cͯ0A$÷†ÔÆÀ¡0½¥C¬á& ÅÀÍWèk8ƒV³|(ë3xzǺ¯Ó…£ášÉFW+4dÈt¥ùÇÇ­n¨3˜_þœjtãj°n8MÁ ‹³yîD5œ}ǸIò7°g)ÈýdT3u%à郲Ójðõ"kd +HØåŒtoK¼òrê¥~PðkLo2§ð¾¾®˜;lµ =t½šx¹`Ÿ×”L +*ºóLÂßw’GºHIÀh¸Üf‡óÞd%샓`„ "FfÍpˆ¸õk Ÿÿ¨b3Í^2 t%JÁ–¾ƒd£ŒJãÁZ£HŒgVTyF:d »ÛÄvãÑkk-oÔæ"kei@Ò¶½Áû¾¹´¸ +Â3‚#oÜß$AC¤×Àøä¤Úž“ý˜©Ü “¡>¹—ée~æù•z>¹»oŠœñ‹àÐkÉKÎ)+7Î'¯ =7B V¿¯m¨­ŸÙãô»ÇÉ:…Î߇[ à,œjÀ5¼;9/~^Þ¬Uç2©êxZŽF?l°ÀH6¡EÔDùyMMû2FÉ¥²ëIÉVHš¼.’QZE“Z.Ë<<¶$Šáp>QU“}Ùg•¥Åª{´`p%½¼H¾]‰ÁÆ…$eó±NA›–©qƒ ¾8@½ß–FAçÊoÀ1k²-iAô”_†›0ºÎ€ÒÀ6“µqý˜+êÙZqÕ&sâH¶pÁG£¦µÍÍŽsu“$Ã-öã—'ŠCÍ¡©të¿„<Ä­ëôÊq&ÂÎþk'—Û”Ô4j•ƒ%øAöŒwƒÜ}B7šÒØ¥eNœ~' ´AÉn#iÊ4$‚I)ܺ)àÈ B7ŸÜJ}@ìžÌý7HÜþQoáö݈M)\v4á É}TcúùßåWøþüV$õá´a‹…õ0ƒÃ̯Lô‹9OæÄ\•Ê&ø4#ã¦4]ak¦å‘ìÇѾ ᗬ +[<Ú€Pô«°A÷csI2WÒÒªJâ^‰khAÎym¢Öú|é e? b»}ÓdÍ“‹‡NÝ4mkõ¶§«!À$W3VÜ\Š›ƒp“øqð¦p,£t0{2¢ûNÀ–¶62g.z)OæȯÑL ЕŽ–pïìÅž•±%èðˆ ‚ÈWä`¥ XTþ( £\*Ûºúq‰p:,¼˜£*,g4²î]&kQŠ|MG€»Ï«O—”‘îKŸ"_§: ßg ,€Qå³=`®9 ) À­ØÞj¨¹jêUÖ·ºžžYؘ‚–À€2^ç…ã +`‚‚dË`ÎøÁ<Õ6Ìþi”Rþàë¥MM¶¨âÕ cNñÇ!SIð!÷V+êèv1yëPpY¨IÀ!| Õ]“Ì3RŽTÑCOí`èÆT±½?NZ¿jþi¯EÿÃÒ;.Äd3;F÷;,‰U]×L8IJC§ÃÙ/¤ þ>zQoØ£Ê2³¼6˜ÝÊÖ¤]U˜°±nÆ%Ì4¤+µËhp´Ø˜K°ö±rJá終\AÚ¢ÃFê~Ð]˜"ÔoÒ9ݤ ›VuŸz¶b¸èŽVïšy¥JL‚èD$*ÝÃ]JîE»ãÒ]pNÞ¢ˆÐ_¯‘¢¢Éô&}ƒÙeãùŠ4¹ö+}ÃÊHjTWC·‰8ôPx‚FÍ3{¤3êS)QQ™ ŸQ`MõIö¨F‘¾WI²Û› Ý +¯‘íùÖÁDT—¾µñðûb\T+”¾)Ê¢M7äÁ´1ƒþ5Ô’Ÿ/´ÔûMü–BtBö¢­[‡„ÇGÓéÙÁ,¯YÃahþ˜ý†k^£HÚb8ÇžŸaLŽ7wiaG&³$[±]ic› °;æ] ›ûA?)Ô|žLä25ùÊÑ’…ýH'c¹Ù_Z¾Ç‡­ÌŸQ]RYujG9€ød|8¾T×D£·éžŒ¼¢ù­NVÏ%¼Ãßö˜C¨ý4·‘½Ùy€ñI¯™_&%î<ÈÙë;¶öЄ>l‰&r$¯[™Ýù»a·Ê”_ ­¡Ÿ†ek¦í´-u|#ò ÷r¯Œ†¼÷7Ôáb.Ø´•¬ ¬ßÓD0j¥Ú¯TÅkô˜&¸©5‡!OWï¼ÛYŒ±»åG”qLý],ï¨kfŒ£–p‘œÇ]UB³¶’ ÖÞ`hÉD² "¸®`iVQeÛ×`–jƒ$ª÷yÄCLu¨ïnE«’&¶ Ì’ÊV:0…F +þÖaM/)nDjªsã©MªÑ\'8/¼F˜Ê ¿-#O®ßv)~‰ëÛÉtnvž2õT15ãy}“16;¨ uЫ3Iº¾²4}|7*Ñ×Rí mÛåc3×wÅúØäø۵ʕ”£E{}(ŠŸK0¯¯4¾T»‡”€Tꋘ-?“ë;—ØI€Ý‰·ÓÝë;ýÚ e°ƒq€ƒm˜¶™0¿rX+¦0H8Yqàsë媠ò8&Í‚æ>cOn88*6)¡ìÜÊP«VUâàЬpÇHáa(ü3Î+vpP rP2¶ð­Ž}kÔoª†ÌÚÅM›ŽÑ‚y]tƒû*ìßÚª[Ikv]êÌEDzURß\bPSˆ²‹¤–€úðk[c{ +ª›6 UÌ%"+“7õjðdÿj´öRjX0¨óõêÄÕ¶ùfq{F’{p&høÌÚÈG5ü°Å •Áüf‘czÝÑVóÀrʤoVö]—Ú)®gc‚»¤œ,÷ÛˆwzÄ!ÈuÖï:ÿœÂªV!·DYwó³#âsj̳ÆÔ®jÿè)¾º! ÞÙïcyÃ!³·æhï™^M •öÖ} ^Q‡ó>{RI@ŠÔ—£Ç<0ÔK¡~:ª¿™fÕg%ÝjœNI_I(\ü§FÍÛª²â¾Ð(âÈ)øQâ¦9ñFǧƒÕ·~ Ù5õ|ÛÉ›°K‡õP \^ŸCxkÅõ:ðXUýê$,{bŸCšðTm.ÞÄ¥L +áªN×.“­Å +…Ÿâ8ÉåØfàóY›¨¾MTwU 'T2Ï 6š©$CXjî8Pm¼Î" +Ã-úÑĘçºõ¬”¼êl`tãs¢C÷È‚¦º<t¥c§ÍF£ZeÓ]!žå&8ò>ÍøE[kÔà-•Ãâà8]oÌ +îCúŠvK¿Ó~€D÷¶¨èÐÔÒ3 +1=ÀM7+Ô¯]“ÌõŸ+g¿¶|¨ô†Ùü!µƒ1ÈP£å»4Ïsݤ€c/br”Tƒ±îÂQç¬||fÜ5>fÖ¸†\AÙØ ëñcÒññû/\åÒ4ßÛ„<•;´ +uõEdå27ºD€¤ê­Îòɲ´5ç*'ù³0Þ¬¹$¾¶EhëC,p+bŽˆ]'»hïhúêÊÔ`G\ …»xÀ¹o<ÈÒ0äÙiõMâ¦)L\Ãr7\wbé%oÀ¢_rÚâlË8|H+”1ļ%³ƒûZ.™‚¨Sx­Æê¸$±´‰Ÿ»òÆ.Ê n7|ŸøENWü/lí5¡©µ© 6窯0˜w:IëÖlêµl¸Ê^» 8Î㕯¬Þ0ˆƒ×W"[þ[è]‡sO,ãÍCERÌ°kžÐ4Õ]9¨ì4Qüu× H6 ¥”6>*­Ã?k!;Á‘V7”œ9ÅžÃë÷)ÿì-øí¨àiV®Y!3»3&. ûp^œ‘ÓÓüèõÂ`äÑ­]ÓÁtjç¹+pábéË»ü¤ìc‰JbÛ*BèÈQûŠDÊM.<}@8˜/Ñv‚`unàê( ÍsùEºÜtØ>«Nß²èg,Åé`Ö.T8¿F™Iaã¤Ùà¥ù«8S!ä ÐÕK@¹ ½gÉÐÊCiªæ‚(4˜AÔ¡‰yä¥ é÷Þ*Å°#…ó–”…8dÅѱlÂQL?ïDîIØ\fÁc¦‰3égfF®±c$á³ß¬AMÃK¼Ÿ#xå0ŽÄÁ`’8Îz…;^mïà,½Tÿâ”(–g"óÂ4ò¾‹ drä9f U4A†‘H{]´>åÐÙmX•/T¥tj`|% Öí5GÒ#Õ {n/K@ù% €GÃn7òÀõœpgGÛ$™ ·7€ùá.å¸Z9;rŽRÛáìLJž3¬¼j_Œ8¼þã +.ƒ‡6™è$¾ÍØÆœ¸ãMVkÇpcÕ;ÒŠémÐA"ÚúÄÊ08)^0xF¯gWr¥rÛúŸjØÙIÕSá=%bDTatkdÃ{üTÛÝÎÁ†}jÒº2â˜uzŠ—›U=îÑ’T„ƒ + +çže™ÞœE Ü¡¹¬€|—ŒM¿(÷=†²[55…hðuʳ˜ƒïÒߎN¸/„—HÔýg›(µYp®I a›¼ý'*XK@()ÚË›dù)aÙ‰”9)ÍpÐ u¨‚¶pÐÉÄÉb—Á•áÈG Yµ· ~×p.}Š¾Ÿ`¡YëZôTHò{J’(VþÙ?‚b¥”ÖR´êSVQ<ÑzÎ3Õy@°ºS4z,=ûá+ûs\— ý~Ž˜è’! ­­ÞÂ;^ʾ­P+*¤‘2êþA‘Ú¸òIúr½´îz>b ~‰³•Z¥±3eZ­NQøý/x욺œò©½a u;‹a9­”“U‚ œÔõàŒÔòUá=ʵG½_ûûØho’oÀÛ™™æÀW°–Ym²Ù:æôµYæÌáà +×d?Ǧ†Û^EñerM™³7Sž½aç%7&üˆGc†?À0Ӿϱ qçÅÂâXYVæ7€ÖÚW@0Ðph¢ 3ü]Cû<ʹúÉ'ÿeÀ +@½¾v;ÆÖGÞíT~é>3øeùJ¬.hË-áÿojÓ1¹ËxU +¡)¸\í² ™öž}Æ, 'ŽW0¯+“üœùìH‘/}Ʋ·5uåiö†<&<Ÿ¦É–²É3®$²!î]× Ç(È¢ÇDýÒ4;H°­F›N +;Ûýf’€`*³$õ´Ðí$%õôêÆÝ" +Œµ”(‚¨å}6¢fàz_g7t]s|ûò41FÎÞÀ¦$˜2˜hGÔbè,‘I,íéÃ]“¦³¹Ö†óÛ\Ýí>¨Ž³•N"0‘"úxpÉ$'-àÇò' +„¹Í +ûØD#+m‡÷iž0ïAXåE<“Vž*Þø˜¡Ê‚ÆÄ%b%úÙ\߃†H¶ÀgÛ"+;ûo…ñžPVÓòóËk0õúù÷ ·´´m`VZ=å,e«d„¬ŒœLD½¾+´4†N¢£ãj$žKjÖi`Ûè'L©yá§À`>Q[lñ"_#{¼¿?ç±WK>QEû·êÍÐã8KSgžW¥B+›žðFh´RDzf¼{ Hî-§†Ñ0ŽäòCNÂqÝ~R¾Gä®óÒè~Vç¶i Æô \µÍz®Û.A`â +÷>e Æl£œâ=Üšéb¢ž™.ºŽÎ, +àU NC4YE½w¢fÕ ŠM"Z1‹D¦üûNœïf"úß,H¶Ô¢Í@ˆ´Å"û˜·Fæ§ÌUÔìç1§ü ?fqÜ£ _Ää™Õ³ÂúuʳU~ŽïžÉe{ÒÄrŒÊkAýqWšåk—FC¥áE悬²dpæÁ÷'Ÿq’¢ñÆiŸ0t^J"lc% +øD2:(%/­›MÜšÜAЂ'gÅ”„S„rK?$ë­µ¢EÙÔ³S+™GÎA › ±!–¶ÒÏŽÌÀUQõ•ðêhBJé*?þ’H>¿b<ôî?“ü›ºÔÍ ÓBWêBdmå™aýož`Ûé¿‘‰s4á+Ôr³+QÎÓ‡ +Ð5Mž–Kž–wš²‘¦ðž‡Ìâ­ˆöÐ^1KPw“l~yYË«„*PPŠyÎl„§æT¥B×v9ôY$_µ3¥‚Øñ%½æVîí +ÞQ•óNáMåÈù)­ ´®Æp¹'àmÍx¨z&×y¤K’”d;m2‰±2_ËH÷A-ÅB4 +‡„. ¤à,ßÌ GB2C…×O9®û¥ð-Ù +'pÑ6ÜéVßù ʼnԈL’)p‰¼naq|ñÿÊÑÕ £ø…ÁQ›ÑãMaŒÊ„ Ç ØÁ¿aæ¬p®¤¦W·ÙòñiŒ1Í^TÝ‘·1Zr\K’ô¾m‡O•?V·ö» +Šª]áì)kÜfÿQ‘íJü~ZÛ¹)Î={‰D‰þ&?Mìàç\­/Vð&oî˜%DvGŒÆo€:”i¹G‹ìõêÊ&ÚÌ/•ºtÌ\š¥|íæíBz Ðj$¯Q{Úî?@s”CXÚœ´bnå)wzÂ%þSî„–äÊ8)¤`é›;•—ÈhÒó‘êëúÔ‘ +)>m7¾mi5;ÄÛ^û” zÉJœôš?°qGî÷âÉ!j¾uKÌZþ/.×kA)¯­d(íÅ‘/÷Øw@ÑXSÔLÒ÷#1*x/FXèw„…WêFçÌY2#]µKÏ+ ÀÏé +JÕõÏIJƒ[¾sÍ;ïÁÁ©ÕVûn>½ki`ôŽÜ-ÍMÇ"‡æDñ¥ŽÏ âÀÛxƒ´¬BC\í²Ê}ü¾'ÅòcŠsê2ŒV{‘}âH=¡Õèuÿ#‚stPÏû^Ú_R¹°{ê‰Ó9ÇqrvÔ‚¨DÀ!–Qzâô¸ñ˜De&j@ŠOzQ1hþŽßý†•îס¨âìj(ÃY/˜MÌ1>²ÌPDé³ 7îriÕ̧O¹b‘ÍÔ»¡ÜY­ö§•ã}ä$᥋WÆÒ£­ Äêìk˜?ˆ˜6ó#Sï˜Ã¨ù-°ø#´³û7-ì³j¢H¼ÙXú(é?0%æ­L~©`&.HŸ‰$9Á¹û§©ág[i^í¹“˜“oúéÏKb§Fæ¶{ØT12W%)ŸT-eÑ¢Sæ )ñš7Š—¨:*‡!£-÷•H#†u»Sð¿YÇámC\«vÿ ìÌPdFÕ2^‚ìqàDÚÔ/‹PðëFQóÁœ6Ú jø~¾Î^²ë0fÆ¢Òƒr}5*{lþ¯™6&‚}àêH#½Ô¸ Ry…¡ªª6 Iƒ3«n‰ºܪˆ©%élú¤‹±3*NëNûÃ-;.SB½lŒp­u£ýûEϘ…Mâ|°ÆOÐŽR­ ¢œ,80‘•j %þCVàDbx?现¸I_’Èz™²\c,äÿi0ß<—½¦ëL e’.$éÖC&0”Îr‘¬Ç´²s¬sLÕ`œÕýfï}dšnÛØ°óe¨¹ñCÆÐþ.b—ôYªZ!òÁ&‹· $4k>òHëÜ$èse½685”Â;s0qtßX¹G´,?9=*¤{T“íC^uΣàRìX¬h#Ò_‡=i ŒN wrŠÈ2*­Qƒ. ·Ð;o.Ñù !PÔn‚o²·Z„¬†É €#°ŸàþÂÇã§AÚÛšït¥1ã‹Ðs—~Í¡É‚ó ã‰ñNìpíÌ‹ñ*<œJ5Ž‡Ëcçs=Äñ'ÊÌÜHnGbKàÀH‡4VŒ‰I÷ê_tÁªÃ:¹\ëù”Û Ü$=e¼óÖ 2˜o…EŒø× oÕ!ѸÇÊFM=_AÌØ£½çf>®'p3^ü&uñ;çp ƒ˜RX-M~þXi»t4Þ¾ýO½[0˜ïââsè ÅëŒ@‚ê +Uê• +¨Rµ†¬:æðÙù³-¶·HÀhjHD$³Ê}ªCÝ÷uÐhð6£©f«ë±˜#:pÉ"Ïðð?v]8ó†ð„.P{9RÄuÍn +îÊ”'ê²l¿]]ô`Ÿ-MAØz˜•Úi¯u´áÇŠN1xÅGaXŽèw¥™3¡\Òp舒¶[#3Š@ýVZ{‰þšŠ•æ$:ë2éÁÀàŒG£‡xª%•þñigFÞ¨j%ÅÖóþ?º¸g£y,ÔÜëQ6÷ž z9"žš7“ežÒµr׌Èlp*·p÷i’ñ±¥}Ã:¨5w#“;A¹Ý?Îõ¾ +®¶Ðø¬(¢á,ZXé*Gÿì"̪1ø¿—1æêÂÛ¶¶»˜v †-D•ûdˆ ÕmX|)´ðAÉ?<^ktõ·´tvŒz€2 +7ÿ¸˜_¾01Quwr&VÈ™ëÚœ.}/uŠáuÁÙ,´s$0sN¨h{K"set§:Î&¨9o,˜ÐŒtè›nH«§ç• +¦Ø-²”Ž’þª]­äm/Ì“n°k•Ï±·ä÷é5%¦ÎżÇX(0°&¥hš$˜Æz`\í¦³ o)¯?'þJ=bIWRÓ"òÜ#\ÕŒ9n'Vt œ =5l£ôiÀ©óW¹'˜HS·c=ŸÑÉO /aˆM¼&ј ¢míê‹™\rgÎ?#òPG]ϲjSa°¶}œá…–[h†-úåçŽ(YŽÏL|g2ÍâKlõl%øæ „ƒX_JþLuwî.µ +ÏØ”ÅIäX,ï #çÍa‡~Qls×IÛ­Ãßlr4¥·¦xie‚}EñZC–â-¢­bðm`ïÔ¯õ¶B»`{Zaûäµ¹‘y‘(‰æ.»†yáNÎÀ=²WƒÑñ„¼`´ÁCoW¢¿¿Pi¨EF«™JÁׯ§e¡ªÅzmy!òUì7ÓŒ«oŽ•¨Õº6ÌÒ…]ð±‰Ï µ'êÄ&؃êw>ø +ÅÜ­‚þ7oûÝ"’`üÙ°Q¾§žHò$k$gªm3õ‘£Ø)ÑW‚úå!žÔJ“MÙi§æZ súdé[ÉÐ;í¦P~¨Ì ’¸f7Õ02ŒêŽ¥|†›h¤ £„XÕÓB]Ë~(²(s^ WºÝI`ÊÚàŠÜæòÀêèG¨uTà&ciÂ좑ð¢Õ2­¬o‚ù)­ùë‚“é9A@*ìˆØìV¿fª³Šj}Ï¥¬Œ¶ Ú¿2dÍ’jæXè0·;¶¨©ÖótºÌ×O(½î¾Õ y—ü,@“§")›PrûÖW“C#uÀA1¶oõ|šÙû±ª¢Q®Ë}w6/ZqM8ˆ 9t=z•–-" +ŠÈ&ùh R¤qLšÆDg}¤xGYeÈQ¡¨lZ<+OùUȈØß•ä}̬êÉþîT© +^ZÕîxïyR,ç'—",ã»Ð¦! 0Õ~ø‘[Ï3ÌA&ºEë›óN¨ûg})“frR¬(z-ƒÌ J£# +üa\£“‹/laqQT:ê8?:ï˜sF“åW” W+›Ï+>þ·JÂ)YÅ™«ˆN"¬„™žÞXè,)Îèóýiâ#™ѹÑ#üÒ_Ýü ¤"°Ñ€Ha·éÃü"Í+ÅQTôë7´ë*÷6#I£*À™œ|…F’DŽŒ;(YÚãk´à!jÓ`D!ftÎ~%êÀ¾TOÌX#á¦=Î'x *A<í¡MàT ø8Ì-h;5|P´ÉÂLü:qûBÄI8Ã{gÇ)ùÁR7ãR‹Y&Návæ^Ê^d¼\´)Ôƒ­`>´Æ¬Ñ3õ($š…»_ŽŸ+ð ÃÛ<ÆÖ||ï´G¡E…:6ÏÙ‘ûS† +=´£eÞÐKÙ|cd4tƒ÷—Óùg‰NóðoÓÛ"Í“‰ªR½}ÊG·ºQø·8á£çMt6R q•æ—¶‹Y]´!ó«ãMÿî¶&Î1Ÿeš†¼×`~T…UaB!znZ³4a’¤‚œ> ÀéÞ°7H§ØwW4ÔlŸ7SÏ¡ÃÁ O%åƒê«+qíaky΃Ù$áÔ¬{6iU ?Ã]íÒ÷¦íû×ëú5€pŽ…¥ ãbE’öWJ+»Üó!úVö0ª_?/ùslT“ ©hO>ÊMYµ»(í¥’Ék 5}(»Ë6ŸÐ¼› `7Š5rÆÔšô©¨Ñ²oØñ¿—F0Üиµ ¢NFä÷ñi—Uv±1X< ¦ý)„²¹9ÚÆðd°°ðU„Ÿ‡×ä‰ä ðB’+¤±`Dú,´w†¥#œT¼?M‰Én…O`-¡è¿I“3Œklmô~mΞ)Á'œ$t¿ä¨ˆC!k\oËû8œ˜F¯YBЖF²i¼ÆÎH(gHYî¡ ’Óˆ¹×ïîž.-ÜýhÓh*ÊV]F`—‘ü®’pssfW]9°Ž„¼GEÁ1f¢fÊ.·ûý–ܦWás*»L:¶©¢yqâ¡ÍoÁ/Š"ƒÝq‘>+’…9¢J aZG.?”¹  £œ6tˆïÜ-6Ήd+ÂÐxY\¾Ë½ÓÛ^±×^‘qØ„A^ H£˜—3;r ‚csº>“æ¬2×F… +—òm XÐÒ:G³¼½r +ý +ž¦gŒVŒëÂ1cZJ5€q•ASz%Ú~cÙmÖÆ㜼{)W"PNl9±gÙ39FÐèk¿Dÿéfp3'ÎS,¸ õ|]m1ñļ@|}Ô+7‚z¨Ë%úˆw¸Åyw|éT)ÛjÄ1 Æ½É M%ùÑ>iÙü© ÛAû‘þ°ÁñhÆé@6+cæ]í\v<‹"ÕöÀ e>ÔêÝuo3ǸNµÖŒK`öv˜#}yÙ‚k^J—äæ0»{ÇóªŠ"©B°,ÞøY#V/l—>3ߨèlÕâ&(}á(h£èЯË)Û—#J6a¿S¬ù4‘Eê£Û}Ø@§p½ ¬e⾺"ÝÃlQph-Ç€S^ñ;:,  ãçív:?ŒÒOÕ>BUÙ뻀n§ÅI©¼`Aàf¼µ/|·Sç{[ñ×FÀ2!õï,šBudƒQ;¶×n–ð;p±ë¬+&ÕÛŒã+r˜c’ŸLûýžaPÉúÕ¨Ï!P/ê„UC§*cÒ8fô Tý‹:n97ŒØ 9µ¦”ðf©ï§WRNd<©²è˜, +,LW²Á$)?ãaÞQàÊÔX»¬F}Ù2.ƒ¢¢·Ý¯ÝÜrePN‰M‹~¾@ £Ð3U.Ž RFÐÓ+1 +ûË€Ñ'+q¾}ˆÇÒÇ BWÑEd‰žríÕ»àCICŽLtæ§X †6‚h5¡@”óŠC|ÆP¼m 5†*±Ä‘œÛž‘áO<9˜»#Sl9ãpË ~4ƒíå=Î{‰Uñ³¯ºçÝ13»»ò|IWqÆ0[—`ŒÀO3(_ãnê³ãp +ìP…pZlÌo?›6óžÒÁôOĈ®ª‹ål©2/ûÞ‡àÿÖS#A$­¥P{•äøµ?˜*Q­ô!¡áOZeõ؉íøi"`í~QnÈWa„.úYE/«ü3¨eä5R#–¾›‡\²PðH”Òú`AN%eœ®®>p«ýN쨙þµ½áíJÀ1?¸rÞ +´öPŠÅ0%{=ÈG"@xQE(6‚`­fóì²9ý +Сè=¬ê˜“P•GFœ@YÓ4ϽHR¯Ü qõ§V×ü\¯ ôÐÈDåhå‘ÈàõñOA@l’!x•qoõ䉪°ÏAs1(‚’;C“°œ!jâ¼´þÛý»wfÒë'¾7 sJhÞ]¼RÇx¬I]pDâ‚òu2;†«Õ{Ö€zÄ a…ñŒ@1èàWmªÅ£„÷ÿñÑv˜õ5úâáè)† )%qŽPÑòÒl¼ Bžƒ6ɨ·koÌ°`ùàJo"Ö°ð=ž X߽W*Ä1N%èš®g]x“uÔξSŽoÏÁ µ7óúvjßùàƃÍæĬ^.Ùû­·Ïª€R¨Ã+|Œûá±1ÅùÃãdò¶iÇÌ5øÿžfƒâsÓ¨ÏÞÎç¡f A+ßšqröðP×@Í‚ÄX¹ë™j«û¦(’äÉž4ÐŒÉ÷íZ²1è×øHh,M* ŒB}£ ”²Ï§Æ rè ÌõàÆ_Ü›íY¼@ä +'›´"îqÖ‹´Åt°´Kû×à˜ïñÁžaâÚ®_½m,ÅSª‚›—…¢@Úbh$Ã!(”.g£gQ•Å¢bcÿÌ„yVX%龈ы8+¬0Ü"ßvUXèAèÉxlWØ¥²(—‘ +ê}Ž^]‘èéÜ쨄ÿg‘–·”} ñ^¯…ftN5ÜKK'¡}LMÿ‰Jý¡w øBªqz£çC Uûádl.[H÷#3nï†΋ü„0a¤LŠ$ÔHѯ+ --SuŒÎðŽT-læ$ +ÚŒ±Œõ¶0â1‘Þ]oF}n"¶sc4Ál9ï¡Gs¿ 4±Mc¿M(þôEßÞO¹eVžÆ‘-RK e&6«§tù3 ]é9oJ¤ª7éô‡¬—Hìh¢Îˆ¶ÌÄÜÊÆYã®;²ÞgÚ–°ø²…ÇŒ15U9¢ëûéÍ2Rs¥S—ÙŽ†Fvj&Z …ìÕ{Šôfð ©b\uÄÚkn zؽ^&e¢ïðÊŒ¬„CÝ–dZ#+‹„näâ;™˜gbbBæ]hë„\êà…ÃO;‰S‹Â³¨U¤Áj8!v·Šäöz™‰ù—)Õ+±}Ï•XZ saV¥¹¸Ž‚9s¤CÉÌŠ7v&³Ïän–ŽÉ gLÌ¢œ Æä¤,JŽæîWeÆóÝ(¦7”†¬Ä±^Eo°1 Ýã•Ú¨Eéc|Tº å.3íØ‚|ÃÔÒ\è¢|™Ýí&Šïs9»DHì0¸DŒþ/‘ÒC§i.“•Ûau\e •5AeW%©•iñg€`6$©Í¹ÛÅÎœ“Y\Ò’2æ¦5¦’ÁKi=QG:uwäé-‘²³õÔ«h„VîdÎdŸxvÑ~°ÜÅîfõ™„]jD£UÄWG.ö•Ž +iÖ]øb“çòhg›Ìû’Z>Ч¤}eäCÏ"¢Ó+>R[ø„­“ÿòétDuüˆ&Zºx‹-I8a3R2Ÿ›ªŸZ*-ô„5ðxsŸ:̓£ K9~£¤cwz(vŸ*hda]j2Y¤¼ôRÖk9F16†Ù/Xtå±´èb©ôb<ñ`giAØßéN¹‘Õ™×8#*™“ +¹E$ÒÏ”khqJ¢"eéÆLQRt'Ö«ÇqÄR",:otv€À`èòEݱ@4ÐĈASF„¨*äÁJ ƒEŒ¬ÇÛê›’Œn)±§¥Tj’o¦ªÔÜì* 0˜ªoŽUËWeš“±hNÎæ”"©ÊÕÍHˆ1G$RVggB2n”UÕXSw,PKÄ~è­ ÚÔæÐØñÓñÚwvâüË]ñ¡A-ïeHå稛6ŸMBº8kGXuÇ˺‚ièø™Û¥uò»ß” Ù»hõ!Y¦dlwì9gDŸ'ån=ÃVf›s>!u'ÅFõb‘ÂgŽ‰¢è +¬‡s¾0QÅÄD~xMoܑΆöî6R8ÐlÙxw…¡Ø›-92és,Æױш÷¡øX")sL×ì/åR™Æ°¸ŠjH>npz⎧žVË;–õ)>÷ŽÙ•?C'‡Q¥XåP§­ô©gªFáÅå@tÒª,ÓX©§wæ7FÅ"²‰çüí‘è駬¬õM•s#/VOäW«¹ +}¼M•Sdïåz+÷ˆLlUU I™³Í7²r ‘béäÃè¯74Kˆ5((îð]a#á’œLÔ7yÒ +š2eH^Š³ÝÕÑs™y¸ŒJ,òÕ"Ï~'NwÚHÖ!w…¡U,…I1w¶MWt³Tô¤Yª‘BÊÚ›©Žg6% Zo÷°´:“NÄ¡W\ݧ.æ_jVa s߇éRñˆTa˜)³]óS&b¾9¬’.ñ©îiLm†ª0˜k,‘ß]\®8æ³S¬BÉ…T™WŒª* S²ëϾ +Cùm™Uô¯qœo$‘* O¹\k´½(C"ú]½Í´Z1¯Âýî{†[‰«x{ÕɽÈø(K­ÛÝHÅݲ;lU ;u+ïdU(jøqy +CÙ¼D¬¤Ù„™Iz#Sÿ.UŽbF¨®WŒšllW…Á4$&uź ›Öfæå»tLþf‰¢®Hgܼh+rÏêa¾BqÔÂÙ­Nã…«/ìì"úÿ~Îh(ÞÓF¡ ë?w!’_>Fªsê5 ú7¬¨2é[F58e/³žøõ-ǘ¼Ç«²òØcbäaXuîÊò!uLÞ±SL:z‘Í{eõkBæ·+1ia°³óÂÀÏ‹DF9ó…!ßéÌüµb«†në˜]¹^(êÐÅnˆn¦MÐëzó³Ö¼Œâ1³£ÕBÂœxdjãIïh÷ͱŒŒµŒsÜe4ä¡6f† ‡úšh]¯ rIv-+r>P¯šm'rcbr‘±»0LÐn®£e0¾‘«x‰ÿاl$ìuÈw’‰;wÊ#;¢?JµWŠc‡GÊapº‹‰Ý +kÛÝ—WT*~=Wp\lÄS¨Jÿ3©IlH~s{s”ÑëRì å1Y¨¢v7ý‹»tJ®£„N–ͬ›ø(E¤•Ÿ`VQ&¯\) ¬”k*ø '”¢r Hô„;@P C§N\M£+3‰1óƒ7sô©¡_4£56Š‡Dlh®GtÉ‹WòqñòyÑäÅ*Z‘+õÄù] ‘ŸËéú9"qJ4‘Rþ˜Xþ ‘Šó)±ºeh>V³R±*Ê~®Ï ÿ¹ãgL©ÿæ?TÊÔnÞØGÄD·©éwó›ÇS›ºË_mLX¶13Òͱ±â ™ðh¬®l*´šŒ +Ù½¶Ï·©M¬~¯8@Pp’…Úf!ePA­¥;„ÑrȦBÊàãêOvð>¡czÆø´Zô6Ù™ºêrQŸÃÞ¢Ñ+f± 43N,œHôÅ‘ÉRvÖBñxQUl”BøÊY°GëþBhÑ× +321 ijÐëêÿÅØqƒ2ÿÄ3b ‹ô ‹‘&Xr +fE#ÄÀXô8–…‹f®{°7‘‚èï­Ñfb‹2Ì/Юz£83«F‘±pRR°±0ð¤^Ðäâ›}JŽ,PÍÂØ<È + ’ð (¸£šMݳD2Ä›#ÿ€Dà­7 J ­@ó>HRS§ 2ªjbˆ/Š; •c˜Üâ•ñfª2[LTZy¹ÕñÆ dBÞZ†¤¥à4¸ÝaT*\"`àÞgá6Á1WD/óƒ¦Ä³Â@e¥zg¾Qׂ±¿BµÈ¼¯Æ®… ˆÄmÆ]‚±0¸nž@ H  Ð`Á Al`A Á,@€ +eÁXà``:` À,-‚ 0ÐÔD ÁBàh€Xð @"ˆà7"ƒÀt (¸¢zD°O0ûŒåœ‘IJd{MUE›ù†îR}êÃÖÔ¨¹¢Ö*E2Ò„1¥S5 5WWæpéf5#æ@ÇNÇ$}ýbP]}ÌÕ“)³Õ±Ðª¹¸»9Ý&á§3VS,±EΡü£ÑzEnÙx|'c"viÔnî(Ì\çÓÂ'Δ˜ÆȤƒêîüÍ4£ò4sð}nadQ:®êÕ„"d»±ŠOi+&Øz‘‡T+>²ñdîf8êˆ]u‘8K{–)ñEmG6—³zo©•™¤ŠØ©Wd‡C H@àA +,Ø4P L p@‚ (0H@  :  d`Á Áp€H€ ,˜ÀP` wÛÝFæ d˜à*g«#•[:²ûXƒ^ÈûMò²KP0Wv©Ìœ~œp¥kp›¦Žì¦ìl¤F¤‹´†c•»ž@¢RGÜ»£œ—1Ê)Ë”SÑ”ÿ4”ÓÎ::9y=‰¼F(òߢ¡yÑo©ÈkºÈër³ë”w55ÕîìJ¬>VW2¥ø\V~E²á«Œue¬)ÕïN$êêEŸ?èªÊ^%tÕ¹š* O1WCÝtÝÔЕÈT†ŽgE¦T©Š=“…fõ1+šWD/ªÛæ®u75Ʊˆ-Äø©(»¡OÍQ‰U™gj¦G㻣©L $ðµ¹ Kù4§qBÍL°êAi³Æì~™ØQ,Å›÷§ ûvNW‘±bTþ21’OÌ¥qDÝÔ©eš£3ó0(ÊÌÙI¨jTDÖ›hòíÎÕ"Ÿ*ŠÈ£Âg$®Œ¤Qf>95¤„„µ™‹»#Þã,º ½l·{t'FTEÇ»OöúD •šŸ‰ÆNÿ~&4˜XÑUÂM‘&Æ®`^´TþÏ¢J7ýB)Û1êðÐë´S!`ÛjHwR±ˆ–²ˆ¢S&,;†ÑÓ_Äe^él´\jŒ®•à6J e+ŸUz®PC߉bÞõ³˜\¹aœÃfÎnkp¦Ë5ÂR0öEñ…¡Î¬º`‚çU¢ÎQÍ ´\ÝõS•`r'3³­Nuã™Ñ”Ýy®uš«OºÙRé•ÈÅ$3$÷1R•>!ëÆò£éݦæ,hÓI«lîyˆM;ÃâéW5qŸ©šû°hÊ•þ”XC7r6\3$2¢šzO¬ˆœÌÏsÆ8•tç[uf&×0 ‡Ê46¡‘­8s¢K^šXNkþ"Þ”1ÑN«ŸÙT§†<=]Ïã:âØ‘y"ŸqŽF¦j)“%<“&9»e¡úMj©2¶t“¦>§öÉ:“º«–§,V2W®d㉰wcÒuÌÒ^žO;5G  +†±\Ù)°k§üX•nõd/ ud ++ ÿYæÓcOªÔšxç7 +1Ç–O«p1Jiq»[s+œœæÄe£s.aúœ8èÎ[O‘®ÂTzgqâ…¬w‰R,˜¸ +ËL,¼î.ŒTváõrÄD!SBsü£Šiâ²›ž* Ä –¦¨tîŠVºXÓ£NÑL@óD  +ÅÂy8 DÈò&Œ¡pű0L´NdjF•3-G­A­U…tOu9#ÂfKQŠADñE#Dr¥(×Þ7kà˜ìýr+ÓR·wE;²…øÝn$¨åZ}s “]£%Õ°¤Xý]xØRTk÷5pLö¶jä)À°mtî‡s\U371`eü î>½œ ¼ILòN…]Ò¥Ïyc®ÍÍâ•Œà$ +VQ;(ûÄ>ÁVGlKÑ5&“²¸˜ÕƒÕŒ'‰þ«bŠ5Ü»ïï4•Ðezùl—UÉÔ‘N¬ÜVFé)‡l@Û + Ùp¦ëâ`¹!Ì Ö‡wYÓIZUKðfѼ¦4ÿƒô~xâ0‹ôðJ¤Ãw^46G ÈR°ºñÅ®1ëÅx…lÅZ´+¸@²TÀ ^Ï\§Ùáe¾ª÷O 12ƒ„Ëé„b¯w#]l«—–d¹ Zµ€ƒ+‹RqS¤>š| © Y¡eËð)\pHÀE4Ì2«õq¿¿‰‹‡!ÒLçýoÓsã4™A0+K4]Hr†L˜w¢¦" Ô/P`8°Õ²¡ÄVË[Šo›?¬UÁýŸeHÙjJÓˆÔr&χ÷´MуäÙÏ[©¤h´ß!ÔŒ˜(~ƒÆçÜúûŠM P+´r¤ ~1@ê8AÛ¬<¹/Q@7ƒŒÈªqç„HÏÊ$%^3ïÔnBåJpí1˜ˆ @(œhŸ›·µ +3%+MPgÓíÙÄœÄQvµ°6_v«E:÷Å%Ak×Ó÷À ÀÊ:S#ûÓø„GuŸÿ;ô.:;*°0–o×Ä€Ô-bPž“ܼºF,צUQÍ•ANÒ7¹)Ú繑„Ž¥h!G>(:ñ$‘z#i~÷r¡PÅÇGuÚ: $Õ€NÛfÞ¤1µ²!ñ¶±fð¹ªfì»+÷€¹­&kֲ煫Ý-²*(ýîT„>úe㘖¡±nt‡k˜ ?eŽèŽQ¼Ö˜ž!—…cèjÂŒ®P«ž«ÑèƒæÙ=S#3UÐíj à€I”]=Öša¤Åª ÞÖØ(‰Dª‘+ìªðì‹V¹¤X$«EßÄPFÏ|««¹<]ùÏ2åX(K†6£í)89¢pI«§®>=k8¬¹[h]A#ÂÕÕ뀲-æú*¨>BhýÈ)®6„jï›É4ÓÌ(ùl[ü8s‰ +#ÛLðRèT×ð(#U¾×¯­ÇRȨ”W–YŠ§íç×—H·¤–ÐE#µO_"íÛ^;õõT/¹Ì†›Z¢½Àð ¬"œK£ÇçÖ¬øÉ•x{åzäjh­‘çv1É}<ÌF‘¨£µ:ßtykQ6Ý„j’í?Ä1É/ôĈ.Á ^äìו´:¯µ€²3ÙúŸB$,qY› 9Sk–8î…SQüRË^×+³ÜG¹-ÆÔþžÁ–þOzzÀ…g*Žù.0ª²Q z›ª[“†Eq7¨†¹Ô¸ù¿¼ö!…Gµ€û².\Eo[mƒ“ÇÕ™Y§.UÖJß(~o@ƒð«›6žw·û²Ê%”U"8‰ú}Q0+ùÑ8?‹‹EJ&¿¿šíà.«dÍê>hÜsYÀ69 5Ï!æQt¥.xBŒ©@KL¿ò} +†Ê¨õNoP Šì)²Â„LìúðpRÅ ¨³¤­j?õå±ÎÇéHVÒ·×+Ýç3 åR1URÑ2ÚÃv ½¦ûk³ ’n;b&›|V_å\Gk}o‰ˆ .—`èŽ1Ì›óFà%¾KR%Lõœ¡ùˆÑRÔõ‚èr.¬ÄQ.¿'ÃEÀrÃSùHšcý ™À?lȶ€õ6N±1X¢1Ùo’å©¥É2‡Èô’3nº|v ñ!E`h¼X­öû$Mà—LP\ÐÕ˨À,xõDM2#T–Úö†j#˜ õíççéé9«Yá} Ø”s°ÍB÷Ó>³d»‘.ÚªcbzíæµR–½y¢ äý7ã=³?¨3O|´H¶ÝÎ*ÙenÁ¡8ë{‹Æ£…8:o@š0ÓTAö·Š0Ý0ÓÍ`Íu´RÚ<ös!ïÀpµ p‘ÑFyñ¾•S¯âúriR ÒˆUÙÌéLÙù‘ìʘ³;7ed#õæXŠÆ§:-(/Â’Åž—[¸Ò3 ª7—–¡ »Öª„ÛE;Ò‚éxofå\q½ xf¤$¢ïoB¿êõòõDC/ÖÖ|šPtN}aҔݕ™iúz:sp*gS zôá²×¡MÎ ß$,•œôíD¥0‡—P&hDZ‹'ÍÁ'Ó‡á3ïŒa_YLš¿ó¹ænJÄhfx_ˆ’\¼ 4D¤sŽ¶ ¦Òï?L•J¤k“ÔŠcœ—ZQLÿKWFIrQ+áø€¥†ö¼Nʆœ‰Ë×0/åS¨\µ.¸++TX Ûfä{%Ø/0J³çl÷ÑÌRÃ1 Ô̬ҟ¼O³¥ÎYŽ{'šS‹å~_’“yçl‘®pýöf¡2—}«Êè”:!wn¨<˜#Òo „²…;LÃ9Ÿ<€éTØ“4mˆ^‚®•ÃèhÐ)󞨈=ÆùKÚßJ5es¾ sñßmï Èø³ˆq'ñõÔb¬+´Z, $rv&$Ê ãh}‘döcHv;NÎZñã[n˜»Ñ¢'Ø}åŒ4üP›©+rCFõ¢:áT„€Ž«‘×öW´‡š=ížÈ°0qž §»'‡¤óŸ2.‡Î=Ü‹:††$o……enNu¶DÛJSJo¼Ô¯že·Íg&Úíƒ%Ǿ«Ð”A½¼Ž2ú°ˆU¿­qyÄ,±ûõ‰¹—P•I„Þ¯+Á°é‘+i¡UóÛ§°Nˆ Lþü‚2€ÀìÖ‚C—ÁDãø¨cK×ü•þjäîU†àþá3tŒ¹IXhY +YálÐ].Ü̉è㤹Rì~©y溃f h%ãlrt.?—ÙÒîÖ)²ÌiVgÿS¢ùÔM6SLøLójS¿>[k¦{^Áñu$Œ‹RÅ°Ö”¥B) ‡VÙl”Y‚‹Ï·ä§TƒNdš"7Bš;^Þ¤Ïî®îa¬‡÷‡/ÚM·t{Ö³vô‹æoœqÛ©22ÌkQÇ%8UUû°ÙÞ¿Ï1E1ãrjfR½Àur›¼~œéi‘?ÊÛr©%7An›…/å}¥ ¾— +^…7¼dú=(Sù`¯lB£ÑŽº…šîhç6ùf&$\Ù‘×¼$ßϤ¾ HåV¨$ñLºOO:ú™èîÃXWà%pf«úé±i¿;Ì:¥PÅÁ”.7 E¶2;ƒfp4 e>:.)­¡Ù-¶H6¥ûÓ4Ê‚&TB-ØI½{!¥¬ ª?ƒQIË„#%ÝW^ùØ ¡¥bÊï9Ä èŽ‰çV*ˆ =‡Âkë®*´:žðS'ˆ•äB VÐ…dº?.2ÀkÌgñG~…þv‰äê½Û=’u)ë™Ìœ¯ÉòÂîXg(ÑP:Š»´E‘•žŠKe…^Dx…_‘9ã‘Q¿õäÖE3æÉN.”¬ÿŠl—€“ÔéF°Ïû=OÒ+†ÀóR%T—,Þ«A¦T×`|s«íþYàÅÙ‚3`|S\n®yœÓ>r~3œMT);8 Þ3j²³Ê’€2e6FƒŠ–M*.…»ÑØ´q†c4– ª/e˜+9¸EbSÌ“Œ­xO ‘žpÃzü[Iàw¸UxüÇÍÊÔkn˜luÈ6,zZ‘qUê £SdE‰*×]Š“$µî?¬ÔëZ˜’õ1kÆ@{ÀG6#ü–´ æei X‡Ïq†,ƒØÍc¸B|zÈ’>Í]ïÂõíðúøiã›^wÁ †RcoþªøD4~%t:: ¤úAÊúŸéÈˬéhjΈ¶ì‚¼Û]„G#ÊÇ®H5»á³]ÝéÎûŸ‡Ç‰rÿ®Ö¢×˜š–§ÚÉÌ­…CfÀœÒcÄ‘ˆDŽÉa1ÈI5ß²ÂÉtj綢ßH`Ä›YzŽ±ØM(0‹ë‡ž`ôkm.¥RúÂ8G«dÇ5KŽ%|”ÚíiBÚñpr̪®Mœ¢h®cÏž=íÊ0lÆ8vŒýÈ©® nÞЇvkœâ¶‡î}Ý•ÜþÓ$òŸ¯¦XYÚ"ŸTVXÑ÷†.ìš^‚¥(‚ñ yIéÚÆSŒ;“§X ®Ê«VY\)”Á‹xÔžð(¨aôˆ@|'µ:cÎô â©0âQý¶üµpÇ7è7/¹×ðl5+è0 ‡èY‘Ôƒ.rüãÎçaÝÝFÕ6BuË£CL 6çæ™Ä|öÅ õDf!ùÞ­Šº¿}.}ö0ƒ' ÉFf F‚,Ø—Å>ð˜ >Ò~Ù¦½ŽÀÅ AnVêò¨RàSß[ù?îÅ56H°Ã•O§«ž9+w1Ã˳£$ä}Ýq3î,É‚ë«D"2zà ‹£\Wô‘ZÿØ”›±È™´k6¸±kõ«Ì›z¯b2dõ Üð*p¨–n;|‘„ñŽ}¤$mƒDýQ’Ÿ—â*WôÉð<ÿ¹7Í“œéŸ˦b¬"£Þû#™½Å¦–å0gfA–†õð 18\Ìy ßì2KТȟ©X-„ ¹åœYßr×–öw`T=Üú•È’;qÃaY²ÚŸ`Xˇ£dòà ô+ç=ÃWA +s0šYt¯ó$‘nA«Fµ×m‹ÆŸe;Ž©í¢z"+ÑÀ1„ âT[O ñ«U ÙP)8abø:ÌÀU|>,˜E…ƒN;ŸÂœžå +n´…þ}7 ‰`ÉÆ.ª½2²oFâ-5P÷ Ù DÎDW6#Ä»jk»‹nÜÆÓÇn³t©æͨa {ü`ˆ˜#: ÿ·¡´¡`¡0´ü+¡N–Äüb^£_fÁ;SÄsBâ  IÌØ¿ 2ÌGµêhì&-@ðÔ9Àõ(´OSÕ»_Jd³Ìñ ÁÁ¤šÕÙÀ¾µýÝTY÷õàŽ¤ƒ<îgXUF†ìxÞ¿ú€çÒ3aÃÆ +bqÖRgž#MsQQwÖØìîh׳ê°}ÜG£/®2«Œ gg“æþ¢pÿ¤Ä¥’‹Y×7Q9%H/<0$Euãf2O¦¨Xª,òqlþ¼ÌâãÒûòÈi1|l«;V¨n—r¶É”±ù^Nû—IV_3‡ < ìÓr”þWAg¹Êš$¶­ºÉl"|ä*˜Û„ô²­ãG5ôš•@Umˆ… ǔʈx`ò£°êJ V&â~д詺¥ç”¦áÅ®²ë£È绽©¢Ù~ew£"Y׆ÂÑs3=FÍ9åønŸõ]ñ^v*êó ázßë‰^Nˆx´Ó˜Êa 8—sGçA¿M®þ`¶ÿp÷¦±—Ô¼!LÓd¿~âW¯ÞÉÉÐØÉvÑãBftÖy”6Ü/¢ëû‚¸^ÐNœ"™sìQàÊÀCz=y­uªb…Š. ¤Ñàù^¡jWy Gh•¥ààôÔªM Ü}âr ÉC|’¨”¾‰`’\ç/k Ó¡ªÆ7àz'nM–²-è* h?$Ñë}Ø«7… ÞX®MnÃYp:UÊ<×í[…¨\Šû~¢Äû-Ë¿^½IMÒë|Âø'©_?Њf…k™rLã 3’…>LÑýNÔFt™eº ‰!еXÝÆUíEz|wð¢:üÖŒÒ`碀ò”§(f¾Ž>Flv'žÊ*7VJ¼„¥Œê8g¤>åÎ]´%‰á¦£Û½@agMb¨ÄŸ·ŠV·vå)K}ç”ãKŘO“G®¹w 7½ sÁð(ÀüŸ."ÂKëÍ–Wênd@ݬåTvûlÏvo..|s3ò-ˆÌÌgŠƒ«×žÇs‡3üi×Òüô³§ìÏ„ùUQ O'¡¥,!Xbá—ÃYå,ë‘N³¬LšJƒÂ¤ˆ9ÃMŠµÆh´XóŸéÂ{ kh»OxBV,½¨îÚ6’¾0FÕUlïr¨”(U“ú7Ñã"€Cº0ȉ¹`!#@º§hLœ×[5&`¥'LÁì˱”î"€7G»ýÜ&²!ú¼£Þ¥Î)»w”V*ÒÔSÒU¯¨J?ä7¤!Pùktø‘™\…UŒâùÏ£Ó2„5U@®ý”À¤Ô’öl§–*WÊeo¢ÜõX¾ס#.4&á–ÌTNqükµ–BZ(-Š`‚q$jô­q ÝîøíI{³›Þš êN%xî$=ÅËÎSC,ÐØ_t›žpGÀýïS‚·îç¡X +¹ïMœšFf4òF‰iCsŸ&ÝfúȽ¤ïf"}iiéĶͽM„›l (çœ /È¢0ÕMð—rò捻_ÜQToBü,DÝ”,Ö¶†öO]Hæ¡"¿q,ÝMK¹ÐMû„¿2‰á¿Zl¿ž¢(ÔÀ—Ùª8û ó–gc-8rË V˜¿Aü+:ÔؘiÁ-îd ôæg´8j˜€åKPžûÑdÄ×(¦’߃…qj·ƒ™ñ Õ•¬&e,tNEšó2  +'3˜.9(©¥Þ´ÂW:yƒïEd4ðíBÎ*ø·Ò+Æ&IÊüé ÿË‚ %Ïœõ€Ý¾«ú®j1aCÁÔêŸí¨€g-®™É_ïvg¨+êM…cÕb³VZ”nüHŠž…“äy³¿¤jæüS½ÔÆ’É£o÷Æýxú¸fçžÇkéŠ$N0³ …ZMÞÕ‚¦Ç†Å FËð1øîìÇS ÔÓJ&<I]O¯1„ׂ&õ:¶0ZMáÿ /t3i%Ô&ÛbT^ Ç‹Æë›ÂQÁÞΠV½’+•ý3pië`P~cª—<’€Å/«Bgï-oE\Â"M&²/dÞøiÈZ$d.æê¸9ÿÒ,i±Ó]ñ ÏIŒ"r”’‚)iË"ï4«¦ä + +/eÌe¯ ½e ŸJ”ß1ÎÃdË’¹Š„¶µ±Zvw¤¾/$ô¬ø@ºÓš9±I}S‘\+^<J‚u×â{âˆ|œÛ^Í G9^©zÚ‡)Zƒ.ÉÙõEªT)W÷L‹m«ª§?·³Ø µ%wÿŒc©¼#›Ÿ¤ZºÐ'wa‰îÐhÛ›ÀéNáE¶Æ&tdÒ$´ç=®zÔ¶»”ÏöÚÏq"9LS_%ƈh®,Þƪ½êÊj|IíÇÓµ“´Y>ã$½ßT°ætaŒ+‘( ± ÓŽBQF§Œ¡G(gÄø1ŽmV ®F¤ô|žÙŠi0ÕÒ2xèSB"‡ëAö6D>šry)”¿ƒ³õÔÏlXf-€µ6yCú•üÕªIŒ•‰ˆ´6pæ¼ê¿¤ÝL·®3=²êI®BÑÏÑÛ!·«?‹öJØ–Nƒ²ii» +b„aMµï*U¨œÛ>Å´¨¾ÜFÆÜ‚ÛlÉE])‚‹´ìžoô¾oQÅ€¯Ã/HJ°”p-~;¥î}OxW¼nKW e²‰ù°?G¼r +b=c¢L)í1°ŇĶˆ¤ ôoé\Wj^$âz‚óôÍÇ| +Θ"FGÆàÙ2$Ñ­ï£ »6\b3È—U™õ¾f6hí_æ%Ÿ +•8Q¶éd <†É¥5$Ý#— t¶Ú¥©h>d<Å^R¶&ºÑþpr-:–àŠ sf<øúÄÜ·™¿<;~y‚Ž®òÄ ÇíkÝv<¼;Í°±2Xw†°P/rÛTãð†Œñ&6¡jW%A1…+ä½úÀ°r\ÛÊPf +‚u“ª‹/ÚîOPÐÈUµ!úxê]¬ @û´­ð^eûŸÞÙ3.ÖÒ²|EUZ×Ì\sDú€jŒ…1"±yOÃ’’G4ड>ÑV²tÉ1ç@ÞfV15d‚n¿aS#À2Uý»' 3 ÌìgñO¢ j`·q5”?h¡*º†LndxV% Œ_vlÜxÔUÜ+MM_F¼jQ‹Q0¹GôfGÕ©–¹6;­qèÞÐëƒb©Ó¾l[˜–†Px¯Â:Oƒj‡Xî5ç””o«Ü@.ܶ~Љ:˜9·¿‚wg¾§4]ÊÈÞþ›è9Ò™7ÄÊ + úya÷5.ž¢Oò'•æQö´ˆ†¤¾KÇLô ±4½3ΰC-µ4”¯m›sä3#{–jKB,ú¿¹ˆÃ—‘ýö*~YP•[è>E‡‚iÂò“jhÞT}˜Sfj-Tø͹`)i¼{ +øô(ÿþ>I(K~zƒÞC¤ÍX¹ EK“›±KE$HJuJ¶xðÂ">ÁãÐ$wýWØøÔºÁÀá}Oûá +F Nü]Š˜ú3ç™lû£-½-;ä%6Bì,ë¬û†Ñl,·$°àèþø¸&ÚŠë(Å jœKr½î`Éû\K|… üÄÚЧ0­¥èEò­:¿ À· +¾Cµ/'£Òkñewƒ‚rÐö¦Î’›\ o +%ƒ*\¦ÎÊž)ÌÔi “3J9 9èê/vë&g¨á¼ù¡šû½b‘¯‘Y—ëºòʈ×ÙÊ Õ–A‹wf*¹Ÿ?–(#M_ ¢Þ<Åèá¶Uµ„i¥¿ÊšÇÚSó»Þz,QFNÔÂEÎ'u?»±ªƒ”¬a]È…0n©k9ç7“ †CHxII}›È‡ô¤CXÎg F€HˆÑe¨H`‡ÅÀŽo®œåÝð «ïϹð¾z!Â@í ´üº*h6ÃÃòr (¥OØúÇZU +T’[o€×B  ½ Òã×>È‹(íXó¤ö /ÄÑéViqã¡]+š=×ø(.¯þ8¥u¡av¡Ü?Vµ‡ œ +”bžsaIÐ_(öåV½Žó‚”gäþ0²Ì|Ÿµ Î-üùo dûv¢h‚ƒ¬à:„²šjÃHˆ>/Ó ¥Ùzx1HG2²U8ÝŦHë–®ÿ¬JÔ¼°ì°É×ûiâÞGÉ«@®B#„ùýÑA‘Åõã\¬DÞ +b_ºy*û˜ÈKÖ–ëÛ÷~I>XñaÂä–1Q¢ƒS-ý -PŽÿŠ–W"Û^eKØf2¡{ €“éÓËgßïQˆÙšQù%Í\ßÊÖ“£+½gŠåãš_º?«1Ƭ, ¤®ºÑ¢ÌÊsŠ{š¯Î_÷ph˜¬J(œXtÀB5ÙúèC,Xé`ëRŘî}m‘©QÓÁÇEÊ$rSÏytаä3µ(댨€%-šmÝÒÚBO.KiÆ`«á/a.\ûÂ1ã@ú¨¡O‰Š±˜-ù+¦F¹"¿€[3Ęù®aÁ;Ç¢¤R”/aÑLÞ¾¦XØ5$¥£¢ÙõfÇE{EZ\éø̬Im1 ì,öºSH: ß05ÂããhO’m$Æf(#2°ÄEç¯Î;#ËâŽp°¦+˯(ºžb +Yœâ´Ç€²l«úl’¯ÝYìˆþ\ųrúbš59µ75nÚŠªì—pï;5`˜MíñÞ®¯B$þ¥ÚHEÆ© ê7èÛ% ^Œ6•¹ùD”Û|¥•úS ”?CÑ).Á¤\®û£Š¾@϶õÉÌIÂñlµOÖíò”µÌ©|«Œg€¨'hÛB ÷ƒ9û/ê]k1ÊDñ¯9}Ö†È,}zÎnºê Ñ¢ +í®T-œï‚']>UŒa)À°rX}+ ºšá¢ÖVaí¹öð"Dß½ÒŽTø›ó8 ü O£ì®ˆvH3Э†.L3ÕCcF‡vpJ¯ã~Ìô´ÓT¼Ì×êí·ÓFo®¶ö„Çm\Hl}@àv*µ{°Ëm†Â"´C$_ŽäIe YÅÀ{Wx9ÊËÅðBe›žë—©e¡_§÷Ì<¹ù€ÔŽD$àTS½2B]ÊÇq›º¡)µvWrnÃN(ÙÅvº£t’ˆƒêæwÆ ½´ ÂpÏnïD0è~Q;7i L”bö,C·‰H¶“¨6 “¹® 7I“Òß?Øfo.£‚•¡Ešš+èc†Š¬_Gù‡¶‚o×">ÐòOüZjª‘F’¸ågý¬±xå,-o3ß÷$å²Ó·”O»òrUÅòâQá)£{S©[lÑç ýAÃîÚj5×iôó½1)ÿÿ(´ê·;“Ìji£yT9]Wºì¨›Bl#¬ SlHšÂrÙ¥´ <êI9—¬ZJ c@ƒÃÈâÂv$,ñë/ÆÇ6JœGxßJ¢‚{ +øð饘¦,)@Cœñ3­ÌâôÕµÆ0 )nƒ}#'Šðr«}&ê1Ü{š{šs÷þµI©ääì77Es–Ø´ez¨â× V§ šs¹Èì9WOù°EŠg¬“N¦†í»³ë;‘C—qL.­¢VeÕ¦_“ÿ[¨÷•ä©àñŒQ2ª ‹¯Ee/¢4ï2méíªaKW,ù:ø»bƒ\o[­›Ä ¹Ö¶µåìÛÔ¢ÖUšž|“HùöƒI)ŒÖžX +ÙÜnf•¬U`jˆb#ñvÿ&mS§4 _mÔµɧùOÐ +=U•*J¿Þ7D°R {ç“é)mE`4½š “U¨tÖµÖíÓØU8òξL¿Ö/doæÂÛú‚„ßx I–1üzaÜ3 FÏýo^ã½=K7.ŽOKãeä½1:Ë=¼U³¬c~Ms\»ÒTÎÁ¥:¿x@ÀZ»5ejSlYw¨>?ªì$R´ç™fZý‡Z+ýï|øæªß« Äá4°nÂÙânT¡Ùçw G¬!'Ãw„ÐÍàÀ.ÞÝ›¸+'æò yÛ‘ôØÀ‹YWE…Ëá”b’2#‘aÿ Ü5¬…_ õÛŒ±¿ª®W—foÙDW= ïè-àt “äåg+À/æ.í}Öœj_v';¡S¼UÊè÷¹‘“Çy’Š "GªØ#L®Êaº}.Ôs-øïÞþ˜]yqXœn68q…õf +³‰aH cÍ"öÙ{rÞH7羿àžVœÌ#Þ—Ô%•‹™ÇÌÈz{[VfaòM» IÁ0zr ˜ÀSB±«ó¸Všß ûoœ¨85³¨Ëe†4p) ´¦…ºO¢«X®,Ã1`tNd7Ei@VrÃÒèyâÞ£LC5>êÁ©<¬¸ò}åè×òÞRoI$¿Èô—Ííâè¨×®G«õ–óRâzϵä«yÑÂÔldæüƒÝЦ¾vOoÍaíH$S:@>…òØϸ7ÕÑh N]kPáüº‘¾»c‹UÁX¢Ã¯+,ÖOƒÚW.´m+wœøÓuB'o“¨5¢Àb®"/§dOÇ \Bn¯FŠˆr33í¼×|'Дg$ß‚.«?²ž!¡j…ü’ÆS» ¹x5iüA°6I{á$­Œµ´¦°Ö®¦šWs¡E +j9ª4›Ëæ®_²èUŸ¿jwø‰3WÁÀD bŽêŒï"åVpÍa¯Ä ›&Í«Ê¥3!Pã8o69·i·aLõœ~oY¬Ùü`ôs»fiZ‰¦»ic1ª—Y~€! ÄÀ±î{%ºçPÓiõàÅM 2=¿6±)±1y ÎXâBÐÄsÄ“ð?S“aSZ"™È¸M·ƒ56Í×VS+eOAàHÚžòz¬€QR…tkÌl¶ÒZ‘#Þ&—­dΨ„¤Eh£_#,ª6L•œò±t1L‹þrzßT&Ê—`|Ä…×+´Ø(ÁHó¶iKë÷¬3¿2ç,±êŠ-xr[ô“©¯«åߦ¶‹Y—+ú©Ù²P@õ™ðpõ øÖ4Ê•ÙL'Í +éŸ~¸7Â_ž)OŠ×á ¬Köã^¦$¹ûýÆ2uV/GwýÝÃñófoð«djÀý³|FIëý¨p9Ì„92ïT D@ÑÏQ€`CÃÕ„{Œ$@àls2íϸ3ˆk :˜Ò ÇÖó±ë§õ2PT£žj¥Ž©±ñßÎo›Þûç&G¹Ë׃¶jÉ”‘>J}ÒÔÕsóYêz‚ +—Œ›²ÆÄGz|ÇB +3öÊ]?â°°hI%jƒ™´°µü}›ÊA®?<.§«é2¡˜Ï:ÚA¡£Á +6^=žàØm="ŠÆ>í´s„£[ý­F ð_ÛóÐvŠÙo­yº ØJ(ü·–{ÆÍñnxÀ›r[2Œë˜·“'q?âäwˆkÜ:9bs<$+bRéóL#vä,ûM}œgæ‚õQàR½&^‘/B[0Ò°ôcí0ߘxRú…•¥‘¤›øq$~ÞíðÑ>'L¥JTŽ_ø²x Â&Ò}d*†ïòܶƒ2;-HjÚ±—„"€0+Ôhj×ÿ<ÒÂøêašGŠmé(!­…An¼'n~¡­Ç½¸è„”’¾­ç‘Еˆ!Üw4ÆÉÙü±Ä, ÉvZ:Ú‚Œ¢*Õo¸?M¡½n€L L1˜wƉüŒÒº­<þ>–U¥d*üAYîtK ÁöO „YDc´‚¯üÃÞE2“6u+ÄE.w‹ ‹ptô&0b =VÔ×üm©È‰#}›ÔѼ>NÊ‹AÑOÙÐÍê!«/³KKÚÌ}Sw‰Z¼Ã“EÇ“Ü6FMfáu«›JŸ¹ ¥æ$ôK3‚6òÙe¦þGO šêqç*y ¬Ý*±s’˜;…#àòÂʼ7-¡ZÀ?£¿<‰èjQKM³˜OˆêO  ågÓ3`’nŽ©•R•ÞBTý”ɾ>UÅ +C^©¬‹;¹&ùšÉèh\=@áZš®qœYXâ(i9T^X1…ÚÉðrˆ2^{s[ÃPpfÂ7-iRZ•±ë åÜ“þÅ.ïkä—©;éY0.€ +ÁŸÊ2+šNç uõ7é÷¥qhåiG2^¡»)À'œåÔÀ²N½i(ª‰•ø\ûbfêÚ1Ï3ñéG(AØí/l&‘–OÑ0ÆmÏñÔI8ò&I”¹^R¥Ja˜OŽÝ” +@1cšµ ]Q›õ<æ–AÌu-ÿPúö˜0CoD1'9*šŸà¬yÏ{*Έ˜¼·—RNñ4®Dæ³Õ4aç£Æ^p?áE¯RÓcÆŠÇÓðzKlÀÈs3?I0U¿—rƒ/ÎÉöë°Á´×ÊtFßtwª’ÀˆŽÚ])ò\æMš¯{e}ÚéGmpòTýY'Œ:l~:ð­ÿ§¨¥ëmå-Ò­ž:ظÈO‘ƒ~¼EIÆù„TG"ià Õ,~ãïÝÐþ{-ü<ÿÖJÂŒ2Ïç§O½í®áb!õÙ8¿nHÅž…Óº¬º¸Åw b”š%p LÔžª™ÒsŒwzªr@ZißføÑ柧Ö Ab[#RË»ðwP´z SR(ÕÁo…Þ¶S?Üþ‘×.©/ v%f¢gaÃ'_>¾H!qÓ¬6‚%¤­›Ø hêŒ:@w0îcÆ·Œô—5KPâ´íÿÅvÆ$™•÷µC•S~¹éŧ¶[kǹŽcðµ¼yzs͵ê7±§c¹z„¯·0C%eÇ@ ãœ«ñƉ• mÎÕðu<3£ÅèR©ù€5Tq  +[7ÔP +uå–¾ã”(âf{á÷–ïù1F2®¨R… Ñ­R¦.Ô›7 kThTó’÷ÜÚx*Ó¢vãÔØ>bD;hÑçF6QÌþ1QŸÕ‹ÇÔ`O°•Em©m IYÐß–é‡zÁ[gkMïI¹=¥Ô㬂Dõ›/(ç$¦™”w7”ÅŠëq-ä8#í¿½—º(mo)rrËG†’TO-LÉ>˜'°ßC +U¸S%ÇCŠ†’ÙÕçˆïXNªJÒþT;v§bŠyýra+,*c¼“sLO1äañcA€úœèêy2×ãæ“gìv‡ZÅô¿ã—ÛþîŠSè ²ÎÐ:¸O¨/þ Ы¹U7´<Ýü¢mdÚþ•H_¬r¡"X8“×^U"ÝŒiã ´àÞË¢£9T³ÅÎ Ý]"ÀZÙY§ÚnVÕï9±×¹ÆÃ[Ò±¶2b î&vŒÂHÁÒ¦-ÔÐM¥¬S>‘a–Ë‘Î’síµXgÐÛm’ÉËr.é1ï¥eˆÈ¢BÍé³;$m!¸¤£3n2injHLGǩܑ°ø_ü8fÉŒy-ÊuY7êTV _kÄßørÎ/&åφMÊE1Ž†„]o[zªŠ û65íµ’I©1Ľà¡qôOJ€¼L×<4|Ór;xàj•s2šdÌôdð]oêU¶³DN@ÏøXJ×è“Ëü­Ö¨4Ï#W öþª‡žÏ­âi“aµ¨:Ûv;άøšJmØ#ë‰êÛé×Í'}ØÙ£îÓ¦ó·²cšCÁÿeÆçhå|U :kÈU´'&δ+…† ŠÐ¶Qžl;Ýö†Wòæ5ÎX|#I‰’ÀýX+ç«$Z—pý¬úÔ%R>P'/Iï„ðõ…Sý(NtÑg¼Z°ϺÈ<úÇvÙ3ë†o~“? z8Nôš1@AÅŠË-¦Š1štK4çœÃ&=³HFê·q”Ä›2ÐL..ÐYêåü²gUF4™p 럤V™Éܪ%CK¤@š ImU«gZ ªŠ¾ýK6K/RÝGâ8“N=#qµAD2 ‡ô “€ÈщNL;hjÜ;B*/ûXæè‚°°ºn'É1œ”}?íRþ&;IÊ[w‚˜#÷KìË=êgE†¼|ö•Ûè!1s×LDäÔ Ï0ãX&8»6—ƒÕ´AÓ•–1øŒ‘Gعtár#x-~þç^‚$Ò?%‡Hç鯎Mwnâü”N´pŽ¥Ve|v™£}¥€'A=ƒ«¯V"‹&´u,v߆¯ ‚Á´ÿpÃMž"ÊåEó˜JVþW®¬õ­L¦´¸ÚNfeô‚µ¸¯ëº†$öÁÔ’M +ŠYðìn HÏöƒÛËŸ4µ±¬fK’Ô#KßYÈZ_‚I¤Àâ†;@óû¼ÌUÃ1é_Ãô M™ßrÓ£>‹f`õ˯Ž¸ü®Ù^ëPœ{¬JÝÁwîh)Û,)ˆªé—Œ€9‹šùpD‹P@$‰©]|Œæ±»õµ?Ê껲s¯› +endstream endobj 32 0 obj <>stream +H‰Ì—Ëη…÷óýCñR¼m­YAE``[ Ù€­÷ò"»gôk¼È. ŠÍK±êÔ©ÃOÿü||úÇçxüô·ÏÇí[BªšuOfÎÇ=‡1ô'†’ø;B3æß‹Ï³8˜0B™“¿)ôùb— µâ™aLmYC«ý¸[¥­#Rã{-!g_ÁÞm$FfˆúÛq‰-‡˜p2D®pZÛ÷zÙ©†lÙצr°wÓdö.­1™øé¢:¼E;ðmà´±g½,\ïI—Ý6W«½öR¼Kìun=‚¦žÇ¶R\²â1Ú¦‡žÓÁ6­t‚]kÍÓþzÚ÷Q‚ÕÊÀ^ðã@Ïl¬%9ã’1Ð9–K÷`Üö>ð¼õ#å‹\ËÎð s=8*)˜é£®Ã©Äú2{ÐÓósža&ßyè¶JbÒÁqônsùFÀGQÙ° k¡Û‹]RHÑÓ{Žȡ̱ƒÂ¬(¯\dA8)»+åHä³j˜`q6"¡4“ütZ•P®- GóªBק²ÖÛPºîÅm uÀœH ä˜ÝÑN€‚`?n_n¿ÜþõRE©‚`KÌŒ5?ëŒ4¢•¢õó^Á {´LøþE¨æoœÂ^Sut¤”Î};õÈ} )ÍÍ>.À-OOKì>}ìÅf‰ÌWGP¦ÆÈEIOóqÃ)¿Ȉ•ì«3ó*pšk÷” Óq¹6¦÷¨}*mØòŒ¢iÛ=B®Vƒ±JxŽ© ܸ»ŒŠ1òš¬lfÏÚËÏ»Y¨y¼ èèq̸˜¤sRÇ &³^w‡¸NÅæÕÑ'å@¤'ˆìÇ®¡{ŠDzlkÓ@ 䧒&]Þ“ ‰B|Ùį¬=G²’Qט*H4£HVP?³Ã$`©ÜØ«Å8ò¦Ve®¬:OUr÷Èû/+¡×úŽyÌP#“®ö¹øF£BL:üôõfbËñf@i +׊v‘EJñƒqͼ§Âmò»‘ë¸øòûã… +ø«SLe º—úÝ…$±o¤Ÿ‚Rš:‚)y,â©Gi¡ªqá¡q/ +¢ûGÍV™5áhy1IoÍ+‡ÐJy ž´M@òc¾zszÒvÿPF³hwŠØa=ÕPš“ßäØ©¾«žEë ëœ&»dqÜi'ƒXW¡tRÉo&©¯CÌ_Æm«*–¤Ué2‹—è¨^“F T/ÒJ§Œr]%Zà=¨Û¨ä;ËV©ÔêÐ ‘íèžIõ ›ˆ‚/‹P‚L/ø5PÔy›/ªŠ¨fŸõPž¬—EŠš,U¡bndæ¤Å‘¶~ ϵáE=)§3í0B"Ô§™"y¶±;¼œ±‹/¿?B­¸)½C~³ØëDàxTâ'Œù–ÔqËÎ~#ºEv(Ý“x¨ª O±$]M…Nµôú–êfpŒTŠK7-ëjœH’*Ðú…Ï­â æ ÞêÌÉ™pˆ 4âúâËY›»¹á}‹/÷¨ùiº?¹,µ5„È,õéЭ§êp%`eµ¯˜EÍœ8æØÖ.J궗n(ÚžÞýŠ½-]×iCxäþ&a‡Š—[»Ô aÉŽ­*Îã«á€¯^´Í%Å!ÐË XtVS3_!RCÛÖUh§çŒÚ] ¢i¬ñ¬† ôuy¹éî4‹-ÍDj¨8·Û=-—ÖóÌ,G†ŒVÒ:—à’¦ì% ‹Åè$5Ò +c¤,9-ÉqN±–NSÁEÈñkDo?{­š ®­„³Ökwš–OChíÖÞ4’¦RS¶ÞêšÒ.ÞÕOUA—E™ˆYØý=v!6u•{]už€«È±˜X|k.ɱm>‡¸m*ZnŠi‡ö˜œH’­ü;Ë¢Å\HÒfu^Ô\…€N³T¿¾Ól^™p{Ëp}3Pá.“^¡î,¼µÕDE“Ò ¶0—–Á´%3஢AâE²£³X6†SlW‡‘’ÏbØ’ÖSÃ> ›ãÓtŠõòØP¬4²n*ÕÇ Í¿6ƒ9Å6ñ{‰ž+Š’›Š³Â]½6[ì‹2»øŸ2Ãa꦳NÉ›m6½&æš¾p†-¾aÕÑ0<‡úÿ®OÞ üè“Á—–ÄÌbèþz!²›5M$*g Ðʤÿ¿oõV† u@‡BO…Á½Ì¹{ kê¯[Ç "’Œ²³‹ì;‹´´ì`ÒANðS¤¡ OKœ%>?ãŽé– +*ò9.ÂK¾µh#©ígч´‰@3ݳæ߶<ŽÎº×´ÕwôšØä­ô¾‘@Úœî/ÚÅUz•Ò{¢ÿ{xüÉ#Ù*'9g¾7îþü™Y/9B …ê­†üt¢î)4OŒIrû£c¬WÓ5@•Š¦õ®˜ErBªCRæÒ}Kýk`1»ËË”þ¥8±¡ûó~H=y@!y˜üžÕ¹œ9„ÖᲦÀ¾"¸¡È°XZÊ$‚QÙ¿TŽ?|ó~^D‡,ZÊVŒ$ò†JFqÀ×u{ä«·¥æ'…ÒKÕ3 }G5ùvm …áZâ\¯Örâ’H%òšó—7AzÓÑötI½/Ÿ—@kÇä ý"ݳPÉ^M¡ÊÉ/OrôgÝ@4 Òau=v•˜â½™gZÏõÐ5é‘=6Y4ۥ拮z¨ÚJ[Ûò‚´@ñQó“øv^&ï%¾íä5ËQ¦7¤ÔoU½PŸžeµÄùê™d•IAÑ=¥,Pचâk >†“WŠÄ$Æ$M +W8£òY½a8<3*{÷€#¥ÙôÞì*^Îç€J> +˜ý9öõuŒËU÷;K€y;|3ô\©Ÿm)ºk¬ ž—ЦdR*ëéé5²/"á}Ž%ë®é<|ÄÊì/Æhc¹$¯‡ ‚îµpíÿÃH|5è¡RÇ2³Þ™0äYü¨¶±œ]&©éI”{ïUÃ!›eÙU¡3[+DØEE$rtÛÔ¼×ÀmI]õ‘˜ÏzŒ +~#­FƲÅr]vûŒèš%ë¹Z׌Æfy½XÜ6€IwÏÐH†Û¤zü±M“TEU$YƒÝê\Ï£é/OÉYÛ3¬º~-ìLËÖ+±Ù¹"Òo²$½kÚ¶VÀªYÙ•ûHYZ+šø8Ûð-Í匤«§¦êé'n%¶}-`Dò[í¼®¥zÛ”´íÚÝ>¯Aìж^,ngàbz.è “ä â2×5¨ÃÉé5Š5½.™Ñ|!v÷¥ü‰mÁ§ f­{šÜÎDŸ…Cq0‰Â))ÙÎ`.ú;Ð!ñÞµ§lêQÒ@²ÂÄÀEkEƨšn«®És©òwõzdôäXðòö¼L½GíÏ“p›Ì­qm·¿¾œG1k^˜ÿÍ?/‡ñ>Î78ý8‘îÞcûó÷XƆtÅb·&¹+û0Ô"J–«,cóÞ‹ªäüÍšhŠnj<ÔýÍf»Œ·h×…=6·Iy^ö¤O€IëmA¶yÓ”P€ÇkRbE@áËm¬¢:Ì1&—ÀºD€+ßd€ã m[lá`$Å%`šO®ÕñkAÆâ,ª>T÷Ie~ÌÚæ:)oZRÀßçÔ +˜-/ •ú6.$Þ—2b=,&r—“¡‡p¶ò™K¼¶+–/=eáYSoÉ`’´PcÍ>||Ë5 [ÍñqyOˆ=QTæ°Ì€ZnqÝ~¬0BqëÛm´ ë˜v¤’ý‡[)¤÷ÃÍ–~ÃîøƒH„¿^Y`á¥Å³i8-&®Ü¼œÌYYN ÃÚ²qn[–¼l^Ž»R€RkÙO²3Bʾ#ÍÏ;€píÏPqy¤¨îx£©áˆ?Ð +å‰xD¾O|äîpE4‡¾’O‹Í„Ž{7 ŽT—fØ{7ÎT‰Eæ·ž&§ÈÇxê–¨öܨ¦]ÚÙ0ù¤ÄïáÅO• €q YKeØÌš ž<‰e½!0Ýëƒo÷Ö$0Mðâéeoó1‹L u.uÂI2`,¯ì}¤¢\æNòÄiæ秣«õõ‚¦(›_{¿/Þë÷3ËãÓI$xËo—^|¾¾ä|_Ú/zl—l}]JgqÄz¼þ%Ö· +Ÿ?a1ñîÖ<‘Çã jøû÷—ÇfK¾h}‰ ªÚÖùAøõKß=ôûϯ¶ö_€«òœd +endstream endobj 31 0 obj <>stream +H‰Ì—ÍŽ$¹ „ïõù¥õ¯«Ç€O†aøàh¬=‡ÙvçýAIU5Ý ¾L™)%Eƒ¡/ûz}ùë×xýéÏ_¯Ûï·xYµÐj¾æ,¡[¹îÇñÇ/·^¿Ý¾|ýG¼Þ~\1Ì™ø?ÍÂÿÖÛuýxãñ_xüï·ß/»"ÿìJ-…Ñš]£„m^o¿Þôä×Û½…’*?ïf¡·tÝSH?ÖB4»x>ˆàívO|‡ô^®÷RÈ£a÷`#?íÄ'ÆðÛ“-”R/ß!qšÜCÎY§ +c²S±09+Ø»ê㥭¿-¤20Gƒ³„Èõvk¡Åô°-póµ„IøY™+x[çe>Þl}¼±õ¶b«=?LoÚû88]Œõ:« ÐØîìÞ‚¶>_&sK¡wÏ{Ô>FM—¶©•|OËýØß}o5ôQpì•%ià`mŸ¤³EÎÝ.Ë¡'íCkõš¡•æË3ÖÛ­†ÚÈuc5ç!übnMc KR}˜*@ 9åç )sšæ›÷Ö=]™¨îµ†âi½çlª +À ©V9DE—Nµ§Mf+IbÅñpI%˜$“½bÿDo…4N‡„W„ŽÍ!ɱñÑ!äxÆ·õv‹¡çzL0ÇeÃ!4C¯ÓËÖU]ŽiÆà +±óŽžÒcR¯|+ôÉíÛí_·¿¿´RÎf²?ñ„TµÝé%VFPWÑ¢£m:ÄŸÒÔ¨f²+¹¨MðkBP¥éº£•Ê rT'¹êW"Ý‚zS'^ЦWÅ<ÈL÷gá¾€ 2»jw@Ðrº(EO“×g˜íéƒ36_=b¿JÝ`÷ª`ˆ¹ÿ¸¶)3Tu¯B£ï+IÕ!48^Sp­R²JsÄì]šç|š1Ô¡X¶CIëôܘ¯?ï$Û^L}w[ '„ÈsíÓC êÁëÎk‰òÂ}…âß;lÒ‰@}¤§ÂÄ x²ÛÈ ‰l”ЀÞªœÝ“—Ê1)Méù° 5Ä ‡tˆJ`èÞÅI¼<¨6[w2ËÎÁ9 xBP•›'u:r‡%¯ÿèÎÆÊyV.îY µOXàMB#µÌP»¢Ù¬nÙC–S¶ñ2‚N-?ÁqY‡M€V‘2ØŸzS•Fœ‹+œôº­H›:%Nïâ2æšØ3ËÁ(€OÍágÐm-+{Àçúš¶øj¼ZS +}O½lægeJC”äË¡›péctJÖ H~ž;“_ÈÑ8qi$ô5 «&¾å€Æ Û¦fCeÚã¹hš]飩4ÊkÛšb”ßîq)ù!Dl@{q‰ñíÙŸk¾qÏ1žïî‰_¥¡ËnEÙN$·$‡¯«;*X̃¥´B¨¢>À‰#;#;œ´ON{i‡â’j¿!™²ºN“TêHµ'Ň5YÈ:—æ)Ð5ª÷Xrv[nɹ"‚¥Ó‡9,KõÏáwsñ±dÏ!²¹“¯Â[ªK?ž7ÈÈbª¹QÉAÕ×|¿¹TKE1“”âHŒCLg+ýiÛ¢o­X$„K2 Ã.ešÓ +e€ÕUˆ"èįĕÉJƒŠŽ5ŽøHâá6ßœG ^ÚêDú×Rš’0ûRô6ögq b¿%lCÀ-ñû¶èn4þ‰¼‰®HCESÏ[}"“Æ« +ª‹%˜JU‚iWy,ò-®nMô’‘¿4Š&/ˆ‹ËpÚ­”z™"]åPÍÑDº©úå¤ø‰™ô±-I™!8µH± 쪤Ü6A°„§s®;Ú#Ïèí‡."-¿80­/Îú$¼ÀŠHWr-‰MÛ&ݹҒ¦RJÏÍ•&»NIz˜m‘n—`“´Ç¡KKZÝ«NÈšfOÓY·*ýç9#vâ¸àW$#ůo¥²X×û7{{¥´ nNS¬ÛTº´Ï¾.,q+/)ýS颩֞ætȽ=‘p2_~¿×ìš:âW,íÿ ½2º¸ŸDšÖèÏÇ‘(}‘°U7ªÔŒsÍJQ5@ÀëŽã:µEHBÑôR˲ƒ˜—Z‡¦ÐtžXŒ=EQåèÝEÏt¦¤2䨻•Ø½hÕ=Û52CóÃA8.¦´V=GÐâ'3ëb"Û˘Ÿæ!ê½}©}˜ü)kL.â]A«Óuø›Í5ä¡Æ‘­÷êÜ‘\ÏyôÑÿ½yê‡_Eçá§ëÔzƒî_xëYžiÒå¤øS9ùqÒrYÓÒÕPõ»èìBr‡´æxq@\òß1ÚÐ’¡kkûIµ1ƒu-yzÎw¸‰4o'Pÿ‹fp•ª ›iôk¤ûuîãèŒYÊ3J»ñ†2„úå¢ö.#’h€Iž£æöCõ à¢Ò!V)>jƒdÑÂ×æwSJÖµí¶ õºQ~ѵyu„ÖÉg{Y*BöÉp–J9EGœÁü$ÙòOÕ~¹³Dè·R9ì3pÍëøM†d,&#]Z®.ˆÂo±.qQfÍÚé/ÊæQãâw%m;t¶²u=Q—šq6Å–›ý‡°m«9½ôtˆU$×P[çwÜ]·Lˆn竤\±Â±ôj{ I’»¿F„ë— ä}R§†©Kߟÿ>6T7úÞ46/ .ÊNÅþæ*Ð0¿•D—ðŒÎ:žö§=Õ‡çûÓ£NLB7;1,ógžç*ÿçO.x¨öñüºeX‚3¯ÐŽ±cv~Ù®ªÞXì4KùÌÃáÍ“îùG..%¿¿lüÑ_ [ w3K²¤¸ÃN¬o§g”ê¡h v€ ±Œê ³Ä0‡- :里ëùË4Èo8yî×u/Sºü¹…¸ZÒ2csx^×MDëÇzŽ ê‰.—eJ¨}êçõÁA˜1y=îI[Úf«z8¬ž·«3tˆ{·¢kâî÷uQÅ.í±}NÞtâR·Sò›ß5ô¶>×5A÷9¯­ù–¨;¶®`Œ;Sôzr2ÏΓØe‘{süc›®„ƒjíƒ £| y žIæ:3‘†Áœéñ¾”²<Íd•E¿ØÚºp'ºÙ}ª–²0+5ÏyÑ…¥)Õ¢‚î:Ò¡ƒŽÔ°Ñme¬jâ‰Å=Õó”4jºìîØLOEö\× €ëSÊL¦¾À®>‰êÓ²5ìĹçÅ•³8 IgO·«JZEÖ˜+ÑØMw ®gšÚ²ó/{,·a_Å'ð“(þ¹Þ·eŽ‘ÎUî_äé¬wSÙà“(Ì “$ÁáÉ—’Èß?€ n¶Òc"¨KDÓ¾sÈ¥¦Ù잨Ëÿi¥ØìAÙUâjI°h7›ƒl%UÛ£Û5Í?%3ð‡1kµ1Rqö7¸ŸÞ€ ’Û†¤SÒö㔲hMqñ^d¥ÉäÞAq!Š q•81E›oؤ8VFL).þ ‘Çö†<ÐøF3"sÙN¹4Ãwè†Jÿç?O[8K¼ WìG˜|ûñAÊœ'Rx\q`ÁïhÛ4”9®LXSŠŒæ¼eÌ!5S +°úuFÆŠ¨m•D@Ê%j–Æ´:K +Š8¹Usï¢ +¡ì +ÈÐtó Snî t LG›˜$/¹ì ­FÔ<]ä¹'Œ¿õD1ZŠ–Áîåº: +û¢Y-G"tÖÙ“—MѲ;®—ž¾Vðž'K;%0߶3`þ³ŠSdƒ4¨þ•æ°n·_œÖLhœò$êuÒa³c±%ù¨A¦”]ÄéT[Í*-*&gš©œªá#é‘y,ˆ¾ ýr a¤yªæ C¿T„X’à*CTshQÙ5Š oF+ÓÚSá’9û¥€2Ð!¤“¯…|"S}—W ™–üÂ)=´™ò¤M¹ï•W¦®Û´æ’O–ûæØ:‡[¸çð–Ûå2 ’+<Lu†?¡P›yr)¬UÄmÏaV7Lý6 ¿„×e[wRh>‹Ë‘íáÂ7gïfŠ³cëŠ0‚Ðö¦—cªî©ìÃç¥lþ‚¶¼[lˆ:‡ª_jX"Vyþ5ãçèžõ<¹ºoöþ\J;É=%ˉ +lçºFÞÝŠ·Ž˜n抺¹ÂGq‹sí‡â)Á$5†0šÜRuÛè»#Õ¤y3=Ð}­N­AäË‹Çîÿºp-Ä+º`Öìñeå؃Ýr—òb¹é……Œçÿñh£ BM¼~xKØãöç7ÏŒ4ÔŸñM4X˯'à‡—M~xäó×Çý`’íŒ +endstream endobj 28 0 obj <>stream +H‰„—ÉŽE …÷÷)ên¥\smi«!<@Ä°h ï/ñÿc' (RçÚ Ž]~x >¾¤ðÍ·/áñáå§>})®•ù›Wå¯ÂçO>>|Ïçß>?Ú´Øk=×ØWx–Êz kÅ5Jøû—ǯ>rì­…ÞR쳜kÆŒ6׶æ¯G +–ÒþçéùðsøóñW°øgÁòˆ½ŒºqÙl>ýñЧ?Ï:cÁ6ö®[ OÖ¦99Êr´ÌÞãÄ“OgËqÚ85¯gî±µžµÆœK0|J†¡Ü—CYÑÖýK9¦ÔnŒ+–G-¡¦8rv£ !Þýxs"c{ÓeuåXÛºù8µðóõñ;&EëÄJ¡*ú8+_±‰ DK˜“ñ°V…“ätn7 ¥…Öb)UŽiH$g×ÈÑÚpMáiqpðÓÈ_nŠSižŽçìqY“"ÇÁ:§ÄÕíPá`y.r^V;Ås¬ßåB,g Ù s3–°ÔY7ëˆÃÖÝB®O=”+±NèŒKÀy“/»„¹j5 |#-ýŠ,¨¨Íâ˜Z-=½yQœ7¼£Kwa¤ÄšeJª—äêu`ÜRl³ Ò“¡$x#U$GOøß½Þ(e_ŸœÕbeás´»Q:Α.z`zZñ h0îŸáI+¯M®ÈÕ6Žå‰pB ¶BE¥û.–œ¶æ¶x€i²0“/èØœ!Õ‡äLÊnÚ¾ƒ¯@ÁH.™íü§Ì¶'tÅÁ[V‘ ¹éþ½ðý+Šï\.º%©yÛ±À"€åâAÃ:,0Õ(NªbrƒyÉI“H€4î50XkHîËv¹¸<¶+àÁ†Û#„°—í^«Ä¹jÉë-®!(ØÝøbTæ k@T2±OÀ=i ô`äÞ2Ú¹5 +_TÛ“+Z\@Ájš _þÜÓLjE1âô²Ép"šû‹F…I¥!9ͲÉY)ruEß1’Á`⣯,йC”îø.œ¯F³ä˜ â¨ÂÀBÈÍ` ë"„µœFÕÏVö=Ø,YF ²ùŘkˆU­KŠ +?KaÞE}“†‚#Ü©ˆUÈÐjLF^]9$h¥9NµgR;]* ïKJÅw«[­¡Ø°jÐWnŽ˜>@!D!•L¡¡—¥…0Š¹a“õ`Ï6^ïXÁ¨>ü³«X} ÿ Ê0ŠÇب¯±eTBÆY£Àªàß©UQ"ö€‘0µ^FuÅÚ¤K奡þ‰¢lID‘—Ÿk.sE+>t"D™ØºÉº,°9 +39“ö&DªÄ«—Xf»¹jˆ°:™¯n +5+b &†+fÞ\2~‡ÊÕv…صÓç\§Â7)°®Qa"ƒ[aîÞž†»¸)Ô£÷ŸÇÿ¯RAu^Ûµ|;×àŽ¹ì~µTE¸;ÌS¾4äœHQz¹;i¤©¤~BYß¹‡ÊªHÿÍ€gÄ…ôeëʉêeÍuO MíJ­Q°mÚ•|#oÕM9²Õáº@dD|Kä3#Þ¦¹ï‚¢Xe–yÁUÔÎ/@SC§^˜_âæy•¹T}[Õ¨‰ª;^e55'ÙUyûÌ®Ò|[¹”åT¼ŽÚ¦&aš«ø©Á¶æ[~`żQÈØ\:9†Èq§!j´ „¹&MtàN¤ f3=Eó±sðɨr'åRiEy?8™V35\¬s™•'±ÓKz»ˆ_S¨ZÇÙpo’«£w0,4¢~´œÍ„új=øFË?ZSæ2’w4.fËYóÕØ¡:ŸÄ|õEÄ]_Ó»ÇÕYM=|WôÏXzufŠVa=Z7¢†å£³ÓN–½ñ#2:žsb¢•cÄÏ\cÅòAø;àC )„ ¯¡0¡c¤ÁÓzÍ;$+ßç!({œó’Hb¥ºXלEvé;ûF1AÇŒÆp¯ŽÊ"!ÇŒ‡ó‰”ÐtŽ‡R¼i+ß{Cñ´ 0æ@¦vªûÖ<· µójÊdãæ’E%ÇryNÁ»:h;d§¥äü·k|Ê%ë§Â!EV÷ßµNà£<\X3î¶1ÿ ·OÏ €"#UÕ¡‚éªä®m £Jn¶±’N|3M# +ÇàÀçý¼÷,£ÊÒûÏ#înv<¼r¿Ö|ñZðjüZuîÓs`Š£¿Ò¤»p{<4 o·Çýžè0o»üú¿h©«9ÑðÚÝ^U‡ +ùüû–y@þsÅqL+z¥Í7‹*ÜqóŸ+¾ûø0ê½Uá +endstream endobj 26 0 obj [25 0 R] endobj 37 0 obj <> endobj xref +0 38 +0000000004 65535 f +0000000016 00000 n +0000000147 00000 n +0000052741 00000 n +0000000000 00000 f +0000052813 00000 n +0000000000 00000 f +0000059357 00000 n +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000000000 00000 f +0000059430 00000 n +0000059648 00000 n +0000061011 00000 n +0000126600 00000 n +0000192189 00000 n +0000257778 00000 n +0000000000 00000 f +0000053194 00000 n +0000053589 00000 n +0000056373 00000 n +0000313533 00000 n +0000053972 00000 n +0000311624 00000 n +0000056673 00000 n +0000056560 00000 n +0000308103 00000 n +0000304455 00000 n +0000054359 00000 n +0000056444 00000 n +0000056475 00000 n +0000056708 00000 n +0000313558 00000 n +trailer +<]>> +startxref +313753 +%%EOF diff --git a/icons/brand.png b/icons/brand.png new file mode 100644 index 0000000000000000000000000000000000000000..61275bcfd33438a687ea8791a267ffd860a355bd GIT binary patch literal 7472 zcmXw82RNJG_kSaZRkW(r)~s2(RBd9^XqB$mL~XVACTextdxeJ93Po*6s-EJJ zL(LK^M*iuy|6iUb$@|{t+;h+Uob#UZx#xX)$5{Uq(?uo#08SYi=-vYW@DlYpm4Tl6 zOW2y7rv5Sd7+ClN0P~r@U(n!G)DZx1|1{LqdJvMoHucc&qH1P#x2%Sv%&+ttq`udo6WaMvom2q6+A~5q~a7 zoE%kF^c&b|ia1UhXesw!uTZp_ZYrcpKaB_Bnkyu2zf?d+&Q*y6fc>3}t_=V6<3gF8 zG}=J_NI2{qW+r+n>?ba)eo>9Ik^}0Y z=;WG9QZiwS2ds;$6PsGm>oZod0z4NkBf+&^XYJ?@2yA+mNi=}nms!ARYGPAh^Z|53 zEPsR?7hwK9_mcA(0^hn&IV;) zdBpz-Ne;3s39RQqEdO^a=3TS1xE{zaMx*jqNrSJb%40z!n0hID18w%Bo9TbI&`T0a z46pvP4?uvZxKKZ3B~lM z`EefKb-M@b$NWEFDYQg(w7Ghs$UnXHLb7b&b`ALWz_kBcsz0cP$=<#>C8HAYQX@Z! z>XaC21W^aGNAjeoF^E+a3l`1)$nxK0gc!;BRkh{*&XcHug)5BO@@27Yppl*t+SNP4|6RFkD1)5fx;9 z<3C@pOO55NM_a?r=?}EL9-g_ungjm&4{U?SRWIsQUb()5{D&d{{s#!a_0Nd_4*0h* zfZoR-|81W=zo-VeXynB96M>IB)v3I9o3*%^=x6|#*N8voa5GD~sJSwx2k9_y{0#6Dj@={^13ov+mGADDjuWoLl4>s&IjQ$gFpxuS}y=@&xT8}iMgT?Lc9N>id$W=`+=pPdLyTID@R z{^pqn%ZZetKBi4xlxI_@{TA%zc6-nOPZO`YB*i=MXpmw*@=%+;8E$35Jv|W_b^ajY z^Wea)RrIV6FUqq(_HrzQQDcNl(|lutb7id8|tJ)b%&?J0XAZD4tm zSJvfL{ABphT^|xQ+BM9ae7%toU?9jeqifVobwyN*lA!^7+x*Zj&dldS$*^-!&aYAN zB)xu3sAuR-ayTN+YO*c*OfO)+F^|*mOH-40snqwaCDBSLkI>s9$U`=m3S;Gmh>VRM z3>D`LP8LA6`YIR2wYgxY6wN$V`GnJic00R6;O$EC#`gz(Yt^*dWg4C!cD94y3JcC1 zF$to`fa+5XK7R9!ckKa|bxN9xM{?%+) zD^knJa?i27?v4i3$Etx2;d^F)*vCN;yp@=u$Ad2w#3Z58Mo}`5-hL;zD3HxB?6P+{ zp%oL?xI&Q6`4h{>XDe7C8(!{EZq!yLqpp_oHt$jmGL*>^WS8DBe1Gx!Vu_-au~m}z zv+Kw&H*q7M=r2XVr_>kC0m)?08s)OLi;UBb=B+`q6Ne?oU3TeGxtGHvu$$x)^Y(ZL zD~l#Ngu-8gGD0^bd6h2OK)ur&tU5C7;ZgiYY*Zx;Jtu?lv;}ALiPvSltAfhL%{1O< z5aVMzchg578w63U7_|=F+AmYI%B`w;DvnfiwzQXVo9T9;KL=>1Wz^kxEP)`zGdE9NHQZwO&ST-%_IyM)4R zgy83=jKET#(EC)E3#14y^_VhD6^di+H^! z`iuC=IxZJK^V0zIn?+USlHAfX3hYp^n_sz$=EQfoNvf?4tF7UmXl<{6X1#zGuM!_Sr5{t+`! zSy8t+cVzTu@xVvpk^03HGgI4&^hS7NApjt)K5|tw2|+R5>cuNseOw!h(b#I$7y9Cy zM;mu28@N%&kGi(!w-oHZfH(bM7S<9)fuD10gxRW!tUO)*Mhyew){cp+}8ysDNhqp7W-D5zMY_P zVvKVn8-h2$@gNShaYYkV`7%`?rWvpSBC;xEOKcWxKPcCoUptmgwPw{-19o;O&1 zGlha-)8n|XY9mqo7OwvQ_OQ1)dL!t9>IE<6$Fu?Egwb+)9g!9728g$miDf5ij_Z%f zi>VKkg7sLRUb@sG-W}b{m+y~ha0_^=x_uP=M$w9#eDdaKkIyQa0r=@J3U2Jkf;66jp*adl3mCck&-=j~T-VoBJ=8adU_I>+jPG`z-iH0!sa{2(BN@9^vHat*);g4{P-2E`2%F_UQrAf!w)8vc|?7d5*{gY;@Sywx);y37o2PRrF{`}B7r!ZO5n-gPiW#{Xy zW@kK)_V14;_&&2rZ7{>XH~YrtNcVPoup^g6Nl$Jz#Pt{i)UIi3-#@=LukCB+we##7 z9+IivSJ9G_R^@bg$)Mu8cBr7!uK9+Zh%c)~)IBd|ZnJez-UUYDdAZ^&Zf20pm-ATr zlJ{7>lWC4n9)LY|Kvm~Lb|tN#yHo#GnD+W-1uJne0U!3}Z!b@jxo!}p}WEI5rmW!gU6 zKTH1Vuv?;8zU^b1*7bs|TD0{uk+mwgn)(dh!?ND@r?aUHkTm~XCl#uGKGT*1xaRux zK@w-5Re$>XzTPz=Ri5LAfa*MAalNX9vTpj2C9ppeic6@F6p1-3gZ=WeKL3EGl|}6u z`n&95ao?<&Lnaq+y=6GNJhQD)$ArmT-2K#;S;zC` zOY*11d?BV()^5s53~n*XeN>HfU%f)MkLGZs% zZT0qD96`g?y<5(=?BSW_k_4Tf2>P+yqq~39TW+nPw{JJlge`$)Vz>DH-Q}LLZ=oy! zbE2g$)V8L?WrVj6Lu7x`=Wc>qy|F8r*eqJ`V=~%!;Vn;(Q0!K$%_6;BILu<-uc!K*=puvZ^Y9h0c^Ufr*o$=9;J->w-8mI@Bm9 zd~@ZbP#X%NNt1vmnB4j%ov&KTNy3-f7umPrZ0U^*i;Nj??e2m+C4I2$K5e73YM;!< zF*h-<1$KpgS^tWv3>02_#yvp;eHrK9-!{-cBR$CZ62a>i|MMyVDFGHWmZC#`==QsJk3yV3TF{Xj07~rlz6xfhF%@CJRoDk+=;_>sqmPEetP~a)oBG zsSPC_vmZh(xxxUz)M8>K=Lb^l(@oE3Y1o3jJ--mRSJitu3v?MM<~HX5vpYH6UunXeF zJ`_=jC8)koevt$Q3twnmUX7oQ<^7$TD#K{avkeZ8reXN{lj)&!rE?2>(aEMq9LlVu zz|*4(J14m46O}bMS9*9;0r`edjyI%Xi02enZ=u7Dk`^Tktj)Q@@fskA$egvm>YB}~ z-~#V6D#*l(5vazWUzt=8{F1hAsk2Y#SJ?4uUzX+TM$gG2Agm_x`G@W{iANJBHcJRz zd%hh9@x?WT^%GilhP4;yd{Y;PjQddHO?kj3EIq@2JeTtWO)Ga4hjzuHNqQh?>7x8- zt6zt^s>qm8iL5I<{#{I0g*vk~8kzWe=$=$O>NRb0y}m{ySMnFb^-M~~R3k5lJ&(Eb zAguV-q>h0?kKY|`1lq#OC3U^0vTl|(*t`>W0csYZWU{ETY8=P1R7}rTbqx&I>p+vh zL|Op%CGv86 zfW9o7psVVDI#1VmKz*?%IAos*+F!Me0{&YI0#WR2sidte) zbwU9^N&|JvRMlI3tOJ9!9IHXeKjguDI)1m)Gq-a;FD9jOe!785R8R|z^E2_oi~!EE zE;XuY{ovKGfchauJOIk3Aed^Iqk1UUnza%>8ORURcAzB^sEiu`HrrzS42m8&rR>a& z7K6!=D=A*6po_D$I|d?u`#K{t1!sc!RSQh)&74v^qiKMU!u;E)3xi2c!s-*rg}<4| z@6zM-O)&W`SB|8+ox%;c0eNjw$^%CbaQ#sniG3oiC^gW)L;)FI&K>vhkv`YCz5PVj zbU*YRpo;OkzTb=_Fl(WwO>Wq6rGHFM!Y%f_)^)Z)TP7`t_Gq}p2Fvm`@q&`8raeOW z?D&V?eB}XZPbuHEkw+C#4?k||xy^WqCF$9ygs9cZC2C(lsY6{%UU(7Ix_qIJYV8>SyYx5` zbC(%Wha9-_UDu74lpM}TewQ#=A=zD3NO`kTnfNG^zN+}<9i2jE&}?Wi*+2vV0<5Us z=U7aRx-#6=%gl>+;tT7EWXXD0ebyjUKK2bkcJ=tS?%58#8IZ8 zKHC5QCoP1+-jDd9L1Z6PFvS;iK8^U%b8||xj+|7ao%yhGqdz@C^b?V9VN)GYWggbI zB}ROc1nh#Tv?D8bMhp|!n@F;z3DeAUAUj2Oxve~X(TmBtGjNK`_lWmNc2v%(Ru6U1`At&vo0bm#&E8|$02~tG zv_74aA9_ZZGVVW?#sFk;F)VLC&}k(wwcgKtuNZEGWs%irDuhZch?7gZHA5^~*h00P zdMXu`qM)oeimhq5IcU9=6nK0puStIJY9EHKmm>C!!A!m7-VIn9whA6 z$!Hx@BHF~8N&rAb+dxyTpEKZ1^Rso7KioCr#=e#g?418@zPIRR;n#7CCy$s9R0pg~ z0-g{1<1p^=+m;%arkSp1Hm)@9Ec}RQBg<&<0Ik9Tk#F%X!>{f$EmweSZh9!xgl!4? zQsSf&)3mxD{}>N03-tO>^8CY8u3Y~erIrh_#`J0zImQCpTH*OmWA2}eGIp_z0uG>?Kl-{_BbbL>8&@Q`&2bZdmp3EJEl6@ zwjPoWQMy3R=ns;e0vgBsX|!v&+UY%6d_&dF#^Of~xEtV86>mr9}C*-Y#mEVx;I?7>fU`xbid<-Wx0|N(2MD)TDa+ zaPs^!hakSemA3KM4wsRK8Q&`;HF^MCs>8v-pH^yPDB_pKI>*hCHev9vyWqxl&e70J z5`j;4Nl*MV@X1NCbb9=E&^O4$L>SFPO`UgJVA<7_$iZwpp-Q?kmi*qx&X&(_3k3KH zIRw4o9z3RT4`9A;YKlKj3=ux`ld7TieMbA^++)*}-`T}8JV|E5(u>xaKbB)^=+kU73&)+o<$1FSk^^rfz*ZGR8q%WS5lK>>`rI_T&`{d;SeSWqtHHb{x3 zKit`W6K4+sxHDJjMp&0W5? zNV};j;&**}q~f;X2_tYcpup6mDPD}G^@}P>)6a5) zkdS{u`ABy-5OZWOi^^RJEn*-7cCm2*{Zct^U>4abLf3K;>rwF}tenc(Rb%H2bH=I1 zF*>4>&9g%G{P26X13t{naGsi3kcDx9+@syhPJ_sE&$uQ_rD?M=8!CYO>toNDr`ll}9(n zW?0lfL#QIPhj20*Mn>sj=Ow@+?OoL`)9No3l0!|<8yD~eakbMP%0?d1K|Bp&*oph| zhkpcq!xut>@40;_^arphL3p-nCj$Wr9qacKjY7asZXt-9v2-+nb@@9z$mKuI3zF1(ZG?z94;T z$N39~#qA`iC&x;qHaEvNtiIWMQ~LqykVlLeVdRY3rl^Dzm;f-5JRE=(|Ksv=BHF7o zlKZ;`4YZw_HEfGlb;yP*->GlQmK3OJt~?o*KN4XTGO&0(wC(w6uT=A9@5xgm6#!>^ zq+9Dj3C%OVQxkiD*gXxT7DvW!Sqq{>Iq_2)TB{}>wT?}V)0`4CWi@@a!Ai=)a+ye5 zh`(lze^)R)4ug{7zE1ArY67YExl^9ou>#9ieN^wmsq@d{-Ro}Nf$Xs9vG)7{Kh<>N z|A2XT<0RdH@wfLWlZDCN##4`#5yscZeS$g7FCM~~)!Py)}JJD4S$*qF(sG=}+r-URIjj ztQECB7tN`9ncJXw(D-9^ELMJ;FOG}NJauBDEz^6?iBr|wfI#}Zw=v_tuWnUQ$JK-T zNXc(J!F^FT_^9l{f4U}Pb43M1W z0Va!IK(uFSJ#jzU5SeiV{#0L0=1&$iFiroR(-yYtmA7HAhIw~+PShK7b%~a}Qk4Al s_4z7~p-M#HVaPWjVSwL#poIb%FwGe(<;0g$88={f!&n!sZTI;90Dfa3p#T5? literal 0 HcmV?d00001 diff --git a/icons/brand.svg b/icons/brand.svg new file mode 100644 index 00000000..18f6bd0f --- /dev/null +++ b/icons/brand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/icon.png b/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ea83f744ad560d62edce18b976190fad9c232b5e GIT binary patch literal 15760 zcmeIZc{tSX_b~oKmZYJGWJ{7PZDfy`XeANZ%9@ZQ`#KC0HDyVKlyxdgMV9Ql2o*6Q zYY5qmbsFpNobi5tuirnv=enNj`u_7QpFg^EzwY}!_c`Z2`+eq--sST<+4r#{2(t6y zg){mH!Zc3*$HoF*bl$Z8fj`?_E||I_$d29ge+;dCxOD{Ck6b)+`l?UDbe~hWMYlC+ zJ}>sCf~2;S;V$Nar-^B|_Yga?-d;V!lr(3wFDplEkNDLKQ--m+m`-VRy_6b1?JMj& zwuL-@wHqJraC*2c;>b}4bwu^9{<{ko_$>|3L?6l(Kge~&>WzlBq4^t|T{_XxTaVU9f%GWV2cXn;#&xs?=S?(uQ-c_ z1PW;^tKO&(WEA=xaz+_>qMU2NhDW(v!amOqv{@f8XxW7v#)SXG)lsBKfJ=5Cdxe&DKElW=B4nrdV9-)_1OSEw)w?T7n_ z$*)?UI>n6yu?{UPDaPTtC`!+DiA;HlUgQ|AnkmtcHupeY1Yuo{;Hp92T^(el2F9P| z8n3>y-5m)MTTApg7s)j8ToZPPH8CKUr;9Y$0*fXHBGkit=iwuWO#>hz3Uj>m!he}} zk7NGUXE|xIp~tRzAwj7tf4OSTo3(h!G77!z=qhkcTlZrdymy5KF?kdyqOp97E`=1Dz>w8{-ju)T7%(oUeR2^z&Kt59NTs5%`S+wFo zUlqA=Om2Oa8n?!>H%RKqPy{*qFuh{)Ynlj4t-LLZ&|&@6ozupjf%#YZxiz|ggXtW- z(SX5WyYCFh`(5^JRek-Gh9)9Y-39FTK*Izkgq8xfjT>O^s?VZ{6RH`ItI|o7 zjq~xiV%0HG-f>LF7E`MBYWV!P0Fz`zO}1l-w7GXBs60Tm5O#g?dH7I?6?t*ro4}>6 z>_ygX_ALHRWHSAjB7|6VR7Rto#Q%KejYr)SPi)<2q$6W@?5OW%QksamTr8{gBh@Dn zAXob|mOlcN{YQLf+X@>@j|Zb}ijw75)!#56)#~cql-i}smRVsK){bTz_+r$cwhr3g z;5(-lk;yTN8XtHxWX|FZxIxkNb8Lg0@-&z9dV(#dN)E4NbJbyBrP4hgqaS?0N|y*u z9U_x-EGl>{#$yhArC{C6psUCS^1JOGR;jI-=*feS?phMz=TtF?;H?L>maSKpGkY;rsmpeh=e7;%@&`u5sB0=)YH`+J@iJw_#pD0;L()Tzn;By@}Z60x2 z5}9^L0Ny5fdnb~QIdN@M$4x#U0ARnV!uj{|^))A=RGr(G&x4y!?+IYWUpvXp?- zGo=Q<@Z17r)Hk9by7ikn^CuaQFYV~ttAlIq!h(2I0{y~>TqkgwfI#xMzm0pwt8@Rc zX3}8!PMt*%b}pu3$JIeOW!yVPA*JU$W0oAE<-Oe75aRwSFdU<4A~S!e;;IKxN?9$B z;8=DZ<3OD2bWdAJzyxggnCloQ33sB4LV`5v9;IzQ>5V-~!i!xrzUog%XF)K7{b(<; zz56N~EYA11QC0@TzOD0)FkhQDQxDS|3evnts0rvkMn#afmlPz**1{BAOsb{>W;*$Bs5TvO|H`@_iieaV7dyxwR=wC=X9A4iZ z*`{(FxC@-w5v7PAR>JBzkJo;XPqX%m9vNHt$yKwM#DO4kyf!QTZJf35h4K6zzb__p z!~VJ7GHLM{{v3=#K488@c8ZpFax;KMyBul0_R>&|UHccFwY8O+tyCa{ApN4}7U>p$ zu@NU@N5Y$LK{&o^^YME2q3omJB8H|{_GtGqAipuL@77v`DWk7VhEQ^2?Oq6?6uq}) z7Ot!Lqi`&oZUCZ!Bu$j$AMtS5rqa{}grh|L39}6Jl}l;lSK6W^>wcWw3_WywJU)A5 z3cuas#XGn&8DTH$bvWXJ#ojV%UZc-V)T3bjePs#{Rs;W?%F&J>*hJb$d4&qZ!w|l(p)D^f6 zSclnsxEV0BL0gOB!+(r07tnRuw0y7&K{`gw;O4JvM8=;cs5tQ(!_o|~>|1-r_0ne* zvJfQ4Xduq0P&f`l_!e%yc8zV2c^EPd$356J!s7*IHjlS0j`chU@^NnAN+tN1I)Xy09jwXex8qK8}ZUojy;GKr3>);>Lab}T}ZbDDHf`AB7I zSsmJ@(mtrCZ3pKR_>OuyG77QBJ})X;gZTxo+tWQ%2g_F&!h+D`nG2u<j zR-g4->MF2LTCZVN;kh;4V;n93N0PJAD|I_O4!14s1?f86BZ6HB;~UpYL*My@AjDe( z3xD{IkzQS*CNm+J?jdzb8y1bn5Ng8B%VuLhmocszll{j`h?>4f_t6bbO>)ng=r)z; zvGNnZKdJRk6(Sli+3(l{(AX`@mNeNs4!4BW!`AntaP!_792YPE$k;Q?j39<9i)A9` zh?=GwA@ht#(EC#=hvCjlD}VSfAQ(eoAsz|RE17{CICdv}ej@%JhVa@yV-UbfsVRMh3sLTO&ICYORf>Sp zH|#&lmLNJiyK=3Tj?C)he$uPMRShn1c#N=F!6DKs!Ky|R7$rh@Ax(OPz-fw?5k#** z8lIyKo8 zrTGid0vgL%Uo)oWQ@Ls)?H3AUg6$KNcZh~)y9csW+b{|t-`w*i2;cYBJApsuKRX=w zZlq+UA+Ncms{pbEtmzQw6vEF1g%0Ei1OC4=&`ZjewbNrpH&&}c#o=(vrriHG`rj$< zq_YpPvsQh>>5E-pM}S3EC`c)qWm*}0?upO*_Zjiu=H&kbfKUH^a1{B8KWeMge3=oW z3q&Twv5iveW%m??INCf?1=Me1;3MeMHkJRSL6RbyCpdS1wn2~8;{sUq_HN49;lj2m ztYSzG_a;Qz&A*aQKqxJ{ouUzLE-<|2zv}L%8)9ivBk^Kgu^}sBvzS|>IFV6(!~J|Q z94zwy(*mKD{nF>y8vg4Lj#WBA>EH^$Yj>W}%fgp?VJ7 z+Tovf>`B9a%_ltOvL7xE)!EKky7YkX>3uY1@r!e<76H? zr8q&2EVr4DZOZUG`aP{T9xM%ytpYyjIeNBO)=5GAY1U0u)g@M7dGxDQOk`u?@W-+I zgucv`^3Rs_(-3{NRp($chIV8Fs5LGyLy7QXtdis^2kYD(XXn66e!jnCt7 zhU}^?3ZQnpdJ6!aIQKd??+!Tdp&8IMd3S?Rt$=hLXQxS}`vLHpCNY^z<_S@F_Zcnvv}i9z*E=E1-R*Z4cv-87 zj~pJ}2~n>M91fYMfJA6NH7BOObcau3U^x&O9V2`aH6dPE!hQ=@=pDvBybI46`qCY- zXNh6%><3!`k^9%M#7Ofp(PqhLIug8gAMi;vqpN>*9wuy!Z#d!Ap#Zm6LDGeIa~|s> z9iR&k1RJk0nkN{%a{u@6hL2hNln*>7^|; z!HSb!OzGV}H-i&p0LJe*8ZhoXWR-tZHyq5s5jQsVV9II*FonqY)3saKM@}A&M=fq_3sK#s1LR05-{~En?ellyqlqqWB?34xD;CHB?4P6`% zZ&EBn*8P7D-W^_08 zu9dcEj%6MOVMTq6#^@TquTb~3>CZ|%UZoOi+w5wKY2%#74i-=vw+mZTdHsac4N1z+ z*QuDHD(WjcuKX4mc&DdCRwX-MraIBJUy z&7Tgay|XG7?~n>nWHr-{ogT2j7iuJV1%exe7aSA{4t?sb<8)76lrda&w~{L{J$TR}uKY?>=cvDD&uembta|4Kv@IH(O;hgy zJJOif@n04BDFHLALq`%WaE{ey{S5$Zm-Bp$5h?Ssv16^RGw5l#)>?V$fLi(Ot!-@= z?Ma*6e`5RsBpE#5Uib@}#&zoFJsoN^*0ZirsK zCS6`n+-ttn|F*W`B)2xc)t5nlZlezOS=*Bw`?0vMkI1&%6V)8F2d4>A%cd;-gDduG zk8>SumJ{O3uMiZ4*o_H$Cu}fsOJ#R^fmeV3{(fEi(5%EV;5Zuc?k!Feu76_m(EskR>Rg#1`b zaPK%|RUarWO4|4A_a`=qa4MC{1xI2}2#vCVQ=G2GWP4E8b+miO#Llj!zBH?{;{}S@ zm+~>$PMf~rC$)R+4}k?hWgKb_H=kMqPO%p{gJH(1Z`==DIMsZy4_jr=f7M+MYvmmp7Ok< zVid8>XH-tkfYtSU#9E)IunK@4(->(re`4HTp1oA3a7NP#9*Jw@DIvjuzP2!+jvm?zGFD2Ao z$dKuOyKHUlvbAIID+X=+wfK=c>ca#fBWCuzH!s`*&+jWlV}-^;B<1&Uv*#o8#tV*( zz#R;c6HjN0lblb|T(ApLr_Qb>y<5R~NR&baV3f+Tx}wF>IIZ09Z03BG(L|f7oR`!0 zsvyvZp`X0tOZqoIxz?@yn7z76nLZ1!!qwP7F?AdG3)xw;65J0=Ocq?TkNf@_VAXqp zkec=%B`N2A%eIgU1EqNRUXpag+1|SKr(=bz{|Dw?C9BOZ7W?xOW-c{}afpFI!-IK^oRS77Ylwdyaa#;A>i*>rZeU=D(wzIu0a zbEWyu9+UEYv8D0!(=UC-pv_mUQpCUHOwZ;O>ks$*iTG$PTvoWe32|7^zLR@?;c@N@ z>@vCuu9e0=79&{CfI$RF$~4=FpP_gef%ui}Il7~$iH@gUt*9$wVhoUy@i9hVOpq7u zeag62*h8W?4@IXe=zb_3mo<0Z&l^CRs=UZaV;Y{;1U%%WagWNlf0@3CLxt0?VW;s? zP|DofUBM{Fr>OAW%<+j%pB4x`dEQVk-gWyNe^P3p2AimL?vt!HL`7n9<1TK-(!LMu z6GnmgQ(~mSkk$gnf~MEbmn^_<7`=vV7{QXawwr~)L%<^)Elv;P%%}Gu{+Cwfh6EX@ zj)TH#m_ecGCSXT+oo$8D+liUffTD zqGZ4g;(+w>O4;sE!FE|}C3d$OKw&vS@;>FGl=RK6wBL=&AhPCL@zT$YiS(jXXhuS40u4e286g;M%4W8 zP?^OX@?xs^V@ff&>}G%Vc33wS;yN7*dT@M0k)&O@L+!;``|kRzJ~mOa%<0qdbo9d8 zj|U4oU9!N}y?0GBg*(EW+op0#^M2?1vz=xiATam!#PM^}EPnU!v^^GYQ;>-84g z7EL?{q5sX4?#0w-|7Y0aVa?C(7NgTKp?c>m({L$^=uZJvgQnVYwriLsTOw5G0ttm`&cvab3ZH#<%*h_CqZjPDWT z;WGzKJ|B(yvFLZd<@?r-Oy42xGQ(x7ZbI{c_TqfC7j!zx)%eGsm&iFzG%vdEQD@2< zgS@|_hf&RBwAt0L{NF|E+dX_TJ5tHcTU*vE#7OWQcm0z(#lWUB)Z=l=P%3#zgDrk5 zHkm%994y@QT23+MnA5(3(t_EG`*IA9`ie-uXdw#n#*ew@(#04Q+Jeh+l0T9R^sa?&-< z3VQ8Rh-bV&W~D;bmsz-er8`vDiPNqH?*G(J)>2as6jxP)E_u^vJ}dPDSR~A8>Q0Ks zMSd{by#kL)=5LpSRrkWxNzJtbwQ*+$+^l|; zH~rQt9uNIz8(}**q6f(*+LS~b>ONnO3H%K~E0kaLitSnaAfXTqQH@gk_SaA4dwgi> zi7bb|4Y;9JYU5ft0Of&~knvYFOdU;1Mly+ARpu*?$Up4bIEy6c3UOBD4zbh2#LVOF z^tJR-<6DP1-`)2ZD_Nrf3l9(=j6)2a8t+?rzQ>yDCp5fVbeCH$-XhT!r5Y^!Q|Y>Q z-@6&l7p4hSPn#b=xFqEU=_uCR`Evi^-go?h2Nn3P%RO?ZKN1o3&%Zp)8PojQGDoE1 z4g^C=RZ3Bjjt*cruTEaT_d^NE);NnCy7uF~E?z&O>g0b-PQL~GTTLnBN0#2iip?k| znA zz=ci--Viz0*MQ`pU-^c^!a4IBDYHs_0R>dEH`GVU0W+UtZbMY9DUk?_nLRt;S-}8B&(G*4pg?+Dm z=J0#*$%0~jqIFDOJ_s1a|bRz{krS3=^|tW7K@!~iu5Hxb;tp2@Xa%69a?d%p_-C(JbV4a~%au2^)-b6F?NuBT zA8`FjyRTDrPKAd`N90MhwK)$Z$}AJgPdF7cx&;8 z10>z_sInuT$)!=$Th1|s>uN&xiq;ys)O!n}1lc9P`4VxEsn7XLEcQkzc1=O8ajK-= zFLWV-XT+|N^H@?B?zQRyGdS0FXK}o+FiU;fS~lmAdOygwE1J1~x1A0wJ6&m=wr+Aa zRdWy(aAOTe_r&eawy^lzxl@AVUw?KciO-l0TuvXFm9crC=5$smS!3OQ)#my7&7nu+ zF}@QP^cZilXcAK-65u-2G~LHDBU-m+ZSUYc+x~c0rNC(Y&D_VHi9M(Pft-pLo<47{ zuAcveynSnDXXYw*+AUwF1(x>V5ziz5hD1Az(9icfb$q4=sy3G4i${hbv z{hGKne>4NbIdefGkpBE=5;_gYREn9WLKBKa-3%p~szOBIc>69pXa}6S6 z-a&`58Xu)-C2zq?pO(5QGCpXXV(H2(;5g@2Y6|FCO@y*hekXYt(TgKuTL+{}!j_x&C|I^2dJmE?Qu zyn}Xe%2i|sFfdbkH;DwoQ=IDelz|uJ+s|`nUkP)?b-DwqI#RpnG}G=vHOj2EetY@o zK8U&Yvz}#L%~I>k>5gr-uA7a4=9HX?M-YfAzdb;z9p>&=oYo%W^5P8crRG4KZFZG= zd~kly6>8!-{UL-bWe%YG%MW^=XSSPFeOAO;=e6+OF%ZyjjS7DDYN(p{b6A33&L$CwUmsmINZah8})y5Mu)!hjH#Z3nJ=aV+P{)d zdO30YRSNdIyKqQ8RJ#XSdt53WwAj(#722~W`6(4c9Phg+-Nm~!l&)(-%Mh}7JRURf zPsqKd!KNtTtY50+xN!|ghU%*$^qB-bYdQ*aRZPsQ6vi5~U#S$BxVxxh12v!MTMAeb zrz}K{J~Sh?!M{u!dlrm-S}X=bXb*3j@gp3o7Os+%B->fVLz|4s*ftA@RfxQK$)^Kb zp#y6I%Rz+(jIU=;p?`l%$hr(+DD2`MXXTnSE#+=VnIL>932>oD?C`N=e|sL+(#wY0 z0fjL?m9i}pu$`j0d(!d5+VKJLEp2C{J%kresMn>wuAEyi({Ai`ZlRstMu(8MpA;!+ z-r}Wf{=+Nn3+Gc2xlva7dzl^@Ux_%Ztl8#GHt=!=_U;~K<^K0G#=(fDA7_X*{?2+m z$;Z5>Lr~WiE%h%t+Rv!(Ew$vPZqRbESi45@G4#QIXQZFp2oPgltD8j6T@7EDn%8aP z?39>YxU{9u5~NP6=^ky#E=8#%bH0%s&<6-siZS4OYvtd)lVfdY?Mi!^QRo60au$rCl7r&r3eR+z_Hc`xPt5fM(Sb)Nk~XH5@BzMChC`RgchG#yOQmjY2CWHCHsS%# zl%#Qrb<2G`!$m@Kh1dRtBOPTopo^Y=*PMpd?TMI?=yO`NwqKBP6#EAaA$EGoLc~R! zfY31rm=3!4-+WK}4}kw?m`H!wtTqT3cW`FBI_?zAJ|#+G_$>*Mi2Aa5&}z83<5g=N z=n#s9ix9g<3lUB?kHh7#3bE(gkboYkA}@3}hVapZUaj!-_2`I(fzn|ZOfKAt z$)wNF-|Ibn^R%9Nxvpv?C3x@v&z+KdS&QK5{AHkf?kmG^&4HF0_jrm54Zt%zUPZ$ zTF^uVvbSCI*NG%BLNQne?MXSnKa9@OFO9G4T7ofzNx@YDB%&sdG<9FY+yxb%p|}%3 zo6N5}tAkYQDroxo`_BPNtpnfTTqub-5$d3WwWqFjaGMHOmLJrRbBxZbSDO+w6Nltc zOX2WpmHGW?Q4+hyTRjz-W(#`VCDQS{vfy24t??`224U|QHi@zYrpMrH#=g<3I3MCC zevgWQEGp&!$vM{=)N=q{9pL|{>XTeOz3$}wSe0H0YPu)giEIR7m$2ODpRu?U7mm!1 zadhl*G25oP@z`US4JBL6|C_^wo732$|!uX~7V34luPZOIB-xm`}zGYLsDN*G` zMIEgHHH&ZF_cI2OyTkpg z@wXAiEm0W4dlPzr(OPh6z6{7bV|un~t`cs|c%^@FQ5~m=M|n<+NMPaej#ZGaf)qSW zBi}X9vhRnw3uWkb6t;!lmzP`~ikpQJM0GuC%|8U#(U#x06y5r636Kz2x#snj3N2QN zZo~oKC1AkwHPO2lcaQ76`WSkbUZ&0*OYu*eiNO%GVlwt;l?TGhSX6u(nMGMv3R(aw zPz7KO*f|Qh4S|o}^ums9ph3%|m8TZ36*O#9v7&=m@1~L56Sk>b&EhD^`3d)8;jQ%9 zsu*tOf^B@Wu`UFMNuGueWavG~J0uFBuw2c()Z61KMZzcFp}zvyCq@r`3x@90V_TEX zM<0|ft}8=tXp<3$XV*{%l^Ks(ucr@|9p>JVBe#3}e)RDttud+!!bQ*J(ujY)ndx+CXM_!@jUb=*DRDQ4x0!1G76de{6&9Rx}>M>tv>Lx zWD#gIxw!H`UhyQf^FDeR+pVoCLBhwx;Q3VtRzaE7R^SNRXo4gOuP7NT#}UDVSWPeJ z6}MfmgzT$7lOsZP@ehLReFrSBF_(}b;XQ?79b!R34z0*d%M4XzLu0r8gZl56ya10g z3)RE5Y}~!cVkCUQ%V`u+{qRpqW}fV_5(G_mQv8GC9|2kSRKo?wz+43!9vB}LK7=5r zh2)*Qg=r&f9z?#8jW1%l@Y?~a$KMP;4Q}@!Dvk&w!-Mty&>x?Dq1Auj47i5V2@#Ub z@bicV`{7BdD_3xgV1tD`YHtWEv^sx|pAKDf_XL96>Ap353fjYO9S}l-jwtVQPmlma zg6Yew-o$lm?1s_Vl?N-5sUEYBm=MyNi}XXP6#$lRO zPx*spM-TfO@dh1&UnyKSJ`jLT^%$ODLgvmXiZ^GcQ-4F#H@8l2z%l6wzJ9tm#@(R< znAU$xNh6O+re<4!H3@t&t2A(Ihg#G@5DI6Y8-A;+_b5I za9(@CzBW>)H?d<*4=m9+Wo^o*IVA-7W?xgDO8fYnAWFjPeJh~^i~%#=dl^0W)Yn0Q zwsS6h<=Bm{&*AOt{ht=x`IYjOXu_w8Gejn9pl+tp0|~kRZvu~}Z)X7aW6Q$NmBXS0F(4W~{oZR9kgysuIhHycHKz!7K0Li@-lJzkyir%Z0K z`e}6QQ&BerQO(`USahs;;2e=DsvVR__RLI}A_tNqA4Z;jQ2pT(uea5LFpBE-HaR7F zW6WvMsIZu+Ah#}2-aJOsrAYYqY9?%q-6Y=XnK_qHosf~hX33(36BxiLskf~iv(ei>1!I$#%5dNzhj$sCce|(kPl-!Jrh;ALQQlWxua{F< zcRj_hcIhc+R6hK>>6g@YfQ0X1qg*VN$`}~~8PBF!p8?>02RqW?*E#fUPmRAF(~cHc zR)9AYtNr2~_S&B|jHxn=y}hFr!gWj$rBQ@1Sk zUO?4)gZy1YFMDl4d9a7w7k;CJ;cD5owzhjR>{g3+&Svi#W6;;H(!)hzdt0g$x;|WP zCNhOzQf{o=iz#}zyfhTB7d{lYvIHFW7O!!Ry|G>>h!0MN8xL=475SrESbF3`X?m(l z;QCaZWxNz85?|s+(X~`xoN~0UGmY5;mI7fT&chE)5vwJ72#IW?hqB0igdWWx3|qhI zWc#PxkKV+HAdFjGoLv9>&=dL3mj7_<{|JF8d#=BV9yj~i|3h&H4a69^c=qy{EDfuB F{{w~#ZPNe% literal 0 HcmV?d00001 diff --git a/icons/icon.svg b/icons/icon.svg new file mode 100644 index 00000000..0bb8a8bd --- /dev/null +++ b/icons/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/icon_left_brand.png b/icons/icon_left_brand.png new file mode 100644 index 0000000000000000000000000000000000000000..3ac536fd98f101469ef2ed16635a3d46bd06b048 GIT binary patch literal 8434 zcmZ{KcR1VM7k^@_U5Z*&)M|~Qv5D5Ktx9dBg2pHndj!=Qt-Xo8_l&(dG}YK61f>;Q z>`nC-pYK1v=l49nJWrB$&N=tq^FHr$?mg#q5@n#LL34xc1^@t{(b7~k0sx3{gzIz) zGQy|pb6ybPi}H=8*;@dB>gM$uI5r=A2>@^dv{Y4$eY5fNJ??xKCcS%-gWSK=a{y8I zz{iaD?Gn`av^3xTaY`ELC!>XJi}K8s=@lI-Ml$n6^~zlJ%0(Qp zu-{Ki5Y+?%{nQd1HF)+*dG_&Gt#^M2--Uf%!2`E|1U9`S(LP=1X?>(ZIYPDUS0pNz zy5yh-cjKBDX;@3V&oeB+aApDdS_pxld$fh+33T}MA#%V!!7)UX$CPAg^a93krd}&R zU;tDL(TlbAl6qsiPIxqfu}Ao^>%K%-G8Hlp|Co66sJe3RzL8U_8nBpFnX$$k61qXk@g) zEJFe#!|f>4RCKAY2v2GFdZ3YfxZpj`cosizJ{~csz=(8+RQgZ|`Z;-g^gT(ztaR}M zBEUA2-?1w3-YQg~j1v7leEqV*yC0~1ayJPmUdgFDw`6RSQ^Wo*21|LEa9f3sn>_yJ zIHlb7=@DA$-c5jQ2H8@{{Jhmc0(xN(O;S`k$)iic{wolb;XiE-i97zEHZ6?1dKl2@1xe#OdH86Rf?~kIE9^ri*&6jbe ziO;8E6n?7WdiSkeb!qYB|>w|kiq1L($C=hPRwPvMn_=gOw*?4 zN}e(0G7;}9mvI7>PL*~D+5r?;WqnZ|c#5!8W3V-D#ytkr@>&2+*RAUzP2jc`sbP>D z3Vh$@d6KZyXLm5M0GM(W=e3J%dE63YjMw0AhF>;f8S=JUUTYAj@YA;R2#pU$RH%?P zZ<4Z$Nh||_3V8E?AVyflK;uY@@RKj<>86JkKeyQaR-HG!FCb!`K#VUSgvom<_PAsS z%1kU2q_jFuxMMgk$&VL;--1xrYjX2RY78$oc$28`a$kEJAck!GEG2SI&NprtszEPB z#RyQ7gDeRWv_)^luQomKTwROJdAs-!JzQJ zk#B6p5m{#YR8((vpwMLcjB>{cqOTHJ#(S!VFKwj~#-{ zDPOgF`2I9AR#ia9+EFD`Q<9vRv|d<;);v}=P8b$OH)Mf4C^pNE@7iilnClH zVn@$=_K@DYT`6%PI|~ovnr-B-Z!?dfv4jvXZ$(fLqnQ?yO9bUteaf%nD*szKXU)Wc zpIcdYAFUIQk3uB{4EJ9SeRLfhOELmqX*ctFMB&Z9Ud=@PB{)9-{?(~}bD+w!h`0C= z?w^(X&9r~ZXL7eLR-Ky%+!AyPILlRa@ZVEt|<$ZdtV)#nH!i<!#E;Ff&<$T_JYxBNp*Iwp>4^~OUb_v z<2;~l`|Wfq1v9DSxBNie(((%tQE_I0+O-ZV&{W|P=ri1m7Jkvrs)x(I^xIMoAy_T` z22(dx-*yfdSF>oWRsN3c&)!)5U|Uy!YE0N>lkx9u!+6k(JXZp+Ie=3p2ChjVDY^A^;bx~*#yzWzW# zS{Z97?>xH4YT+B(NLn5sI_)d+$D<=Dy=Kq@A1OZzRS3qDaonf*I=CrYcX|^Ya5<)+ zm*7|`cZ!+q?UWdiLo&UqH6rs7i&5y9THIF1he|X}lvKI=JX133hyzU?xBI~Kwadkr z9?|qW#$uTy@fqRmnz6p*cch9inx`PTUm=)QzJOG>|qcscwFo zVe_`DDu0efYUM!hB^r&3hukPqA+F&aHlr+QJbW4+X}E!pDKs0`P3fCwFr&sZ`FR&% zFc^S#ZMS|ekH5RT59>!|847hU7lSUPHFI*I08wj(n7v#)@7uyVI;*SN!F1q7sZEEm zmx|H4_|2_f;**b`x0f6VjvNOiytWNK9HFKU63=cRo!AXK3-TaQOanif!hk*L(Zn!bP@7DMXrNUC0ZZ)o5X5k6y$Y; zPRF~)z8aE`zTc?YO#fqaXZ&PYsy8%MWYA4}%|=;umz&XpAuOBcTl8^oj+jWs13~kv%v!}hVO6ImDTJ(_(BC|)K38go6>@dPA5maaF}i9KwzySG%y$F zYb5lB(N5g@NH_&nmw9w3_lLlnE39c^3*1>Z--|l-crxU#Nih0@!UYYMpP@S9N-h#Y zlYU@pA@L(~n=QFdac3Rw=430%m4r1y_5u;n8wUl_Kg__+g|1t#S6#$%N~sT@6<0^y z6aOd|Sw;dSoQX>&5G@4%al3gr)5jZa%-~xpvWX?yVU~e+cBGz_8NPRN1h$zF;oHX! zLcMWuuN~nzBU$!qyqlFS>pHS3vh+2DnzNav54U#>%)YQ{*CAt9+le*q+^jhtJ&7JgD%K=a&%`+RR8rQW{Jb$JFS$yUv-x z8Fu2d0HgwyBCM;l&o?ShLG+?ZKx0c-?WvUxSy2799%&XjJ~yB}zmEBq*7z{!!iN|f zAduhfs7TkZO|tAkc}#frMl-_GYU075^dU@6gw1_!j1(-_Ld|v5`x|Pw_3K37DiT%d zf7x)%@{=K%SFO}*8eq%YhesF8hMZ-iCvk2BcegxS~q$89aKWH@t=dRJn9 zNZ2+Z5+(eOPAZ3JN)Ff+-PD_u$8{&Nu*GX6M)|WJi#q?(09tn+6!rPL&gNqD7G?VJ z8~#pd4h4}>5Kno^{=s8Nb0O4sZeJf;G?uA2twXO=iSUD8ZRd0+=n^M6$_-$hmSEhf zDaJ5B?~3}}rt+NGV~hF3smr;w;UNAI{{qB-BHhno^s#wB_QqI=I zI6XFa>zpXXX^yXD7^5n9JD(pFQS&@EgZe9Wrag{d*Nfk)Y9}|QIc+x6Ppj^v{^I0! zVyy$+D+z92^POHbacey}A5i=>7UpeTzqif`#y-zZyIO`dhlqSr!vm{RCZM=su$*ve}&rWjs^Y+|HJ+B=- z;(y}~uD*=zRU;(dHLz`*@)Sh5glzK{f1!k7nm=b6ZHZInKh4@tm3>@E$g5W~CxOJS zSR?k_Z^%bgs1=HMq^T&9s(UdoQ^8whG{>`@1o9js%Vcv&RfRtvWF&am&%XE*5Du1BykOY zw%#}?ABDfW`lXWw&5^0nuk%qZET9Ts@AU9iO>lIFl1on-#`L!#7ECjT z;-&vKWPAPPdn6b#wo7&u!?Q12SfytvyJ0+Y^D7_{#3TA_#2i+XCgf<;^vo6;xpc3G zmOHo_`o-9JM8;Dlsebig(r=U}^Juj3J?{z(}a+A z9SS%FU(Gg_lygk68F7+wNV~Wo zXJ&6c9C}ekXaQ-G2lw(^XDPyIm(WFPp2i}sKG^t z81+QEZvk1;p>MScHMkG`=XwKjUnQ?0JR-*S68)#>Kwg(dl5deaLFzAIpO6_Ad?>Lgr{}OiooZy-TFS zEEjke$af2O65knRbzgB}iIZf(yO~Pj^gK^2q(Bxf`JoikS7Gwbs>~kgi8p$4^r)JZ zdLimr2TT@Lciipqr_JF3>h_uZu7#f#FkAJ;WM+&{7@d zI$|P580TdA2f9BSGazr*1Nha#_gR`50d}K)`BS9$uV_0w<(&@Y>8EW-Me(srJgktD#^Pc;CjvfVyh=giw2@V?R|$te2U&CzFUAA3A^H>z)0S>79f zTUKs9a;|!!yZ5+fCX>@A<0d9Lc6wX|@MlrZH(qrcV^vydgSCD;mtUhSDdZrNtWc%}cB`Bo;LqIN%XrGhF=luF){azDf$|ASmN(@huhKTckepsE zXNH2i&{-0e+WE6stAoHXDDjqj5Bxv9>yF)*O%}dSUhe0qa&_*2cR@Q>PMP ztCWW+wV_+n#QQbm=DNzOt=>&G$2TcZKPE`bJvlz+Mi|I6Y7O`&Wi=*=2z%|U@I#y2 zg5taYF)y<5k;^6et0WXzwQvR8GDx?`4PoL-PEibe`hRXqr}d zbpM*SNK^4kR<0u*b&-wJnB@H-Lha#P7u&^jnS%g-x9j*VX%BcObll{@r(XHMUh!2S z5ayFlx-`bat?b^4_*S&(Don^FfyXNNuXbWLH3ObtU5XaJnNUaV2-5Q}a3;SXrU+9H zu={n6_UWgz$OYb^l3-0M0k$da@i*f52W_XG8(8DavZB$LyT(6=P3jopzcVk6X#qat zIz*zHT{Rtgw&=fI{z~*9dRIu@AHF3ijkwD~LkQ)>9XGb*te#1!qt)i5n0aRgKwexy z`??157bjqmZiXDt{)Wku8MXxP{Pv*D$so8g$s6jp%9ELL5{h zRb1$|d26+ntbbDX6)eFqt%T^J;%O3FWNwq-FO&1J&L8Hdh3jx2DDAU1A7LCQo`&pQ z9}`#ZyN1OL9TXoVz$@O=;zweYX+VqzDDWS*UpI<#lZ+Z?XvvJhaUd;P=*0N~L800Eklq1(T1 zWlsUyI_U;YDm08t)u2_Cbaf5&aa61sHDk`_sz-qYVCD}5`Qt$Ty zEOQB}F?oY-gqBc@e_eX+q;iG@ww+QA3})^rhIy!>`yHi`t}g{CS~+$I(8^k&7;JwM zX=+}kK*3Im+rKLqBFxj$oHT#VgWK>0KLHtK+f$)tOuc1ufwB<~Lyyz>0C2a?QM(1v zuzk-o(VeOC25b8pUF%d5v#E-;e?LReq_-P~Wp=p{xNu(2WmN--ur2*-*wzb(B;YiyA*vRk6+ah-A?;1Mt6 zM89AYd>m9u`K&E}9day_FH`}(?>XKX^` zn0C>i54IeX4Y#B;7;vvuT3|?7>Xc8$&&T>hBd6ECMl>NA0D&V&SXojN$kqA0y@lro zVlaN8v=PRir!>hTGhsZ5AcgWdvX!BEGM9mi_PZ8}42fbXKvxNJ!Z@1na=FzfHCRA8i zDJQcNIL#tAGUt}Hn5@{@Bf~KKJwQ{5O52Z|V288$1LkC(oCaFkdlsQ4BM52E-#S88 zRu5?5r)5og^@_GZR&+|PNzFun9of0p0hsS35@u1Rr= z)BW54?f4NE5cbG;RtMlT_|l-;2A47JkVk@p2Ox>|MXG1%IS}hw%`Y-OeI&mlX%FH` zf^&4`Y3sCeWzRaMasZS@@-ESR7M_a|f&$#+9OF`}$PPLw7`-{Uo+I8~P^Lxne#(>; zMBeG>qGpqgV)k>sqf3#jgf=bUR(n=jW!~HmRThGF9|rAAty|n)EJ`&uFP%)^ko(@u zd;G%h!JU++z&IBY9OO;xA2)!(Z*jp8Yh1?B73+UvZZF{tyro9exAMGsL=0Kh$NSjX z<6vg;M0^CCIoF~>$ivRtx{Q+)9xUIG?qPm3;BBA>ruEI+q;UoD3{e@)m3~UYiwHlq zz0bYiEhXt)a75m&-Y$2J>bG4pfoJINq=$y_47)`u$WwNf2@=pI=3n9wdD(D|-S5Zk zOr@kk75+>g(;fj`(9u!&jE8?)2sMdUIGiKs6?D+2P1#Lcr51Tm`{@4WG?T|f@7#Cf zCxAcdiGf?OzB-up)c$@~{Q$}GYAn^eBYxJ}Iy2dY0d)FR76^KK`m!aIdmVtNUS{hf zSvFr=3zqLG4n32Gh^O~*z^T}h<2Vk>)n*rm!aa#Rdl_B9md5UZkV;^=1VR;&uwx-E znu^)-`9+C|-A5Z$w0l&BQjTbV?icQC=rpB0TFxv8p5$&^$-9}}c*O~Sj$LJyqZnv$ zIV`6{0ekc~Kdoix%|R_TF9`s)bF}9M=^U3`M`TW0u&LD?Ko~R74|2Q*`95kvz^EZ@ zWxrDgj{_Mv-zbhU9f4fGhbm!i2UHN(X>CfjQ4h%fB;w&FSMZe4wyvl-m~G{C92?iw z&{pk!8VGGHK9sY5aDkw8fqwU{Ha0-a$YG!kP)KL;e)j>GQS^oc*CrUz{P;-<_)`6j zyVV8HelyvMXp0xg2=BE8I_<{eHe9sUmIx?p4PrxSq=pFViCFMRaAc9|kcTGPRnZI^ zuy-Vc_lokbwkz}^iK}Ve9)TREy$1pVb6l*Nxv#AB=va>Epo#3GL$qE96!*e8l-C9_#pSN5k#=RF73&BW#*qwgVCitZ>L_f z{R{@WW;g$Uo`%x}meBs|-43wB!ATzAHT2brEqPW{&L#C`=68$xf~95b^pOKf(UHm4 zU%E?N79`|pA9xisj=$K_mV8scOAT<*497{5?<|G7{hKA=)91n{#l&XTDMC|1)&4(3 zdX~%jp|Aikl`)xxm5ayite*s1Z^-~Er372L*khdxe)-$+XTY;7x}6GRgqx6S4P-35 zes!hpee0yI;=AHqtF6;_r1>$gB7kq4wgfHKTk*%S<%97j9ws;N-{R$bRio`YrDXm# z2#B4e&Un~9`f#z--%G9Yv8;-=_m->s``B|#EtX`toD`?satyFL%a(DIvJEYHdHnl) zw497>tgrn`PLB;g>z()689(vBWiK<7#T%0{q@`lu4nps0r z^SKWpwCq7?LrtgL@JG(Gb(o|FW!hsCz;}E_c?#cLhxMQuaFp^LenA56SQtqa!Fl|U zRqP&f^2xb5kyVAUjlQ2FO_Hu;(G&@M#OWVeEw`Vr9T9Nxa)g!?w}2}rU0clGgZzlk zcIE+25+*r`*h@nLu1%L|1ldwoPbxUgs~48N*zP%g6`~HJfyb}kGA9eL z#BVxD2FTbaXx-pF#J<^5t7Erc&DI9sSbW;XV(&9nMa}*3R^Kbx?hG=>j(6#GaTJZVy>mDI;L| zNCw{Oxy`Za5_7$q7X5ZhZ&xoGv;TAdh_PKyaUm0_PqoCtF8m2`f`Uo%pLHCcP9BL;63&>&0WC{>=-%8k2esM_wGp)+?NXrhISSLhluj4zY*Kv1TX!cI2B6U9DiBvwjS!6vDqS>KBV9F z{(4@!v01p~FY;y6c9E(4$hoOt;9W_~=7^;-5^~+`qA#>2#}yM`&D%Pw@@(nP4nTCDs37rg!V2Dxl;c(dx;jDxbAy#tHu554*D5! x-3tTsKAifm-3M3%{;xv_;G+Nk5i%n$mB%b=8~Q27?q849Qqxndd2Ac@e*peo2b%x@ literal 0 HcmV?d00001 diff --git a/icons/icon_left_brand.svg b/icons/icon_left_brand.svg new file mode 100644 index 00000000..ef81a6e4 --- /dev/null +++ b/icons/icon_left_brand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/icon_top_brand.png b/icons/icon_top_brand.png new file mode 100644 index 0000000000000000000000000000000000000000..c46acec066bcf08a4f14aff9b1630a3d01fd5477 GIT binary patch literal 14971 zcmd732T)UA_cnS$Xi}mm(#sDOrHFv^(1KJcQk5n}P(UFR2_@7ZA_@p7AP52h1XQ~8 zjs;K<2wggYKtdG=y?q~J9F>-=9|$8=VYCI*4lgRwVw6tm1hPwG#TkQ z=pYDUyso8Y1VL~j1W}x#r3QCSpH;g6K2Er6nR`MI{psWXu%5A?eF*vox~`^T;`?T4 zO#kU!()cBEZfF|2=y-+&+v-H-e1oWL)w8y7L7{}_v_%|7Si|bNns=9z!Z=ATx1Wj` z4MmC3YCA4CJPv6amF=8&xbxKYtpp7f>ov!gS#|8jtwIeC@q`~uvgZufFh^6jOf($s z-Tk_={>D>@m$v5Fx8*3=OKWm-d;2?z>Gi=!N-&_~5{KL&_#i03xWNbAKX^CUQ17eMabp#KZ<9o8=dqm$j6Z0F>jcRTP9ux^?_jY5vf16N{ zV{iz<8J&>*_b=Sp9xJQ`2^Pc2;ozR(`uA-QZIZIK;DNJXcJSp&QYSydt%i)fMkDPb zv^J?IDyLM~h6=aLM1Ub3fTsQNJwe(eUY}*0{C>J%_H;L9lXv{^D#U*p2-pGvdco|Y zb2&wvf;0mgN;#j7{If`l!I(a%?IIq|e0d<@h(@D5gzmTD#?e~M zo4g{tN!|AkYvNv)M(D=O(I0oawwigySWWlfgML>M4o+;|9RE+d%T z=yc_ z^7$-h@N|^r;L7kt$ikMA8-J(d-f*xzQr@6gVd=o8Qi>B*8ij{TIsudBz-##5z{A?n zeG?JV^tdN%Dl|^0e5s@KB}W9tU|jhiyi$|XvFjD=7=I=$wqx5FuR$~OSxm#I@uOq=sX2;PPC<5E;O2+qt zwFD0`?CHsUM`=d8iTOG-?_S{$1{t(-!7q(=!=~xTqZ3{Jx%4Eczl53_f9|uKm{@he zUYye&^bQ9EVZXT81o*h|*C>!puO55aPCmxqB4~+_<*80s(7Cs86BLT(XRJHo4-+4^ ztqu^HJ!JjGHF>`AX$2FG5eRZ7fmKO1+NF=?Zi;9;g}wltynu%*5CX0q78z*=6RTjO zq?1_%l#?(@42}i}(+FlOd;frzH(I+LD2j8s#v6lI%wYLRLIYUPmmud;1lWLBdQ_K@b|WSq;#o}WenR1X62?8r^RJb5 zOg|Kalzy3)WkUgvq=-;D5a!K|Kdg-JNw43_h0MMjr5Nqz0yjtqrE-@)4 zJ$h1l(trysA!c@bB}5I6*BoJB04~bV6L6I$H$LdsBAj`SwOa$u5Djkj+dQ0wMQpU* zU=&jW511TfN}M#vSAO^a$9I|#lHoV?1J10&$;j~XSeCU@0aa4g-SiL8X7b0S=?U2d z=w9zFxGHc!)w1b)IJ3Sbfgx2>7#SjP z2oSrefS*DT9-=^k3uds3AP7eR_6r0VK>sbcQ)0k90k9YU|9j|9bsU813Y19hjGo_p z3bx7RY_iKtSoqEZ$<);TKn@UIT(wEz39LM`BOno&ZTZ)C1YA|p5)AZd*MSK1AYlD3 zO;KH+d$an|H5bnu7aQ~68Jdhb8SOdF+d1<`cCHEk|H6D+S1KjM7Ud(Dy)L4> zIFjH9;k3bUVy8gbIG0_v5CBKL*7f-RXp)cT3$m00_Ls~YP20_|L$>ZLy(IuDm(<_tXihe>-I7cw-v3noPd|^ z$1DiAdR1)CIU)Tk*S%FP7iM?(H!JGfEX$f*fRD3XhH)d$#YnbVPg`+|AH}zd zFCBby0=q<&AFuk$4!NfuC7Eae6$;t3I_=(2Rsz=L>!ZRSHNV@5DJj}E_}xwBor_xy zS-A)v$&8R^7O2pZ=Qa~-B-N@X?1Jp*I&%|(i0tQy21&=0>f8~|)ybNc8{esmrGac| zDKP?Cy7!Nkpn+uNuf}}b5RUH@A>&j@;IQ!A^sN{>tKCDiC zFYMfX0}_%4&A2cGDLiGuIK6OhcrhqG0u1s~M}@zlowLO9y@f*r846?>O19{6ECNCm z(Gm$_zv_&x0qg4d@T%+4Ve8r_NDxGRghMb!{IfGEUNlN?Sqy^+UKQq{Pt7^kEsfP-u1}n?uV#Dg2FKVovi`CnALdsiW zHxMP!O2IkxzVWAjdTIsBBzN90wT3~s&nJimrL?8|R4wghG{FkVovzU@i}(*~OMSW2 zXuhiONl-?E30-zV)|z3d0P1vLME0#z#WKwT=P}J9xj0Iua-%sq-QvFdaYD8mZy&vg z@2RZZ*NWqri4G;Qfj4x58Pr(+?puV*beBsy5~3l7>)KDKAr8Rq6gz5&@4==vF7O;r zL+W|Fs@^%S3iIN{s~SH%#e!?s9z0)L;nZ`i70gZ)%wEV*_Wfo##9eT_gThV|GL$sM zUyhV(SqCkKVPeYn3*O0V}lXQc8U`Qm|i+!CK1K z<)l3l+r8$u4N|-#0D}SaE9;HGf4kdCLDZ&&aSJhLmC-P|9w|cwOpi8VRlIb94oO^}l9-RFH8_Cu>e`ry}xKKt^-yp<9sx0QSfC zku;31dEQr(I}xU7OG&S;?J8HVG5k#!;q!|!n9HJ^JnxHcMxF;wW5BFFg{J_s+?Y{5 zHz=50UxyxQ6Xs^=A2a?EHa!c#XNr+u;~HJydSz<&__n{QG6etq1|E72uPT+yp*X>- z=jaAr60RU@3&KoUhJKvmDejmYiG;BX`NO_y4zA;jdz5{!@fI{(4bO)-tl%6Unrsy;W>V&ye;uaP3GOVr0a5l8t8* zJ+@;7HjTP!E9Ms|;Hq>7*lPnPh{a(^JXSs)Re2!Ql&!hMJkKdDjqj0R4Ep@#XDbf` zS)9jz@lIwVEgkf2j;Q6T8IKgr94yfEp8%T@ONse;V+?k1RUIsKk=GgmI54(%*XGdB^FnYCC6A<`zvkJO&dxUzCY*MH5=zKHg^_+T>0@| zX6MroV7WLz}CTDc@tOuTaY*?brj8N=mu8e!6p0`hxyx~g?@V+b|3uDEPQ=_Yy|LD zY2&$fADGBeV1B3T_^mn`H2?^~Gf^N3US2Vo%BlJ-Sn?ZlJ zjhDWMDJuXuK(zSv@W8V?wnH)CE}|f#&DwZY9DB71$cP<%a+yMSOvfE*CqSrsaHU~ON?iq)N<)LMtlL$T^2mX z95z+>Kp|*PxQk)L10MFvNHrBKU-30m_)?!-L_<;XUYFg2z0w5M54jKJA*g_fa7W(j zNwwxc<`Hw}y2+8LuxQ^GqTGMJG2P|ClzdE5A(%*Q#C2nCIsQ_HpzRAV?g}vgK(gb< z`+ONZORD&a)Vmfs!K7N&J7>*7P#MS`OYG`JpFrjg*}80l1R_1{W>k4rL?W zGPZ!l&=U+CB+PY;eE3%+W;{c8@8GuU}CLwjMJD(qy`7c6$3= z$V7KdHuz@ICoQl@j0=LYL42dgB3%w|%S(=#DK$$R(_!lD;t--soC;v9(N=<>djM)6 z_44fxujJsi+Kgm_hbQNTP1z6-?j`W%gJV^-B&U~SBc^_&VLOIZ*i^}NymBnt)Km$0e3J$$}??k%g;n4-_Q zGyMDbUuQT4<9=}Je0d@x&xpE~=+(y>b1FPiM}9NS4vQ8(z+Xuq?R2Sp zX_b!AKJr##=S{piqo~ob?(&G&(#);Fb_}d#D!^@ecIitima+|^4`CH%3uDRK2XbtU z4_47{%#H^!4OrGyDL~jStxrEW>B*xP%+0qWhNR~x*-jM+m`%CjmePExcPY%9nA7Ph zaspb|UOd>_LNNCMLIUtXK~6ht>ACUIpxRw+6F&P?aAS|@=?G$0FygafHF^xoHwVXU z1oYp%B!L=2v%c@tw$-iF#yawnIv zs#K8_o0MnF6@sp&M=7snrT$qn{ssI+J10V3@-8TvpTBUNU;S7pwF#IoN*Ff>^PWc| z=7+EkDANH_qCiSsesq^}FKg>2Vy<9$2LQyIz|*LJr@3i*PyKY$lLad;Ly2*^PN~Be z==mAUb3I6ck&nsG14WOAIL2tYTEWGMC2If1Zxm`;rn1XH6-L0aN`Mg6qUc5*EqQ}1 zD;fqk!(9-#9;rbGLKK3^wYq{!Ej(*LHA@O4vjeE0vg5taB_YN*fep9}pa}|~o+T&b z2>W&o*$sq_@xMqPaFgU%9D*v+i-c|4>&JEW-prR#wm3#yeqi{|B6?Wf&rW`aQSv!u zaOk-F#h}t=xwA+%Iq{wTzMX6Dc1(99l6w$PlfhqjeHSa4y42$B5}34^me(K5nKkPu zTtopi;Q;DX!OB+vDExL><>11joeV=@15m8jm2#s{H!%2&`sAi(`EG(K46?<8!Xkur zzP3c;;6hGx;>E@yo92Q05lx`xU`z+G7;}qq;$Z&}07Pr;`>VcvJ&tX?E9>6(TXRZ4 z+{*=;7z1-9v*UT?^5?oYny1Y>(zmY$t-OEn5Q5CoaR~USvpvB!{E8I*`*~S9tcll^afkbHzHjuK|gk*xDnH~*z=rMD1Z}$SK`row9KLEUdn%~or7cV>a zACl;`U_3O_Ee#I^6t1fGJRp$%!zm4bw>onb1;C*QkF#irzswMU{XI+|T$-It61=Ep zI)fL&J#vZ*Q8@!a8iJzISCNS6=o!bS$j~~YbYPgm@gh+e?S_y zc)+WAWx8)s`~f^!6SbYFjrufnEA{6Jm#qZ2@dRkz5R3TpBF>2u_7hNK#bH%UH;1_Y z$@cukWh)OPHoe0zwv!Jzt7OQbP%(%awmKzYv9Sn-o34A_=v?aJW15^ohi@zyW2<%J zu_tamVA+hq+ra4~&)_U&!T&hZovu?`6bM z5g35lDj=?2&MwPplQ*au)mTdEK@cwQfun}YInQ_i80n4gG1Ml#_k@_Iu2$-Izotcf z0V%fZ!UPnPQ>u`B_*{Dh)&7u^tmhgB?A`?HAOfmvO+(UHnGc?ELNjdhye2Q+iUNs( zW%lf;Xp>9@v(L;?I-y*hgAzt~&JM%cfIl%G=VNX?$Gt~LgDiEii7s?Bobm6$3QFeD zZsou`xMfXrg_;l}_s6y4?8N;>G<}cu3W$>^flMJzSRSOiak?}n@C^u7oI*$p+ zWAu5rgE><@IT3LP}vWq|A)NorV3ynsqZPVD_<8}0(_B5^@c>2x&R&~fngLE?P%4?4M-^%NZ6NkY%<IBBB**7V5EMx8M!D^6 z^}>${b^;ArUp!bcJ<8`+S{Th=@f(pFb7tD7NXLotH`pN&x5^GSAV!3xySd`j7wMi7ij*kO~aR;}H z3qEzN7j%1mIQMskfX9G3&YN>r0f0zz%iehs-(SfDw(cPFb;p{hZohY+z6DbZlC6An ztaU49tFALpMq?r4>R*d>n0kEd%W8$N9Xo06H^9jjP`z>uv(=5Mhzs3#l|{nFY={=f z<=*%!?a!2-{x-e=+w{~ALB1rIeO&YwK3M8o^3-Gl9{VWiRZu+dzjjwA_t}Z{E~~E% z^8~hImDt|UE;z2@&i!{@{CL#=>*Ry{QEL>G2B$3rMz5=D;ATe$uQuUa&vO4PwPqSRIXBgJIRvR&x^!<>W8)dYn^A;>cX9 zuyWPz#Mr>a^y(9$?VyNz=2;`#YNPObxV9M6P&5@F%Hv3}U#yl=QcF&4i_zTTrbJT{ zOjGHI9`?n&j61yvygz^R`YPD$D(cf!_5gl`@3qu!82igT#2%~;AGNg`eM`aLu?JlyBe5V7}Luiv{7oa${Um+NL~ z>y5jOT^fGOyS!8KA>iiJhVOgNYP4nw<}{1^Bc5QBJ;w2>3I2&U0jbAJuHecbbN^|Y zq*rf!(z&WNmpyMz1?tFYgUpLDs&TAQ7aONjhG!2_-LPkR=smiRGo}-1{rAM)x#4M5 zm3j`5C%ImV*2t|vapdq2;buu@k>KhWvd_UKpX|IaO*d|1HTU*Am!U3-x28&8P8c7$ zGot7ChnbadK8csbFEsY(TY59?;GHvB_aXkLJUd?~kb10Zd)s`jH76;{JHBGa+{6{o zQ{B%A<@a0Vq=f!SnkXA}Sj_W;4$erm-JqwPyEO9SgMdRyxk9!pIHqk`^UC-YU>+1) z1d1B<(NTzO)bKq`zok{&sxaQY;c{axZ`O3Y)Qs~Wh@p||!`bdZy`@z=jn5H57O&i5 zr&m^e!h$N-d}e)y8HkCrbBed}rg{9Wry{{`(H6yU(v0)RxjkK6RV_ z7?EsWKurd18>bktgKnqSIu=H%#9$Z;MDh#IGtT5z+iV5Aerx{FE?z3x)FONm7z8ZMGDe|?RZJu%K`RfO&jvSuNQ+r-a zBZSTGwj&J0_fI42jCs`W-Erm%v1?|YoY?f8U!QcXO5DUP zty`>zTj<&=%@2%R(GO@G-}JrpBo+}CnJvZtyDibnWqnu+74yXPUHx`Z7zVG_ExmP5 zdoQ<+OKCj?Dz==4e2rzf za>?r~{}xP0`fbII*F++>I-c0>H;Gh?u@Jwu`-SoT#k(uViod(`@K%%8@?Onn><4)~ z{T#0c7c|^IazBr%pm`x8_Zre!Y=nZ2DL68h>hbz9kGD{GvYd)+Hpd@NVGxlUIO?q< zJCPLI8Tio9-f}~wJu~¬=RcAh?%Lw4m@Cy-Y6ncnKQY`k-&=dScGpOytun%kZYa2O@ur zav#ynkUJuk1_ba|8#oF0W4YFEJ0oCB6LT;oVy}>bKxH+)yCxZ7YFKYtf`MH9?yyrz zX5n{*i%+t(>%Kb{aA~EsNV#p%0c5!S~!Qh42=bN8jW+kE@ zuP?4AIPwTwv5Rno2vYu5H9^gK?Rg2TZ7(8;zwhHBIg|IhQk*x_zMh|uU;LU?_I@|U z?$x6g7}t4|6qwbS!Rk_qLVCG3m7A3fUZ@zafSh3%K`Qlxtc$)82@iJN!r zb!yGM>ixYFl_e$1dw6_iW^JVvT-)ed-{ykZ8DGqxT+y}t#+Y;YE1ENl6_0kad_}FU z2$)6W$-eUO_8skYSY$Qv&miF%Naf}4r#ccXmm|8JyjSbG;G-Lq= zQ;Uq`fL>p-W+$!N0?~Af-Qnk&^#((aHk0#{Mqj+>d3k_2U(w+ab>F_#qc_k5<%VU= zsjgJ)*d3_}-9f)<5T;@38`&lI~HMmT}QnOb+S?`OK1@aC!rIa)S6&eS9 z4wZfyosbW)dSycSFhyvYe~IRG=uTo5DgV{`h-!}?3j~0dcy0!>vvT41b^t@SWYXW# zqPG?2)HqURye8*TEwO*Kupk_LV?EkyQeGs;bTPR8^~yRn?}3QpkEbr{{>8RBciw3& zLuhxF8LKPy^5u38+OEt5dJe@d{c+2^2FW6bLbB*7#M4b!8i%f3+^Dt&NQKL76r#8yFVjz~qx#5bbwbP9kG9x7n z_~)cLQ5}Joo+(wEhe+2C$#U3; zuMCnqmaQX0FK7_%s2b`|dBIxUioUw1Npq>V`IGnWk@2h<@uXYO*_vp2Q~Ol>YV9yb zxyTi~CYy_2f%EFG06w4_f~4LlB%qZgQwvZDsPnN}Ck9_9^0Ue;TA)NXXZdn0PmoZ& zi_JG!o=0rfKW8dAJ&2d#R%lq!mbG~*_Vc&m_w^+8Zy(%-tA?HrF{6LjNv2Tn2zJ^j2ERmb^qBX@Bw#NvQQCo9U~z0lRhlMJ!oL+pOw$of0s>p@!@T}XKb(B zFa62Xl%vhAzSL*3z#)n4kaw|{Q)S%btXWp7XdcjvrrSA`RD5K!HV|cy+{^Od!zgAv zTcO$;6+>^-+j|;0RLUEsL;eW2dT4F%Dc+90vQH$ZQpW4N=g?Z;?>03pm-*7FhNH!r zFx!B{oRO%CFVvXNpwi-A?y?9fO0;T^JISu%HXZ6!;ix>&TyKtr;dS|!qCINXe!ELz z3zjUY7PFpcbj#OYz+cluyZO|fqJnqae*8!cBzouNoXV(zuc{P&y=R4s{0ODNlaFAS zrh6eH&&v#7S5xYRHNJGvH96k$INaf`c4sC>td%^wUoC^wTLF|Wff#c5%U3w8sr*CUKc+rYNI8nWfrKlnY`pWEq<;p?p+t93=WeMH^1sT^cqt589` zE~EjJ*_*0Q_xEO>gah6-1K{b$xgxxvoyFgHpUii2l(T#Kz>Nxnm&}flWqjY1)4jgT z{##Bs;O(%By3>Fm?ALys{x5}sGX%~n7kWnm-Dr?jX9O%sus|*2TT6LkiZ%>?By0*5 zj9DZe;mu<#mBteTM{j18F_9Z4q7s7cz@MlaWa}~{b?|ZkKT>-duM(Jkjv7!8 zwn#OdNPV~ql0v$v%3M$k8Y-Ql0o}{ADhZb2>=tIBRORk=O7GO^{COz zme*WOkXVPYl^u6HfwMjh|Lu3#_9e6={k1)FZ28|c04o$+v&PWZ`usdb6VchV5LB5( z`oX{h36(SVbkWh0YOPL^vTu3>4-8)OSj`T*2u^dj^+t6S+Op_{yFh>In(k22^0>pV zs$8ZX`P54*j6@MKiY)bQxj;ceSMQ)($r78x8QBH{_fLK}65W*(wftHwH))ZNb#CfVZAnD}A;k3z#CyzKr>c6_1z?X}o1sV@-qv zv#5fSl?~7ZNXW1R0YucqlLmhRlzxr4*uybELD*m`3>KpCxhx>E))oizX>{qi9ht>V zWSy4BCMQn?m9H@vlnLbb#&?dsg9zxb$K*k)6C{2ba;sHJw0op#)?9(#?MVScW7rr` z4%3_RWPBsU5Cd_0ag>^PagnJtTh$ZIr$*PEMK}v* zMe(XKQvDiA^Tze8P#a5a!XnG59(^6r;X065HuCj&37S@K=6Q@qMu-Gr#Ki^nc*%t9 z@9ccwu+cbfFq&2I!xjbV7u^YxoK0E+R}b&iM`;#x?Vx976*NKJlmED~b4kkl`@#Ja zGI;Sbznk0WH}3o8P#A=;Aiu<98M8wVSos2re!*%}A`qmLj70Qvy5O+{^@9@EhVTCj zsH=`v6p^;TX>d*6_Zy_vK*2VP-FS5T2UP#YD##ed2i2k zhRXE;QeoDdt)M_XCZ*+~Jh~8FLY?Y8{70{S9kkXA#q!9QR!?uWf@0HQSP8Xb$GK9W zy4%ZkT~z3;GKNyIe6-ivMJr}&!w(GA+d^Kr<@asMNBTg<-cKH5rhDY<*J5EEGhUtP zq4R71zQP7*jr{^zD~Ua6m5?ekU;R==O;zmSBu)u&6jlyif2@gInrmHYK2k$?z=m(~Hls)%ey z;z%3i8Ne7Qo18j{R}hCry|qLgst0pm2k&M!C~pV zsqk?@SDJ=lQp=SJY;C@*hw@Ktw5ND#cEOFbIj^yRmaXZ`X6-|bu8d2Wr3KFo$z=h7 zR@iJRar-3!jgEBmDuPGd;7DHFc=CIjK17J<#=I($LtAaJSeMl|Z{?gPzK=B|hYkBT zCqBZa{K|71W{8VS>B+j`)q=|Y2+q(0GsnZ)s-%g^@#h-}V)WAs z=mv?M#%Iwfjm9XEM|nxFA+lP&x|@I7#2Rw>;G#o*v&rT(?QGG5hRrLFYQWDw=uYt+ z=(VrBE&u!*oPV!F*n%biAe^vY|4574`CW?$9O<(Ay$7O2ZMm%ZsQYihF8!(WTB@Kz z>+iiXfKj^1r$gl$rWmOHZEm%q!1 zxY@OI-pBlO6ZyPqtuVZuA1N}tCN=q|aE}V&C<(Ub?2!JwFlHARU|&H;yrVquIAMP1 zVQd7XA*Vf>xojTtYfot~?1vU4^a}1yo;orhlmzm`1gt+~Pfd19^d?qS30G+z#H6H7 z@t0Re;)BkrSj}BReVH1rv_xrjwcimZw<`0|t z#2X-m+m`n!KzH68Wu_oxCY97%YH1bK8$7rNQ$*apeshfU>fGk=5Z)k@X;!g z1iI}FoUm}Y_A?+!rULfp4u_s24f3n=dV$v*bT49a=U%(pGO4GKKOs*6AFdeY;`KL7p}!In(|GYP+Sal@5C7FtY=v35nWM^dtq)#w>poZZ9&R zK_Q5ozwMmoaXOU;VlIrGu@;@DC3!u^=SjCqZ<=@=U2r}hl1@BT6y=T}h=*O?`k?T3 zkcD6@*S|?jy0$5Qx&K-Di>8mG4uBfxcJ7h6_y&$KDVyMbOm2%9?B^i#hQ}V>ovhy* z9Jr;d8{oF6yeBU6qA8WG`Z_6jr92NByQTGFZ3Vy({=v9mxZhQ(m8(br-RnX4cLw9$ zu%||}b4~S`sRY-;xqG@Xl=wYu0i(m3h}dy0ls<1bnQvoxF71s~bDgxQRzgd~iMgiX z%(4Bps~Pe$(`(r?*PsJK0?r*lvh1L6y$mLG;rBzrw)af);rHIz$Ln-o_4*Rw^+U{^ zAvBTfTKbC-CFgi!F43VW$X!NSZt@ab0NSS}I1ctXp=_QiYU~ZyrYrXr>80ci7=8N0 zClco?_y(R5abtDtUC32(H#z;mC!#D3vGUrrUGTfLp&Y*Y1YDZZ@>p=`nw`%~+Dcb=vn{y}G_%lY9Q!MCqA9L?mqwhC=Cn>z>*Qe_@^7U3Mh{!>SiMs7h1yZ-u?!8eho}{1?ef(Y4k&3-G z-gWl-#)Wpz2ysaTG)<7YmRs#lGRRIg@^4w0z&gI@{z5x%MQas_y4P37uaiY;oOd^{ z8P!^@dO?}|GLi5b;*_}(D0soAf%%6B69qn=JA;S0C-MZ5UYya5sir~HUF|?2qUnT5 zSk-DON!!5Z{E4zFk9J)b<2`ylnY#?GHZ(>dJeLF^UO|DkMK^Pg88kYktR5Ob>x)UJ zb-@i3GtEqBNm7f66zeW7Xu^v-bezs4OE9PV} zk|p%WKT4XEYjxi4XrOtzyX|D9n}?p#&G?N=!%-)>B2Oki7}R!E4j-;)=c@e1r$|?U zt~)S43sA&iVCwhdA3Fxkd^ZAU`;y0(SpU8B!F}m5h>Qww^iFJ$(VK9YrCz(-pc#4+ zhN=Xo8)HuLK9?w)lbw%#31u+dR;jGRn6+NpYU zGJty`wb_UG`7|xw?SYFbYp1>1tlfK&n{!mSPecr=Q7v~_q8OB7=PKR6iXTO*)Hw6ekhNH9vv&l#B=*%}a+4iR+Ks5ke NSHGcFc+K|F{{TZTx=8>4 literal 0 HcmV?d00001 diff --git a/icons/icon_top_brand.svg b/icons/icon_top_brand.svg new file mode 100644 index 00000000..c839ddab --- /dev/null +++ b/icons/icon_top_brand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lifecycle/bootstrap.sh b/lifecycle/bootstrap.sh index f36e44e3..3d87ac0c 100755 --- a/lifecycle/bootstrap.sh +++ b/lifecycle/bootstrap.sh @@ -2,9 +2,9 @@ python -m lifecycle.wait_for_db printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap", "command": "%s"}\n' "$@" > /dev/stderr if [[ "$1" == "server" ]]; then - gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application + gunicorn -c /lifecycle/gunicorn.conf.py authentik.root.asgi:application elif [[ "$1" == "worker" ]]; then - celery -A passbook.root.celery worker --autoscale 3,1 -E -B -s /tmp/celerybeat-schedule -Q passbook,passbook_scheduled + celery -A authentik.root.celery worker --autoscale 3,1 -E -B -s /tmp/celerybeat-schedule -Q authentik,authentik_scheduled elif [[ "$1" == "migrate" ]]; then # Run system migrations first, run normal migrations after python -m lifecycle.migrate diff --git a/lifecycle/gunicorn.conf.py b/lifecycle/gunicorn.conf.py index f9ba639f..52a5769f 100644 --- a/lifecycle/gunicorn.conf.py +++ b/lifecycle/gunicorn.conf.py @@ -8,14 +8,14 @@ import structlog bind = "0.0.0.0:8000" -user = "passbook" -group = "passbook" +user = "authentik" +group = "authentik" worker_class = "uvicorn.workers.UvicornWorker" # Docker containers don't have /tmp as tmpfs worker_tmp_dir = "/dev/shm" -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") logconfig_dict = { "version": 1, diff --git a/lifecycle/migrate.py b/lifecycle/migrate.py index fe29e16e..89e917f4 100755 --- a/lifecycle/migrate.py +++ b/lifecycle/migrate.py @@ -8,7 +8,7 @@ from typing import Any from psycopg2 import connect from structlog import get_logger -from passbook.lib.config import CONFIG +from authentik.lib.config import CONFIG LOGGER = get_logger() diff --git a/lifecycle/system_migrations/to_0_10.py b/lifecycle/system_migrations/to_0_10.py index 4e941f31..7ea1e5d1 100644 --- a/lifecycle/system_migrations/to_0_10.py +++ b/lifecycle/system_migrations/to_0_10.py @@ -1,34 +1,37 @@ +# flake8: noqa from os import system from lifecycle.migrate import BaseMigration -SQL_STATEMENT = """delete from django_migrations where app = 'passbook_stages_prompt'; -drop table passbook_stages_prompt_prompt cascade; -drop table passbook_stages_prompt_promptstage cascade; -drop table passbook_stages_prompt_promptstage_fields; -drop table corsheaders_corsmodel cascade; -drop table oauth2_provider_accesstoken cascade; -drop table oauth2_provider_grant cascade; -drop table oauth2_provider_refreshtoken cascade; -drop table oidc_provider_client cascade; -drop table oidc_provider_client_response_types cascade; -drop table oidc_provider_code cascade; -drop table oidc_provider_responsetype cascade; -drop table oidc_provider_rsakey cascade; -drop table oidc_provider_token cascade; -drop table oidc_provider_userconsent cascade; -drop table passbook_providers_app_gw_applicationgatewayprovider cascade; -delete from django_migrations where app = 'passbook_flows' and name = '0008_default_flows'; -delete from django_migrations where app = 'passbook_flows' and name = '0009_source_flows'; -delete from django_migrations where app = 'passbook_flows' and name = '0010_provider_flows'; -delete from django_migrations where app = 'passbook_stages_password' and -name = '0002_passwordstage_change_flow';""" +SQL_STATEMENT = """ +BEGIN TRANSACTION; +DELETE FROM django_migrations WHERE app = 'passbook_stages_prompt'; +DROP TABLE passbook_stages_prompt_prompt cascade; +DROP TABLE passbook_stages_prompt_promptstage cascade; +DROP TABLE passbook_stages_prompt_promptstage_fields; +DROP TABLE corsheaders_corsmodel cascade; +DROP TABLE oauth2_provider_accesstoken cascade; +DROP TABLE oauth2_provider_grant cascade; +DROP TABLE oauth2_provider_refreshtoken cascade; +DROP TABLE oidc_provider_client cascade; +DROP TABLE oidc_provider_client_response_types cascade; +DROP TABLE oidc_provider_code cascade; +DROP TABLE oidc_provider_responsetype cascade; +DROP TABLE oidc_provider_rsakey cascade; +DROP TABLE oidc_provider_token cascade; +DROP TABLE oidc_provider_userconsent cascade; +DROP TABLE passbook_providers_app_gw_applicationgatewayprovider cascade; +DELETE FROM django_migrations WHERE app = 'passbook_flows' AND name = '0008_default_flows'; +DELETE FROM django_migrations WHERE app = 'passbook_flows' AND name = '0009_source_flows'; +DELETE FROM django_migrations WHERE app = 'passbook_flows' AND name = '0010_provider_flows'; +DELETE FROM django_migrations WHERE app = 'passbook_stages_password' AND name = '0002_passwordstage_change_flow'; +COMMIT;""" class Migration(BaseMigration): def needs_migration(self) -> bool: self.cur.execute( - "select * from information_schema.tables where table_name='oidc_provider_client'" + "select * from information_schema.tables WHERE table_name='oidc_provider_client'" ) return bool(self.cur.rowcount) diff --git a/lifecycle/system_migrations/to_0_100_authentik.py b/lifecycle/system_migrations/to_0_100_authentik.py new file mode 100644 index 00000000..b1d33b88 --- /dev/null +++ b/lifecycle/system_migrations/to_0_100_authentik.py @@ -0,0 +1,102 @@ +# flake8: noqa +from lifecycle.migrate import BaseMigration + +SQL_STATEMENT = """BEGIN TRANSACTION; +ALTER TABLE passbook_audit_event RENAME TO authentik_audit_event; +ALTER TABLE passbook_core_application RENAME TO authentik_core_application; +ALTER TABLE passbook_core_group RENAME TO authentik_core_group; +ALTER TABLE passbook_core_propertymapping RENAME TO authentik_core_propertymapping; +ALTER TABLE passbook_core_provider RENAME TO authentik_core_provider; +ALTER TABLE passbook_core_provider_property_mappings RENAME TO authentik_core_provider_property_mappings; +ALTER TABLE passbook_core_source RENAME TO authentik_core_source; +ALTER TABLE passbook_core_source_property_mappings RENAME TO authentik_core_source_property_mappings; +ALTER TABLE passbook_core_token RENAME TO authentik_core_token; +ALTER TABLE passbook_core_user RENAME TO authentik_core_user; +ALTER TABLE passbook_core_user_groups RENAME TO authentik_core_user_groups; +ALTER TABLE passbook_core_user_pb_groups RENAME TO authentik_core_user_pb_groups; +ALTER TABLE passbook_core_user_user_permissions RENAME TO authentik_core_user_user_permissions; +ALTER TABLE passbook_core_usersourceconnection RENAME TO authentik_core_usersourceconnection; +ALTER TABLE passbook_crypto_certificatekeypair RENAME TO authentik_crypto_certificatekeypair; +ALTER TABLE passbook_flows_flow RENAME TO authentik_flows_flow; +ALTER TABLE passbook_flows_flowstagebinding RENAME TO authentik_flows_flowstagebinding; +ALTER TABLE passbook_flows_stage RENAME TO authentik_flows_stage; +ALTER TABLE passbook_outposts_outpost RENAME TO authentik_outposts_outpost; +ALTER TABLE passbook_outposts_outpost_providers RENAME TO authentik_outposts_outpost_providers; +ALTER TABLE passbook_policies_dummy_dummypolicy RENAME TO authentik_policies_dummy_dummypolicy; +ALTER TABLE passbook_policies_expiry_passwordexpirypolicy RENAME TO authentik_policies_expiry_passwordexpirypolicy; +ALTER TABLE passbook_policies_expression_expressionpolicy RENAME TO authentik_policies_expression_expressionpolicy; +ALTER TABLE passbook_policies_group_membership_groupmembershippolicy RENAME TO authentik_policies_group_membership_groupmembershippolicy; +ALTER TABLE passbook_policies_hibp_haveibeenpwendpolicy RENAME TO authentik_policies_hibp_haveibeenpwendpolicy; +ALTER TABLE passbook_policies_password_passwordpolicy RENAME TO authentik_policies_password_passwordpolicy; +ALTER TABLE passbook_policies_policy RENAME TO authentik_policies_policy; +ALTER TABLE passbook_policies_policybinding RENAME TO authentik_policies_policybinding; +ALTER TABLE passbook_policies_policybindingmodel RENAME TO authentik_policies_policybindingmodel; +ALTER TABLE passbook_policies_reputation_ipreputation RENAME TO authentik_policies_reputation_ipreputation; +ALTER TABLE passbook_policies_reputation_reputationpolicy RENAME TO authentik_policies_reputation_reputationpolicy; +ALTER TABLE passbook_policies_reputation_userreputation RENAME TO authentik_policies_reputation_userreputation; +ALTER TABLE passbook_providers_oauth2_authorizationcode RENAME TO authentik_providers_oauth2_authorizationcode; +ALTER TABLE passbook_providers_oauth2_oauth2provider RENAME TO authentik_providers_oauth2_oauth2provider; +ALTER TABLE passbook_providers_oauth2_refreshtoken RENAME TO authentik_providers_oauth2_refreshtoken; +ALTER TABLE passbook_providers_oauth2_scopemapping RENAME TO authentik_providers_oauth2_scopemapping; +ALTER TABLE passbook_providers_proxy_proxyprovider RENAME TO authentik_providers_proxy_proxyprovider; +ALTER TABLE passbook_providers_saml_samlpropertymapping RENAME TO authentik_providers_saml_samlpropertymapping; +ALTER TABLE passbook_providers_saml_samlprovider RENAME TO authentik_providers_saml_samlprovider; +ALTER TABLE passbook_sources_ldap_ldappropertymapping RENAME TO authentik_sources_ldap_ldappropertymapping; +ALTER TABLE passbook_sources_ldap_ldapsource RENAME TO authentik_sources_ldap_ldapsource; +ALTER TABLE passbook_sources_oauth_oauthsource RENAME TO authentik_sources_oauth_oauthsource; +ALTER TABLE passbook_sources_oauth_useroauthsourceconnection RENAME TO authentik_sources_oauth_useroauthsourceconnection; +ALTER TABLE passbook_sources_saml_samlsource RENAME TO authentik_sources_saml_samlsource; +ALTER TABLE passbook_stages_captcha_captchastage RENAME TO authentik_stages_captcha_captchastage; +ALTER TABLE passbook_stages_consent_consentstage RENAME TO authentik_stages_consent_consentstage; +ALTER TABLE passbook_stages_consent_userconsent RENAME TO authentik_stages_consent_userconsent; +ALTER TABLE passbook_stages_dummy_dummystage RENAME TO authentik_stages_dummy_dummystage; +ALTER TABLE passbook_stages_email_emailstage RENAME TO authentik_stages_email_emailstage; +ALTER TABLE passbook_stages_identification_identificationstage RENAME TO authentik_stages_identification_identificationstage; +ALTER TABLE passbook_stages_invitation_invitation RENAME TO authentik_stages_invitation_invitation; +ALTER TABLE passbook_stages_invitation_invitationstage RENAME TO authentik_stages_invitation_invitationstage; +ALTER TABLE passbook_stages_otp_static_otpstaticstage RENAME TO authentik_stages_otp_static_otpstaticstage; +ALTER TABLE passbook_stages_otp_time_otptimestage RENAME TO authentik_stages_otp_time_otptimestage; +ALTER TABLE passbook_stages_otp_validate_otpvalidatestage RENAME TO authentik_stages_otp_validate_otpvalidatestage; +ALTER TABLE passbook_stages_password_passwordstage RENAME TO authentik_stages_password_passwordstage; +ALTER TABLE passbook_stages_prompt_prompt RENAME TO authentik_stages_prompt_prompt; +ALTER TABLE passbook_stages_prompt_promptstage RENAME TO authentik_stages_prompt_promptstage; +ALTER TABLE passbook_stages_prompt_promptstage_fields RENAME TO authentik_stages_prompt_promptstage_fields; +ALTER TABLE passbook_stages_prompt_promptstage_validation_policies RENAME TO authentik_stages_prompt_promptstage_validation_policies; +ALTER TABLE passbook_stages_user_delete_userdeletestage RENAME TO authentik_stages_user_delete_userdeletestage; +ALTER TABLE passbook_stages_user_login_userloginstage RENAME TO authentik_stages_user_login_userloginstage; +ALTER TABLE passbook_stages_user_logout_userlogoutstage RENAME TO authentik_stages_user_logout_userlogoutstage; +ALTER TABLE passbook_stages_user_write_userwritestage RENAME TO authentik_stages_user_write_userwritestage; + +ALTER SEQUENCE passbook_core_provider_id_seq RENAME TO authentik_core_provider_id_seq; +ALTER SEQUENCE passbook_core_provider_property_mappings_id_seq RENAME TO authentik_core_provider_property_mappings_id_seq; +ALTER SEQUENCE passbook_core_source_property_mappings_id_seq RENAME TO authentik_core_source_property_mappings_id_seq; +ALTER SEQUENCE passbook_core_user_groups_id_seq RENAME TO authentik_core_user_groups_id_seq; +ALTER SEQUENCE passbook_core_user_id_seq RENAME TO authentik_core_user_id_seq; +ALTER SEQUENCE passbook_core_user_pb_groups_id_seq RENAME TO authentik_core_user_pb_groups_id_seq; +ALTER SEQUENCE passbook_core_user_user_permissions_id_seq RENAME TO authentik_core_user_user_permissions_id_seq; +ALTER SEQUENCE passbook_core_usersourceconnection_id_seq RENAME TO authentik_core_usersourceconnection_id_seq; +ALTER SEQUENCE passbook_outposts_outpost_providers_id_seq RENAME TO authentik_outposts_outpost_providers_id_seq; +ALTER SEQUENCE passbook_policies_reputation_ipreputation_id_seq RENAME TO authentik_policies_reputation_ipreputation_id_seq; +ALTER SEQUENCE passbook_policies_reputation_userreputation_id_seq RENAME TO authentik_policies_reputation_userreputation_id_seq; +ALTER SEQUENCE passbook_providers_oauth2_authorizationcode_id_seq RENAME TO authentik_providers_oauth2_authorizationcode_id_seq; +ALTER SEQUENCE passbook_providers_oauth2_refreshtoken_id_seq RENAME TO authentik_providers_oauth2_refreshtoken_id_seq; +ALTER SEQUENCE passbook_stages_consent_userconsent_id_seq RENAME TO authentik_stages_consent_userconsent_id_seq; +ALTER SEQUENCE passbook_stages_prompt_promptstage_fields_id_seq RENAME TO authentik_stages_prompt_promptstage_fields_id_seq; +ALTER SEQUENCE passbook_stages_prompt_promptstage_validation_policies_id_seq RENAME TO authentik_stages_prompt_promptstage_validation_policies_id_seq; + +UPDATE django_migrations SET app = replace(app, 'passbook', 'authentik'); +UPDATE django_content_type SET app_label = replace(app_label, 'passbook', 'authentik'); + +END TRANSACTION;""" + + +class Migration(BaseMigration): + def needs_migration(self) -> bool: + self.cur.execute( + "select * from information_schema.tables where table_name = 'passbook_core_user';" + ) + return bool(self.cur.rowcount) + + def run(self): + self.cur.execute(SQL_STATEMENT) + self.con.commit() diff --git a/lifecycle/wait_for_db.py b/lifecycle/wait_for_db.py index cc6108c4..7ef55119 100755 --- a/lifecycle/wait_for_db.py +++ b/lifecycle/wait_for_db.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """This file needs to be run from the root of the project to correctly -import passbook. This is done by the dockerfile.""" +import authentik. This is done by the dockerfile.""" from json import dumps from sys import stderr from time import sleep, time @@ -9,7 +9,7 @@ from psycopg2 import OperationalError, connect from redis import Redis from redis.exceptions import RedisError -from passbook.lib.config import CONFIG +from authentik.lib.config import CONFIG def j_print(event: str, log_level: str = "info", **kwargs): diff --git a/manage.py b/manage.py index 8b16cf9a..f7fe701a 100755 --- a/manage.py +++ b/manage.py @@ -8,7 +8,7 @@ from defusedxml import defuse_stdlib defuse_stdlib() if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/passbook/__init__.py b/passbook/__init__.py deleted file mode 100644 index 8ddafcd4..00000000 --- a/passbook/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""passbook""" -__version__ = "0.12.11-stable" diff --git a/passbook/admin/api/overview.py b/passbook/admin/api/overview.py deleted file mode 100644 index 801ad842..00000000 --- a/passbook/admin/api/overview.py +++ /dev/null @@ -1,79 +0,0 @@ -"""passbook administration overview""" -from django.core.cache import cache -from drf_yasg2.utils import swagger_auto_schema -from rest_framework.fields import SerializerMethodField -from rest_framework.permissions import IsAdminUser -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import Serializer -from rest_framework.viewsets import ViewSet - -from passbook import __version__ -from passbook.admin.tasks import VERSION_CACHE_KEY, update_latest_version -from passbook.core.models import Provider -from passbook.policies.models import Policy -from passbook.root.celery import CELERY_APP - - -class AdministrationOverviewSerializer(Serializer): - """Overview View""" - - version = SerializerMethodField() - version_latest = SerializerMethodField() - worker_count = SerializerMethodField() - providers_without_application = SerializerMethodField() - policies_without_binding = SerializerMethodField() - cached_policies = SerializerMethodField() - cached_flows = SerializerMethodField() - - def get_version(self, _) -> str: - """Get current version""" - return __version__ - - def get_version_latest(self, _) -> str: - """Get latest version from cache""" - version_in_cache = cache.get(VERSION_CACHE_KEY) - if not version_in_cache: - update_latest_version.delay() - return __version__ - return version_in_cache - - def get_worker_count(self, _) -> int: - """Ping workers""" - return len(CELERY_APP.control.ping(timeout=0.5)) - - def get_providers_without_application(self, _) -> int: - """Count of providers without application""" - return len(Provider.objects.filter(application=None)) - - def get_policies_without_binding(self, _) -> int: - """Count of policies not bound or use in prompt stages""" - return len( - Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True) - ) - - def get_cached_policies(self, _) -> int: - """Get cached policy count""" - return len(cache.keys("policy_*")) - - def get_cached_flows(self, _) -> int: - """Get cached flow count""" - return len(cache.keys("flow_*")) - - def create(self, request: Request) -> Response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - - -class AdministrationOverviewViewSet(ViewSet): - """Return single instance of AdministrationOverviewSerializer""" - - permission_classes = [IsAdminUser] - - @swagger_auto_schema(responses={200: AdministrationOverviewSerializer(many=True)}) - def list(self, request: Request) -> Response: - """Return single instance of AdministrationOverviewSerializer""" - serializer = AdministrationOverviewSerializer(True) - return Response(serializer.data) diff --git a/passbook/admin/api/overview_metrics.py b/passbook/admin/api/overview_metrics.py deleted file mode 100644 index f5c91e7c..00000000 --- a/passbook/admin/api/overview_metrics.py +++ /dev/null @@ -1,79 +0,0 @@ -"""passbook administration overview""" -import time -from collections import Counter -from datetime import timedelta -from typing import Dict, List - -from django.db.models import Count, ExpressionWrapper, F -from django.db.models.fields import DurationField -from django.db.models.functions import ExtractHour -from django.http import response -from django.utils.timezone import now -from drf_yasg2.utils import swagger_auto_schema -from rest_framework.fields import SerializerMethodField -from rest_framework.permissions import IsAdminUser -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import Serializer -from rest_framework.viewsets import ViewSet - -from passbook.audit.models import Event, EventAction - - -def get_events_per_1h(**filter_kwargs) -> List[Dict[str, int]]: - """Get event count by hour in the last day, fill with zeros""" - date_from = now() - timedelta(days=1) - result = ( - Event.objects.filter(created__gte=date_from, **filter_kwargs) - .annotate( - age=ExpressionWrapper(now() - F("created"), output_field=DurationField()) - ) - .annotate(age_hours=ExtractHour("age")) - .values("age_hours") - .annotate(count=Count("pk")) - .order_by("age_hours") - ) - data = Counter({d["age_hours"]: d["count"] for d in result}) - results = [] - _now = now() - for hour in range(0, -24, -1): - results.append( - { - "x": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000, - "y": data[hour * -1], - } - ) - return results - - -class AdministrationMetricsSerializer(Serializer): - """Overview View""" - - logins_per_1h = SerializerMethodField() - logins_failed_per_1h = SerializerMethodField() - - def get_logins_per_1h(self, _): - """Get successful logins per hour for the last 24 hours""" - return get_events_per_1h(action=EventAction.LOGIN) - - def get_logins_failed_per_1h(self, _): - """Get failed logins per hour for the last 24 hours""" - return get_events_per_1h(action=EventAction.LOGIN_FAILED) - - def create(self, request: Request) -> response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - - -class AdministrationMetricsViewSet(ViewSet): - """Return single instance of AdministrationMetricsSerializer""" - - permission_classes = [IsAdminUser] - - @swagger_auto_schema(responses={200: AdministrationMetricsSerializer(many=True)}) - def list(self, request: Request) -> Response: - """Return single instance of AdministrationMetricsSerializer""" - serializer = AdministrationMetricsSerializer(True) - return Response(serializer.data) diff --git a/passbook/admin/api/tasks.py b/passbook/admin/api/tasks.py deleted file mode 100644 index 3ba020d9..00000000 --- a/passbook/admin/api/tasks.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Tasks API""" -from importlib import import_module - -from django.contrib import messages -from django.http.response import Http404 -from django.utils.translation import gettext_lazy as _ -from drf_yasg2.utils import swagger_auto_schema -from rest_framework.decorators import action -from rest_framework.fields import CharField, DateTimeField, IntegerField, ListField -from rest_framework.permissions import IsAdminUser -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import Serializer -from rest_framework.viewsets import ViewSet - -from passbook.lib.tasks import TaskInfo - - -class TaskSerializer(Serializer): - """Serialize TaskInfo and TaskResult""" - - task_name = CharField() - task_description = CharField() - task_finish_timestamp = DateTimeField(source="finish_timestamp") - - status = IntegerField(source="result.status.value") - messages = ListField(source="result.messages") - - def create(self, request: Request) -> Response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - - -class TaskViewSet(ViewSet): - """Read-only view set that returns all background tasks""" - - permission_classes = [IsAdminUser] - - @swagger_auto_schema(responses={200: TaskSerializer(many=True)}) - def list(self, request: Request) -> Response: - """List current messages and pass into Serializer""" - return Response(TaskSerializer(TaskInfo.all().values(), many=True).data) - - @action(detail=True, methods=["post"]) - # pylint: disable=invalid-name - def retry(self, request: Request, pk=None) -> Response: - """Retry task""" - task = TaskInfo.by_name(pk) - if not task: - raise Http404 - try: - task_module = import_module(task.task_call_module) - task_func = getattr(task_module, task.task_call_func) - task_func.delay(*task.task_call_args, **task.task_call_kwargs) - messages.success( - self.request, - _( - "Successfully re-scheduled Task %(name)s!" - % {"name": task.task_name} - ), - ) - return Response( - { - "successful": True, - } - ) - except ImportError: - # if we get an import error, the module path has probably changed - task.delete() - return Response({"successful": False}) diff --git a/passbook/admin/apps.py b/passbook/admin/apps.py deleted file mode 100644 index 07843888..00000000 --- a/passbook/admin/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook admin app config""" -from django.apps import AppConfig - - -class PassbookAdminConfig(AppConfig): - """passbook admin app config""" - - name = "passbook.admin" - label = "passbook_admin" - mountpoint = "administration/" - verbose_name = "passbook Admin" diff --git a/passbook/admin/forms/policies.py b/passbook/admin/forms/policies.py deleted file mode 100644 index 9751260d..00000000 --- a/passbook/admin/forms/policies.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook administration forms""" -from django import forms - -from passbook.admin.fields import CodeMirrorWidget, YAMLField -from passbook.core.models import User - - -class PolicyTestForm(forms.Form): - """Form to test policies against user""" - - user = forms.ModelChoiceField(queryset=User.objects.all()) - context = YAMLField(widget=CodeMirrorWidget(), required=False, initial=dict) diff --git a/passbook/admin/forms/source.py b/passbook/admin/forms/source.py deleted file mode 100644 index 2207dec1..00000000 --- a/passbook/admin/forms/source.py +++ /dev/null @@ -1,17 +0,0 @@ -"""passbook core source form fields""" - -SOURCE_FORM_FIELDS = [ - "name", - "slug", - "enabled", - "authentication_flow", - "enrollment_flow", -] -SOURCE_SERIALIZER_FIELDS = [ - "pk", - "name", - "slug", - "enabled", - "authentication_flow", - "enrollment_flow", -] diff --git a/passbook/admin/forms/users.py b/passbook/admin/forms/users.py deleted file mode 100644 index 2e9f51eb..00000000 --- a/passbook/admin/forms/users.py +++ /dev/null @@ -1,22 +0,0 @@ -"""passbook administrative user forms""" - -from django import forms - -from passbook.admin.fields import CodeMirrorWidget, YAMLField -from passbook.core.models import User - - -class UserForm(forms.ModelForm): - """Update User Details""" - - class Meta: - - model = User - fields = ["username", "name", "email", "is_active", "attributes"] - widgets = { - "name": forms.TextInput, - "attributes": CodeMirrorWidget, - } - field_classes = { - "attributes": YAMLField, - } diff --git a/passbook/admin/mixins.py b/passbook/admin/mixins.py deleted file mode 100644 index 1ac2fa82..00000000 --- a/passbook/admin/mixins.py +++ /dev/null @@ -1,9 +0,0 @@ -"""passbook admin mixins""" -from django.contrib.auth.mixins import UserPassesTestMixin - - -class AdminRequiredMixin(UserPassesTestMixin): - """Make sure user is administrator""" - - def test_func(self): - return self.request.user.is_superuser diff --git a/passbook/admin/settings.py b/passbook/admin/settings.py deleted file mode 100644 index d6690770..00000000 --- a/passbook/admin/settings.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook admin settings""" -from celery.schedules import crontab - -CELERY_BEAT_SCHEDULE = { - "admin_latest_version": { - "task": "passbook.admin.tasks.update_latest_version", - "schedule": crontab(minute=0), # Run every hour - "options": {"queue": "passbook_scheduled"}, - } -} diff --git a/passbook/admin/tasks.py b/passbook/admin/tasks.py deleted file mode 100644 index 4c9532a4..00000000 --- a/passbook/admin/tasks.py +++ /dev/null @@ -1,30 +0,0 @@ -"""passbook admin tasks""" -from django.core.cache import cache -from requests import RequestException, get -from structlog import get_logger - -from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus -from passbook.root.celery import CELERY_APP - -LOGGER = get_logger() -VERSION_CACHE_KEY = "passbook_latest_version" -VERSION_CACHE_TIMEOUT = 2 * 60 * 60 # 2 hours - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def update_latest_version(self: MonitoredTask): - """Update latest version info""" - try: - data = get( - "https://api.github.com/repos/beryju/passbook/releases/latest" - ).json() - tag_name = data.get("tag_name") - cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT) - self.set_status( - TaskResult( - TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"] - ) - ) - except (RequestException, IndexError) as exc: - cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) - self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) diff --git a/passbook/admin/templates/administration/application/list.html b/passbook/admin/templates/administration/application/list.html deleted file mode 100644 index 49489252..00000000 --- a/passbook/admin/templates/administration/application/list.html +++ /dev/null @@ -1,121 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -

-
-

- - {% trans 'Applications' %} -

-

{% trans "External Applications which use passbook as Identity-Provider, utilizing protocols like OAuth2 and SAML." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for application in object_list %} - - - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Slug' %}{% trans 'Provider' %}{% trans 'Provider Type' %}
- -
{{ application.name }}
- {% if application.meta_publisher %} - {{ application.meta_publisher }} - {% endif %} -
-
- {{ application.slug }} - - - {{ application.get_provider }} - - - - {{ application.get_provider|verbose_name }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Applications.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any application." %} - {% else %} - {% trans 'Currently no applications exist. Click the button below to create one.' %} - {% endif %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/certificatekeypair/list.html b/passbook/admin/templates/administration/certificatekeypair/list.html deleted file mode 100644 index 25155168..00000000 --- a/passbook/admin/templates/administration/certificatekeypair/list.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Certificate-Key Pairs' %} -

-

{% trans "Import certificates of external providers or create certificates to sign requests with." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - {% for kp in object_list %} - - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Private Key available' %}{% trans 'Fingerprint' %}
-
-
{{ kp.name }}
-
-
- - {% if kp.key_data is not None %} - {% trans 'Yes' %} - {% else %} - {% trans 'No' %} - {% endif %} - - - {{ kp.fingerprint }} - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Certificates.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any certificates." %} - {% else %} - {% trans 'Currently no certificates exist. Click the button below to create one.' %} - {% endif %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/flow/list.html b/passbook/admin/templates/administration/flow/list.html deleted file mode 100644 index 9102643e..00000000 --- a/passbook/admin/templates/administration/flow/list.html +++ /dev/null @@ -1,135 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Flows' %} -

-

{% trans "Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- - - {% trans 'Import' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for flow in object_list %} - - - - - - - - {% endfor %} - -
{% trans 'Identifier' %}{% trans 'Designation' %}{% trans 'Stages' %}{% trans 'Policies' %}
-
-
{{ flow.slug }}
- {{ flow.name }} -
-
- - {{ flow.designation }} - - - - {{ flow.stages.all|length }} - - - - {{ flow.policies.all|length }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
- {% trans 'Execute' %} - {% trans 'Export' %} -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Flows.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any flows." %} - {% else %} - {% trans 'Currently no flows exist. Click the button below to create one.' %} - {% endif %} -
- - - {% trans 'Create' %} - -
-
- - - {% trans 'Import' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/group/list.html b/passbook/admin/templates/administration/group/list.html deleted file mode 100644 index 0f452766..00000000 --- a/passbook/admin/templates/administration/group/list.html +++ /dev/null @@ -1,114 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} - -{% block content %} -
-
-

- - {% trans 'Groups' %} -

-

{% trans "Group users together and give them permissions based on the membership." %} -

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - {% for group in object_list %} - - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Parent' %}{% trans 'Members' %}
- - {{ group.name }} - - - - {{ group.parent }} - - - - {{ group.users.all|length }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Groups.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any groups." %} - {% else %} - {% trans 'Currently no group exist. Click the button below to create one.' %} - {% endif %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/outpost/list.html b/passbook/admin/templates/administration/outpost/list.html deleted file mode 100644 index 693b269c..00000000 --- a/passbook/admin/templates/administration/outpost/list.html +++ /dev/null @@ -1,149 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load humanize %} -{% load passbook_utils %} -{% load admin_reflection %} - -{% block content %} -
-
-

- - {% trans 'Outposts' %} -

-

{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for outpost in object_list %} - - - - {% with states=outpost.state %} - {% if states|length > 0 %} - - - {% else %} - - - {% endif %} - {% endwith %} - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Providers' %}{% trans 'Health' %}{% trans 'Version' %}
- {{ outpost.name }} - - - {{ outpost.providers.all.select_subclasses|join:", " }} - - - {% for state in states %} -
- {% if state.last_seen %} - {{ state.last_seen|naturaltime }} - {% else %} - {% trans 'Unhealthy' %} - {% endif %} -
- {% endfor %} -
- {% for state in states %} -
- {% if not state.version %} - - {% elif state.version_outdated %} - {% blocktrans with is=state.version should=state.version_should %}{{ is }}, should be {{ should }}{% endblocktrans %} - {% else %} - {{ state.version }} - {% endif %} -
- {% endfor %} -
- - - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
- {% get_htmls outpost as htmls %} - {% for html in htmls %} - {{ html|safe }} - {% endfor %} -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Outposts.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any outposts." %} - {% else %} - {% trans 'Currently no outposts exist. Click the button below to create one.' %} - {% endif %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/outpost_service_connection/list.html b/passbook/admin/templates/administration/outpost_service_connection/list.html deleted file mode 100644 index 0dcbc429..00000000 --- a/passbook/admin/templates/administration/outpost_service_connection/list.html +++ /dev/null @@ -1,154 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load humanize %} -{% load passbook_utils %} -{% load admin_reflection %} - -{% block content %} -
-
-

- - {% trans 'Outpost Service-Connections' %} -

-

{% trans "Outpost Service-Connections define how passbook connects to external platforms to manage and deploy Outposts." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - - - -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for sc in object_list %} - - - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Type' %}{% trans 'Local?' %}{% trans 'Status' %}
- {{ sc.name }} - - - {{ sc|verbose_name }} - - - - {{ sc.local|yesno:"Yes,No" }} - - - - {% if sc.state.healthy %} - {{ sc.state.version }} - {% else %} - {% trans 'Unhealthy' %} - {% endif %} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Outpost Service Connections.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any outposts." %} - {% else %} - {% trans 'Currently no service connections exist. Click the button below to create one.' %} - {% endif %} -
- - - - -
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/overview.html b/passbook/admin/templates/administration/overview.html deleted file mode 100644 index d504096d..00000000 --- a/passbook/admin/templates/administration/overview.html +++ /dev/null @@ -1,230 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load static %} - -{% block content %} -
-
-

{% trans 'System Overview' %}

-
-
-
- -
-{% endblock %} diff --git a/passbook/admin/templates/administration/policy/list.html b/passbook/admin/templates/administration/policy/list.html deleted file mode 100644 index d985ab4d..00000000 --- a/passbook/admin/templates/administration/policy/list.html +++ /dev/null @@ -1,148 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Policies' %} -

-

{% trans "Allow users to use Applications based on properties, enforce Password Criteria and selectively apply Stages." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - - -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - {% for policy in object_list %} - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Type' %}
-
-
{{ policy.name }}
- {% if not policy.bindings.exists and not policy.promptstage_set.exists %} - - {% trans 'Warning: Policy is not assigned.' %} - {% else %} - - {% blocktrans with object_count=policy.bindings.all|length %}Assigned to {{ object_count }} objects.{% endblocktrans %} - {% endif %} -
-
- - {{ policy|verbose_name }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Test' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Policies.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any policies." %} - {% else %} - {% trans 'Currently no policies exist. Click the button below to create one.' %} - {% endif %} -
- - - - -
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/policy_binding/list.html b/passbook/admin/templates/administration/policy_binding/list.html deleted file mode 100644 index f4e7d9fd..00000000 --- a/passbook/admin/templates/administration/policy_binding/list.html +++ /dev/null @@ -1,119 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Policy Bindings' %} -

-

{% trans "Bind existing Policies to Models accepting policies." %}

-
-
-
-
- {% if object_list %} -
-
-
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for pbm in object_list %} - - - - - - - - {% for binding in pbm.bindings %} - - - - - - - - {% endfor %} - {% endfor %} - -
{% trans 'Policy' %}{% trans 'Enabled' %}{% trans 'Order' %}{% trans 'Timeout' %}
- {{ pbm }} - - {{ pbm|fieldtype }} - -
-
{{ binding.policy }}
- - {{ binding.policy|fieldtype }} - -
-
{{ binding.enabled }}
-
-
{{ binding.order }}
-
-
{{ binding.timeout }}
-
- - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- -

- {% trans 'No Policy Bindings.' %} -

-
- {% trans 'Currently no policy bindings exist. Click the button below to create one.' %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/property_mapping/list.html b/passbook/admin/templates/administration/property_mapping/list.html deleted file mode 100644 index 011154bc..00000000 --- a/passbook/admin/templates/administration/property_mapping/list.html +++ /dev/null @@ -1,139 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Property Mappings' %} -

-

{% trans "Control how passbook exposes and interprets information." %} -

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - - - -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - {% for property_mapping in object_list %} - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Type' %}
- - {{ property_mapping.name }} - - - - {{ property_mapping|verbose_name }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Property Mappings.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any property mappings." %} - {% else %} - {% trans 'Currently no property mappings exist. Click the button below to create one.' %} - {% endif %} -
- - - - -
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/provider/list.html b/passbook/admin/templates/administration/provider/list.html deleted file mode 100644 index 1bdbe68b..00000000 --- a/passbook/admin/templates/administration/provider/list.html +++ /dev/null @@ -1,159 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} -{% load admin_reflection %} - -{% block content %} -
-
-

- - {% trans 'Providers' %} -

-

{% trans "Provide support for protocols like SAML and OAuth to assigned applications." %} -

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - - - -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - {% for provider in object_list %} - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Type' %}
-
-
{{ provider.name }}
- {% if not provider.application %} - - {% trans 'Warning: Provider not assigned to any application.' %} - {% else %} - - - {% blocktrans with app=provider.application %} - Assigned to application {{ app }}. - {% endblocktrans %} - - {% endif %} -
-
- - {{ provider|verbose_name }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
- {% get_links provider as links %} - {% for name, href in links.items %} - {% trans name %} - {% endfor %} - {% get_htmls provider as htmls %} - {% for html in htmls %} - {{ html|safe }} - {% endfor %} -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Providers.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any providers." %} - {% else %} - {% trans 'Currently no providers exist. Click the button below to create one.' %} - {% endif %} -
- - - - -
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/source/list.html b/passbook/admin/templates/administration/source/list.html deleted file mode 100644 index aa63ca60..00000000 --- a/passbook/admin/templates/administration/source/list.html +++ /dev/null @@ -1,153 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} -{% load admin_reflection %} - -{% block content %} -
-
-

- - {% trans 'Source' %} -

-

{% trans "External Sources which can be used to get Identities into passbook, for example Social Providers like Twiter and GitHub or Enterprise Providers like ADFS and LDAP." %} -

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - - - -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - {% for source in object_list %} - - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Type' %}{% trans 'Additional Info' %}
-
-
{{ source.name }}
- {% if not source.enabled %} - {% trans 'Disabled' %} - {% endif %} -
-
- - {{ source|fieldtype }} - - - - {{ source.ui_additional_info|default:""|safe }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
- {% get_links source as links %} - {% for name, href in links %} - {% trans name %} - {% endfor %} -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Sources.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any sources." %} - {% else %} - {% trans 'Currently no sources exist. Click the button below to create one.' %} - {% endif %} -
- - - - -
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/stage/list.html b/passbook/admin/templates/administration/stage/list.html deleted file mode 100644 index bb32294d..00000000 --- a/passbook/admin/templates/administration/stage/list.html +++ /dev/null @@ -1,148 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} -{% load admin_reflection %} - -{% block content %} -
-
-

- - {% trans 'Stages' %} -

-

{% trans "Stages are single steps of a Flow that a user is guided through." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - - - -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - {% for stage in object_list %} - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Flows' %}
-
-
{{ stage.name }}
- {{ stage|verbose_name }} -
-
-
    - {% for flow in stage.flow_set.all %} -
  • {{ flow.slug }}<
  • - {% empty %} -
  • -
  • - {% endfor %} -
-
- - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
- {% get_links stage as links %} - {% for name, href in links.items %} - {% trans name %} - {% endfor %} -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Stages.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any stages." %} - {% else %} - {% trans 'Currently no stages exist. Click the button below to create one.' %} - {% endif %} -
- - - - -
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/stage_binding/list.html b/passbook/admin/templates/administration/stage_binding/list.html deleted file mode 100644 index 6fce1b50..00000000 --- a/passbook/admin/templates/administration/stage_binding/list.html +++ /dev/null @@ -1,125 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Stage Bindings' %} -

-

{% trans "Bind existing Stages to Flows." %}

-
-
-
-
- {% if object_list %} -
-
-
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - {% regroup object_list by target as grouped_bindings %} - {% for flow in grouped_bindings %} - - - - - - - {% for binding in flow.list %} - - - - - - - {% endfor %} - {% endfor %} - -
{% trans 'Order' %}{% trans 'Name' %}{% trans 'Stage Type' %}
- {% blocktrans with slug=flow.grouper.slug %} - Flow {{ slug }} - {% endblocktrans %} -
- - {{ binding.order }} - - -
-
{{ binding.target.slug }}
- - {{ binding.target.name }} - -
-
-
-
- {{ binding.stage.name }} -
- - {{ binding.stage }} - -
-
- - - {% trans 'Update' %} - -
-
- - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- -

- {% trans 'No Flow-Stage Bindings.' %} -

-
- {% trans 'Currently no flow-stage bindings exist. Click the button below to create one.' %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/stage_invitation/list.html b/passbook/admin/templates/administration/stage_invitation/list.html deleted file mode 100644 index e1052d78..00000000 --- a/passbook/admin/templates/administration/stage_invitation/list.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Invitations' %} -

-

{% trans "Create Invitation Links to enroll Users, and optionally force specific attributes of their account." %} -

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - {% for invitation in object_list %} - - - - - - {% endfor %} - -
{% trans 'Expiry' %}{% trans 'Link' %}
- - {{ invitation.expiry }} - - - - {{ invitation.Link }} - - - - - {% trans 'Delete' %} - -
-
-
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Invitations.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any invitations." %} - {% else %} - {% trans 'Currently no invitations exist. Click the button below to create one.' %} - {% endif %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/stage_prompt/list.html b/passbook/admin/templates/administration/stage_prompt/list.html deleted file mode 100644 index 13b8a455..00000000 --- a/passbook/admin/templates/administration/stage_prompt/list.html +++ /dev/null @@ -1,130 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} -{% load admin_reflection %} - -{% block content %} -
-
-

- - {% trans 'Prompts' %} -

-

{% trans "Single Prompts that can be used for Prompt Stages." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - - {% for prompt in object_list %} - - - - - - - - - {% endfor %} - -
{% trans 'Field' %}{% trans 'Label' %}{% trans 'Type' %}{% trans 'Order' %}{% trans 'Flows' %}
-
-
{{ prompt.field_key }}
-
-
-
- {{ prompt.label }} -
-
-
- {{ prompt.type }} -
-
-
- {{ prompt.order }} -
-
-
    - {% for flow in prompt.flow_set.all %} -
  • {{ flow.slug }}
  • - {% empty %} -
  • -
  • - {% endfor %} -
-
- - - {% trans 'Update' %} - -
-
- - - {% trans 'Delete' %} - -
-
- {% get_links prompt as links %} - {% for name, href in links.items %} - {% trans name %} - {% endfor %} -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Stage Prompts.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any stage prompts." %} - {% else %} - {% trans 'Currently no stage prompts exist. Click the button below to create one.' %} - {% endif %} -
- {% trans 'Create' %} -
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/task/list.html b/passbook/admin/templates/administration/task/list.html deleted file mode 100644 index 9f897afb..00000000 --- a/passbook/admin/templates/administration/task/list.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load humanize %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'System Tasks' %} -

-

{% trans "Long-running operations which passbook executes in the background." %}

-
-
-
-
-
-
- -
-
- - - - - - - - - - - - - {% for task in object_list %} - - - - - - - - - {% endfor %} - -
{% trans 'Identifier' %}{% trans 'Description' %}{% trans 'Last Run' %}{% trans 'Status' %}{% trans 'Messages' %}
-
{{ task.task_name }}
-
- - {{ task.task_description }} - - - - {{ task.finish_timestamp|naturaltime }} - - - - {% if task.result.status == task_successful %} - {% trans 'Successful' %} - {% elif task.result.status == task_warning %} - {% trans 'Warning' %} - {% elif task.result.status == task_error %} - {% trans 'Error' %} - {% else %} - {% trans 'Unknown' %} - {% endif %} - - - {% for message in task.result.messages %} -
- {{ message }} -
- {% endfor %} -
- - {% trans 'Retry Task' %} - -
-
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/token/list.html b/passbook/admin/templates/administration/token/list.html deleted file mode 100644 index cca46124..00000000 --- a/passbook/admin/templates/administration/token/list.html +++ /dev/null @@ -1,102 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Tokens' %} -

-

{% trans "Tokens are used throughout passbook for Email validation stages, Recovery keys and API access." %}

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} - {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for token in object_list %} - - - - - - - - {% endfor %} - -
{% trans 'Identifier' %}{% trans 'User' %}{% trans 'Expires?' %}{% trans 'Expiry Date' %}
-
{{ token.identifier }}
-
- - {{ token.user }} - - - - {{ token.expiring|yesno:"Yes,No" }} - - - - {% if not token.expiring %} - - - {% else %} - {{ token.expires }} - {% endif %} - - - - - {% trans 'Delete' %} - -
-
- - {% trans 'Copy token' %} - -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Tokens.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any token." %} - {% else %} - {% trans 'Currently no tokens exist.' %} - {% endif %} -
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/user/disable.html b/passbook/admin/templates/administration/user/disable.html deleted file mode 100644 index 0a311333..00000000 --- a/passbook/admin/templates/administration/user/disable.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
- {% block above_form %} -

- {% blocktrans with object_type=object|verbose_name %} - Disable {{ object_type }} - {% endblocktrans %} -

- {% endblock %} -
-
-
-
-
-
-
-
- {% csrf_token %} -

- {% blocktrans with object_type=object|verbose_name name=object %} - Are you sure you want to disable {{ object_type }} "{{ object }}"? - {% endblocktrans %} -

-
- -
-
-
-
-
-
-
-{% endblock %} diff --git a/passbook/admin/templates/administration/user/list.html b/passbook/admin/templates/administration/user/list.html deleted file mode 100644 index b3b27109..00000000 --- a/passbook/admin/templates/administration/user/list.html +++ /dev/null @@ -1,125 +0,0 @@ -{% extends "administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
-

- - {% trans 'Users' %} -

-
-
-
-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
- -
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - {% for user in object_list %} - - - - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Active' %}{% trans 'Last Login' %}
-
-
{{ user.username }}
- {{ user.name }} -
-
- - {{ user.is_active }} - - - - {{ user.last_login }} - - - - - {% trans 'Edit' %} - -
-
- {% if user.is_active %} - - - {% trans 'Disable' %} - -
-
- {% else %} - - - {% trans 'Enable' %} - -
-
- {% endif %} - {% trans 'Reset Password' %} - {% trans 'Impersonate' %} -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- {% include 'partials/toolbar_search.html' %} -
-
-
-
- -

- {% trans 'No Users.' %} -

-
- {% if request.GET.search != "" %} - {% trans "Your search query doesn't match any users." %} - {% else %} - {% trans 'Currently no users exist. How did you even get here.' %} - {% endif %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
-
-{% endblock %} diff --git a/passbook/admin/templates/fields/codemirror.html b/passbook/admin/templates/fields/codemirror.html deleted file mode 100644 index 9df833a1..00000000 --- a/passbook/admin/templates/fields/codemirror.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/passbook/admin/templates/generic/create.html b/passbook/admin/templates/generic/create.html deleted file mode 100644 index 640ab391..00000000 --- a/passbook/admin/templates/generic/create.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends base_template|default:"generic/form.html" %} - -{% load passbook_utils %} -{% load i18n %} - -{% block above_form %} -

- {% blocktrans with type=form|form_verbose_name %} - Create {{ type }} - {% endblocktrans %} -

-{% endblock %} - -{% block action %} -{% blocktrans with type=form|form_verbose_name %} -Create {{ type }} -{% endblocktrans %} -{% endblock %} diff --git a/passbook/admin/templates/generic/form.html b/passbook/admin/templates/generic/form.html deleted file mode 100644 index 73c28442..00000000 --- a/passbook/admin/templates/generic/form.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends container_template|default:"administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} -{% load static %} - -{% block content %} -
-
- {% block above_form %} - {% endblock %} -
-
-
-
-
-
-
-
- {% include 'partials/form_horizontal.html' with form=form %} - {% block beneath_form %} - {% endblock %} -
-
-
-
-
-
- -{% endblock %} - -{% block scripts %} -{{ block.super }} -{{ form.media.js }} -{% endblock %} diff --git a/passbook/admin/templates/generic/form_non_model.html b/passbook/admin/templates/generic/form_non_model.html deleted file mode 100644 index 032e5abd..00000000 --- a/passbook/admin/templates/generic/form_non_model.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends base_template|default:"generic/form.html" %} - -{% load passbook_utils %} -{% load i18n %} - -{% block above_form %} -

- {% trans form.title %} -

-{% endblock %} - -{% block beneath_form %} -

- {% trans form.body %} -

-{% endblock %} - -{% block action %} -{% trans 'Confirm' %} -{% endblock %} diff --git a/passbook/admin/templates/generic/update.html b/passbook/admin/templates/generic/update.html deleted file mode 100644 index 30d929ff..00000000 --- a/passbook/admin/templates/generic/update.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends base_template|default:"generic/form.html" %} - -{% load passbook_utils %} -{% load i18n %} - -{% block above_form %} -

- {% blocktrans with type=form|form_verbose_name|title inst=form.instance %} - Update {{ inst }} - {% endblocktrans %} -

-{% endblock %} - -{% block action %} -{% blocktrans with type=form|form_verbose_name %} -Update {{ type }} -{% endblocktrans %} -{% endblock %} diff --git a/passbook/admin/templatetags/admin_reflection.py b/passbook/admin/templatetags/admin_reflection.py deleted file mode 100644 index 9faeaa39..00000000 --- a/passbook/admin/templatetags/admin_reflection.py +++ /dev/null @@ -1,62 +0,0 @@ -"""passbook admin templatetags""" -from django import template -from django.db.models import Model -from django.utils.html import mark_safe -from structlog import get_logger - -register = template.Library() -LOGGER = get_logger() - - -@register.simple_tag() -def get_links(model_instance): - """Find all link_ methods on an object instance, run them and return as dict""" - prefix = "link_" - links = {} - - if not isinstance(model_instance, Model): - LOGGER.warning("Model is not instance of Model", model_instance=model_instance) - return links - - try: - for name in dir(model_instance): - if not name.startswith(prefix): - continue - value = getattr(model_instance, name) - if not callable(value): - continue - human_name = name.replace(prefix, "").replace("_", " ").capitalize() - link = value() - if link: - links[human_name] = link - except NotImplementedError: - pass - - return links - - -@register.simple_tag(takes_context=True) -def get_htmls(context, model_instance): - """Find all html_ methods on an object instance, run them and return as dict""" - prefix = "html_" - htmls = [] - - if not isinstance(model_instance, Model): - LOGGER.warning("Model is not instance of Model", model_instance=model_instance) - return htmls - - try: - for name in dir(model_instance): - if not name.startswith(prefix): - continue - value = getattr(model_instance, name) - if not callable(value): - continue - if name.startswith(prefix): - html = value(context.get("request")) - if html: - htmls.append(mark_safe(html)) - except NotImplementedError: - pass - - return htmls diff --git a/passbook/admin/tests.py b/passbook/admin/tests.py deleted file mode 100644 index 13ced0c9..00000000 --- a/passbook/admin/tests.py +++ /dev/null @@ -1,66 +0,0 @@ -"""admin tests""" -from importlib import import_module -from typing import Callable - -from django.forms import ModelForm -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.urls.exceptions import NoReverseMatch - -from passbook.admin.urls import urlpatterns -from passbook.core.models import Group, User -from passbook.lib.utils.reflection import get_apps - - -class TestAdmin(TestCase): - """Generic admin tests""" - - def setUp(self): - self.user = User.objects.create_user(username="test") - self.user.pb_groups.add(Group.objects.filter(is_superuser=True).first()) - self.user.save() - self.client = Client() - self.client.force_login(self.user) - - -def generic_view_tester(view_name: str) -> Callable: - """This is used instead of subTest for better visibility""" - - def tester(self: TestAdmin): - try: - full_url = reverse(f"passbook_admin:{view_name}") - response = self.client.get(full_url) - self.assertTrue(response.status_code < 500) - except NoReverseMatch: - pass - - return tester - - -for url in urlpatterns: - method_name = url.name.replace("-", "_") - setattr(TestAdmin, f"test_view_{method_name}", generic_view_tester(url.name)) - - -def generic_form_tester(form: ModelForm) -> Callable: - """Test a form""" - - def tester(self: TestAdmin): - form_inst = form() - self.assertFalse(form_inst.is_valid()) - - return tester - - -# Load the forms module from every app, so we have all forms loaded -for app in get_apps(): - module = app.__module__.replace(".apps", ".forms") - try: - import_module(module) - except ImportError: - pass - -for form_class in ModelForm.__subclasses__(): - setattr( - TestAdmin, f"test_form_{form_class.__name__}", generic_form_tester(form_class) - ) diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py deleted file mode 100644 index 5c556d30..00000000 --- a/passbook/admin/urls.py +++ /dev/null @@ -1,353 +0,0 @@ -"""passbook URL Configuration""" -from django.urls import path - -from passbook.admin.views import ( - applications, - certificate_key_pair, - flows, - groups, - outposts, - outposts_service_connections, - overview, - policies, - policies_bindings, - property_mappings, - providers, - sources, - stages, - stages_bindings, - stages_invitations, - stages_prompts, - tasks, - tokens, - users, -) - -urlpatterns = [ - path( - "overview/cache/flow/", - overview.FlowCacheClearView.as_view(), - name="overview-clear-flow-cache", - ), - path( - "overview/cache/policy/", - overview.PolicyCacheClearView.as_view(), - name="overview-clear-policy-cache", - ), - path("overview/", overview.AdministrationOverviewView.as_view(), name="overview"), - # Applications - path( - "applications/", applications.ApplicationListView.as_view(), name="applications" - ), - path( - "applications/create/", - applications.ApplicationCreateView.as_view(), - name="application-create", - ), - path( - "applications//update/", - applications.ApplicationUpdateView.as_view(), - name="application-update", - ), - path( - "applications//delete/", - applications.ApplicationDeleteView.as_view(), - name="application-delete", - ), - # Tokens - path("tokens/", tokens.TokenListView.as_view(), name="tokens"), - path( - "tokens//delete/", - tokens.TokenDeleteView.as_view(), - name="token-delete", - ), - # Sources - path("sources/", sources.SourceListView.as_view(), name="sources"), - path("sources/create/", sources.SourceCreateView.as_view(), name="source-create"), - path( - "sources//update/", - sources.SourceUpdateView.as_view(), - name="source-update", - ), - path( - "sources//delete/", - sources.SourceDeleteView.as_view(), - name="source-delete", - ), - # Policies - path("policies/", policies.PolicyListView.as_view(), name="policies"), - path("policies/create/", policies.PolicyCreateView.as_view(), name="policy-create"), - path( - "policies//update/", - policies.PolicyUpdateView.as_view(), - name="policy-update", - ), - path( - "policies//delete/", - policies.PolicyDeleteView.as_view(), - name="policy-delete", - ), - path( - "policies//test/", - policies.PolicyTestView.as_view(), - name="policy-test", - ), - # Policy bindings - path( - "policies/bindings/", - policies_bindings.PolicyBindingListView.as_view(), - name="policies-bindings", - ), - path( - "policies/bindings/create/", - policies_bindings.PolicyBindingCreateView.as_view(), - name="policy-binding-create", - ), - path( - "policies/bindings//update/", - policies_bindings.PolicyBindingUpdateView.as_view(), - name="policy-binding-update", - ), - path( - "policies/bindings//delete/", - policies_bindings.PolicyBindingDeleteView.as_view(), - name="policy-binding-delete", - ), - # Providers - path("providers/", providers.ProviderListView.as_view(), name="providers"), - path( - "providers/create/", - providers.ProviderCreateView.as_view(), - name="provider-create", - ), - path( - "providers//update/", - providers.ProviderUpdateView.as_view(), - name="provider-update", - ), - path( - "providers//delete/", - providers.ProviderDeleteView.as_view(), - name="provider-delete", - ), - # Stages - path("stages/", stages.StageListView.as_view(), name="stages"), - path("stages/create/", stages.StageCreateView.as_view(), name="stage-create"), - path( - "stages//update/", - stages.StageUpdateView.as_view(), - name="stage-update", - ), - path( - "stages//delete/", - stages.StageDeleteView.as_view(), - name="stage-delete", - ), - # Stage bindings - path( - "stages/bindings/", - stages_bindings.StageBindingListView.as_view(), - name="stage-bindings", - ), - path( - "stages/bindings/create/", - stages_bindings.StageBindingCreateView.as_view(), - name="stage-binding-create", - ), - path( - "stages/bindings//update/", - stages_bindings.StageBindingUpdateView.as_view(), - name="stage-binding-update", - ), - path( - "stages/bindings//delete/", - stages_bindings.StageBindingDeleteView.as_view(), - name="stage-binding-delete", - ), - # Stage Prompts - path( - "stages/prompts/", - stages_prompts.PromptListView.as_view(), - name="stage-prompts", - ), - path( - "stages/prompts/create/", - stages_prompts.PromptCreateView.as_view(), - name="stage-prompt-create", - ), - path( - "stages/prompts//update/", - stages_prompts.PromptUpdateView.as_view(), - name="stage-prompt-update", - ), - path( - "stages/prompts//delete/", - stages_prompts.PromptDeleteView.as_view(), - name="stage-prompt-delete", - ), - # Stage Invitations - path( - "stages/invitations/", - stages_invitations.InvitationListView.as_view(), - name="stage-invitations", - ), - path( - "stages/invitations/create/", - stages_invitations.InvitationCreateView.as_view(), - name="stage-invitation-create", - ), - path( - "stages/invitations//delete/", - stages_invitations.InvitationDeleteView.as_view(), - name="stage-invitation-delete", - ), - # Flows - path("flows/", flows.FlowListView.as_view(), name="flows"), - path( - "flows/create/", - flows.FlowCreateView.as_view(), - name="flow-create", - ), - path( - "flows/import/", - flows.FlowImportView.as_view(), - name="flow-import", - ), - path( - "flows//update/", - flows.FlowUpdateView.as_view(), - name="flow-update", - ), - path( - "flows//execute/", - flows.FlowDebugExecuteView.as_view(), - name="flow-execute", - ), - path( - "flows//export/", - flows.FlowExportView.as_view(), - name="flow-export", - ), - path( - "flows//delete/", - flows.FlowDeleteView.as_view(), - name="flow-delete", - ), - # Property Mappings - path( - "property-mappings/", - property_mappings.PropertyMappingListView.as_view(), - name="property-mappings", - ), - path( - "property-mappings/create/", - property_mappings.PropertyMappingCreateView.as_view(), - name="property-mapping-create", - ), - path( - "property-mappings//update/", - property_mappings.PropertyMappingUpdateView.as_view(), - name="property-mapping-update", - ), - path( - "property-mappings//delete/", - property_mappings.PropertyMappingDeleteView.as_view(), - name="property-mapping-delete", - ), - # Users - path("users/", users.UserListView.as_view(), name="users"), - path("users/create/", users.UserCreateView.as_view(), name="user-create"), - path("users//update/", users.UserUpdateView.as_view(), name="user-update"), - path("users//delete/", users.UserDeleteView.as_view(), name="user-delete"), - path( - "users//disable/", users.UserDisableView.as_view(), name="user-disable" - ), - path("users//enable/", users.UserEnableView.as_view(), name="user-enable"), - path( - "users//reset/", - users.UserPasswordResetView.as_view(), - name="user-password-reset", - ), - # Groups - path("groups/", groups.GroupListView.as_view(), name="groups"), - path("groups/create/", groups.GroupCreateView.as_view(), name="group-create"), - path( - "groups//update/", - groups.GroupUpdateView.as_view(), - name="group-update", - ), - path( - "groups//delete/", - groups.GroupDeleteView.as_view(), - name="group-delete", - ), - # Certificate-Key Pairs - path( - "crypto/certificates/", - certificate_key_pair.CertificateKeyPairListView.as_view(), - name="certificate_key_pair", - ), - path( - "crypto/certificates/create/", - certificate_key_pair.CertificateKeyPairCreateView.as_view(), - name="certificatekeypair-create", - ), - path( - "crypto/certificates//update/", - certificate_key_pair.CertificateKeyPairUpdateView.as_view(), - name="certificatekeypair-update", - ), - path( - "crypto/certificates//delete/", - certificate_key_pair.CertificateKeyPairDeleteView.as_view(), - name="certificatekeypair-delete", - ), - # Outposts - path( - "outposts/", - outposts.OutpostListView.as_view(), - name="outposts", - ), - path( - "outposts/create/", - outposts.OutpostCreateView.as_view(), - name="outpost-create", - ), - path( - "outposts//update/", - outposts.OutpostUpdateView.as_view(), - name="outpost-update", - ), - path( - "outposts//delete/", - outposts.OutpostDeleteView.as_view(), - name="outpost-delete", - ), - # Outpost Service Connections - path( - "outposts/service_connections/", - outposts_service_connections.OutpostServiceConnectionListView.as_view(), - name="outpost-service-connections", - ), - path( - "outposts/service_connections/create/", - outposts_service_connections.OutpostServiceConnectionCreateView.as_view(), - name="outpost-service-connection-create", - ), - path( - "outposts/service_connections//update/", - outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(), - name="outpost-service-connection-update", - ), - path( - "outposts/service_connections//delete/", - outposts_service_connections.OutpostServiceConnectionDeleteView.as_view(), - name="outpost-service-connection-delete", - ), - # Tasks - path( - "tasks/", - tasks.TaskListView.as_view(), - name="tasks", - ), -] diff --git a/passbook/admin/views/applications.py b/passbook/admin/views/applications.py deleted file mode 100644 index 2b53b215..00000000 --- a/passbook/admin/views/applications.py +++ /dev/null @@ -1,93 +0,0 @@ -"""passbook Application administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.forms.applications import ApplicationForm -from passbook.core.models import Application -from passbook.lib.views import CreateAssignPermView - - -class ApplicationListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all applications""" - - model = Application - permission_required = "passbook_core.view_application" - ordering = "name" - template_name = "administration/application/list.html" - - search_fields = [ - "name", - "slug", - "meta_launch_url", - "meta_icon_url", - "meta_description", - "meta_publisher", - ] - - -class ApplicationCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Application""" - - model = Application - form_class = ApplicationForm - permission_required = "passbook_core.add_application" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:applications") - success_message = _("Successfully created Application") - - -class ApplicationUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update application""" - - model = Application - form_class = ApplicationForm - permission_required = "passbook_core.change_application" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:applications") - success_message = _("Successfully updated Application") - - -class ApplicationDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete application""" - - model = Application - permission_required = "passbook_core.delete_application" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:applications") - success_message = _("Successfully deleted Application") diff --git a/passbook/admin/views/certificate_key_pair.py b/passbook/admin/views/certificate_key_pair.py deleted file mode 100644 index a221cd8e..00000000 --- a/passbook/admin/views/certificate_key_pair.py +++ /dev/null @@ -1,86 +0,0 @@ -"""passbook CertificateKeyPair administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.crypto.forms import CertificateKeyPairForm -from passbook.crypto.models import CertificateKeyPair -from passbook.lib.views import CreateAssignPermView - - -class CertificateKeyPairListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all keypairs""" - - model = CertificateKeyPair - permission_required = "passbook_crypto.view_certificatekeypair" - ordering = "name" - template_name = "administration/certificatekeypair/list.html" - - search_fields = ["name"] - - -class CertificateKeyPairCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new CertificateKeyPair""" - - model = CertificateKeyPair - form_class = CertificateKeyPairForm - permission_required = "passbook_crypto.add_certificatekeypair" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:certificate_key_pair") - success_message = _("Successfully created CertificateKeyPair") - - -class CertificateKeyPairUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update certificatekeypair""" - - model = CertificateKeyPair - form_class = CertificateKeyPairForm - permission_required = "passbook_crypto.change_certificatekeypair" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:certificate_key_pair") - success_message = _("Successfully updated Certificate-Key Pair") - - -class CertificateKeyPairDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete certificatekeypair""" - - model = CertificateKeyPair - permission_required = "passbook_crypto.delete_certificatekeypair" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:certificate_key_pair") - success_message = _("Successfully deleted Certificate-Key Pair") diff --git a/passbook/admin/views/flows.py b/passbook/admin/views/flows.py deleted file mode 100644 index c45dbaf1..00000000 --- a/passbook/admin/views/flows.py +++ /dev/null @@ -1,151 +0,0 @@ -"""passbook Flow administration""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpRequest, HttpResponse, JsonResponse -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import DetailView, FormView, ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.flows.forms import FlowForm, FlowImportForm -from passbook.flows.models import Flow -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.transfer.common import DataclassEncoder -from passbook.flows.transfer.exporter import FlowExporter -from passbook.flows.transfer.importer import FlowImporter -from passbook.flows.views import SESSION_KEY_PLAN, FlowPlanner -from passbook.lib.utils.urls import redirect_with_qs -from passbook.lib.views import CreateAssignPermView - - -class FlowListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all flows""" - - model = Flow - permission_required = "passbook_flows.view_flow" - ordering = "name" - template_name = "administration/flow/list.html" - search_fields = ["name", "slug", "designation", "title"] - - -class FlowCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Flow""" - - model = Flow - form_class = FlowForm - permission_required = "passbook_flows.add_flow" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:flows") - success_message = _("Successfully created Flow") - - -class FlowUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update flow""" - - model = Flow - form_class = FlowForm - permission_required = "passbook_flows.change_flow" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:flows") - success_message = _("Successfully updated Flow") - - -class FlowDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete flow""" - - model = Flow - permission_required = "passbook_flows.delete_flow" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:flows") - success_message = _("Successfully deleted Flow") - - -class FlowDebugExecuteView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): - """Debug exectue flow, setting the current user as pending user""" - - model = Flow - permission_required = "passbook_flows.view_flow" - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, pk: str) -> HttpResponse: - """Debug exectue flow, setting the current user as pending user""" - flow: Flow = self.get_object() - planner = FlowPlanner(flow) - planner.use_cache = False - plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user}) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - self.request.GET, - flow_slug=flow.slug, - ) - - -class FlowImportView(LoginRequiredMixin, FormView): - """Import flow from JSON Export; only allowed for superusers - as these flows can contain python code""" - - form_class = FlowImportForm - template_name = "administration/flow/import.html" - success_url = reverse_lazy("passbook_admin:flows") - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_superuser: - return self.handle_no_permission() - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form: FlowImportForm) -> HttpResponse: - importer = FlowImporter(form.cleaned_data["flow"].read().decode()) - successful = importer.apply() - if not successful: - messages.error(self.request, _("Failed to import flow.")) - else: - messages.success(self.request, _("Successfully imported flow.")) - return super().form_valid(form) - - -class FlowExportView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): - """Export Flow""" - - model = Flow - permission_required = "passbook_flows.export_flow" - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, pk: str) -> HttpResponse: - """Debug exectue flow, setting the current user as pending user""" - flow: Flow = self.get_object() - exporter = FlowExporter(flow) - response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False) - response["Content-Disposition"] = f'attachment; filename="{flow.slug}.pbflow"' - return response diff --git a/passbook/admin/views/groups.py b/passbook/admin/views/groups.py deleted file mode 100644 index 64609d7e..00000000 --- a/passbook/admin/views/groups.py +++ /dev/null @@ -1,83 +0,0 @@ -"""passbook Group administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.forms.groups import GroupForm -from passbook.core.models import Group -from passbook.lib.views import CreateAssignPermView - - -class GroupListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all groups""" - - model = Group - permission_required = "passbook_core.view_group" - ordering = "name" - template_name = "administration/group/list.html" - search_fields = ["name", "attributes"] - - -class GroupCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Group""" - - model = Group - form_class = GroupForm - permission_required = "passbook_core.add_group" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:groups") - success_message = _("Successfully created Group") - - -class GroupUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update group""" - - model = Group - form_class = GroupForm - permission_required = "passbook_core.change_group" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:groups") - success_message = _("Successfully updated Group") - - -class GroupDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete group""" - - model = Group - permission_required = "passbook_flows.delete_group" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:groups") - success_message = _("Successfully deleted Group") diff --git a/passbook/admin/views/outposts.py b/passbook/admin/views/outposts.py deleted file mode 100644 index 43a6b88c..00000000 --- a/passbook/admin/views/outposts.py +++ /dev/null @@ -1,93 +0,0 @@ -"""passbook Outpost administration""" -from dataclasses import asdict -from typing import Any, Dict - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.lib.views import CreateAssignPermView -from passbook.outposts.forms import OutpostForm -from passbook.outposts.models import Outpost, OutpostConfig - - -class OutpostListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all outposts""" - - model = Outpost - permission_required = "passbook_outposts.view_outpost" - ordering = "name" - template_name = "administration/outpost/list.html" - search_fields = ["name", "_config"] - - -class OutpostCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Outpost""" - - model = Outpost - form_class = OutpostForm - permission_required = "passbook_outposts.add_outpost" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:outposts") - success_message = _("Successfully created Outpost") - - def get_initial(self) -> Dict[str, Any]: - return { - "_config": asdict( - OutpostConfig(passbook_host=self.request.build_absolute_uri("/")) - ) - } - - -class OutpostUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update outpost""" - - model = Outpost - form_class = OutpostForm - permission_required = "passbook_outposts.change_outpost" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:outposts") - success_message = _("Successfully updated Outpost") - - -class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete outpost""" - - model = Outpost - permission_required = "passbook_outposts.delete_outpost" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:outposts") - success_message = _("Successfully deleted Outpost") diff --git a/passbook/admin/views/outposts_service_connections.py b/passbook/admin/views/outposts_service_connections.py deleted file mode 100644 index 93fc6c51..00000000 --- a/passbook/admin/views/outposts_service_connections.py +++ /dev/null @@ -1,83 +0,0 @@ -"""passbook OutpostServiceConnection administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - InheritanceCreateView, - InheritanceListView, - InheritanceUpdateView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.outposts.models import OutpostServiceConnection - - -class OutpostServiceConnectionListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - InheritanceListView, -): - """Show list of all outpost-service-connections""" - - model = OutpostServiceConnection - permission_required = "passbook_outposts.add_outpostserviceconnection" - template_name = "administration/outpost_service_connection/list.html" - ordering = "pk" - search_fields = ["pk", "name"] - - -class OutpostServiceConnectionCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new OutpostServiceConnection""" - - model = OutpostServiceConnection - permission_required = "passbook_outposts.add_outpostserviceconnection" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:outpost-service-connections") - success_message = _("Successfully created OutpostServiceConnection") - - -class OutpostServiceConnectionUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update outpostserviceconnection""" - - model = OutpostServiceConnection - permission_required = "passbook_outposts.change_outpostserviceconnection" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:outpost-service-connections") - success_message = _("Successfully updated OutpostServiceConnection") - - -class OutpostServiceConnectionDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete outpostserviceconnection""" - - model = OutpostServiceConnection - permission_required = "passbook_outposts.delete_outpostserviceconnection" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:outpost-service-connections") - success_message = _("Successfully deleted OutpostServiceConnection") diff --git a/passbook/admin/views/overview.py b/passbook/admin/views/overview.py deleted file mode 100644 index c8874b3b..00000000 --- a/passbook/admin/views/overview.py +++ /dev/null @@ -1,85 +0,0 @@ -"""passbook administration overview""" -from typing import Union - -from django.conf import settings -from django.contrib.messages.views import SuccessMessageMixin -from django.core.cache import cache -from django.http.request import HttpRequest -from django.http.response import HttpResponse -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import FormView, TemplateView -from packaging.version import LegacyVersion, Version, parse -from structlog import get_logger - -from passbook import __version__ -from passbook.admin.forms.overview import FlowCacheClearForm, PolicyCacheClearForm -from passbook.admin.mixins import AdminRequiredMixin -from passbook.admin.tasks import VERSION_CACHE_KEY, update_latest_version -from passbook.core.models import Provider, User -from passbook.policies.models import Policy - -LOGGER = get_logger() - - -class AdministrationOverviewView(AdminRequiredMixin, TemplateView): - """Overview View""" - - template_name = "administration/overview.html" - - def get_latest_version(self) -> Union[LegacyVersion, Version]: - """Get latest version from cache""" - version_in_cache = cache.get(VERSION_CACHE_KEY) - if not version_in_cache: - if not settings.DEBUG: - update_latest_version.delay() - return parse(__version__) - return parse(version_in_cache) - - def get_context_data(self, **kwargs): - kwargs["policy_count"] = len(Policy.objects.all()) - kwargs["user_count"] = len(User.objects.all()) - 1 # Remove anonymous user - kwargs["provider_count"] = len(Provider.objects.all()) - kwargs["version"] = parse(__version__) - kwargs["version_latest"] = self.get_latest_version() - kwargs["providers_without_application"] = Provider.objects.filter( - application=None - ) - kwargs["policies_without_binding"] = len( - Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True) - ) - kwargs["cached_policies"] = len(cache.keys("policy_*")) - kwargs["cached_flows"] = len(cache.keys("flow_*")) - return super().get_context_data(**kwargs) - - -class PolicyCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): - """View to clear Policy cache""" - - form_class = PolicyCacheClearForm - - template_name = "generic/form_non_model.html" - success_url = reverse_lazy("passbook_admin:overview") - success_message = _("Successfully cleared Policy cache") - - def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - keys = cache.keys("policy_*") - cache.delete_many(keys) - LOGGER.debug("Cleared Policy cache", keys=len(keys)) - return super().post(request, *args, **kwargs) - - -class FlowCacheClearView(AdminRequiredMixin, SuccessMessageMixin, FormView): - """View to clear Flow cache""" - - form_class = FlowCacheClearForm - - template_name = "generic/form_non_model.html" - success_url = reverse_lazy("passbook_admin:overview") - success_message = _("Successfully cleared Flow cache") - - def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - keys = cache.keys("flow_*") - cache.delete_many(keys) - LOGGER.debug("Cleared flow cache", keys=len(keys)) - return super().post(request, *args, **kwargs) diff --git a/passbook/admin/views/policies.py b/passbook/admin/views/policies.py deleted file mode 100644 index 1de8f1bc..00000000 --- a/passbook/admin/views/policies.py +++ /dev/null @@ -1,129 +0,0 @@ -"""passbook Policy administration""" -from typing import Any, Dict - -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.db.models import QuerySet -from django.http import HttpResponse -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import FormView -from django.views.generic.detail import DetailView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.forms.policies import PolicyTestForm -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - InheritanceCreateView, - InheritanceListView, - InheritanceUpdateView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.policies.models import Policy, PolicyBinding -from passbook.policies.process import PolicyProcess, PolicyRequest - - -class PolicyListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - InheritanceListView, -): - """Show list of all policies""" - - model = Policy - permission_required = "passbook_policies.view_policy" - ordering = "name" - template_name = "administration/policy/list.html" - search_fields = ["name"] - - -class PolicyCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Policy""" - - model = Policy - permission_required = "passbook_policies.add_policy" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:policies") - success_message = _("Successfully created Policy") - - -class PolicyUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update policy""" - - model = Policy - permission_required = "passbook_policies.change_policy" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:policies") - success_message = _("Successfully updated Policy") - - -class PolicyDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete policy""" - - model = Policy - permission_required = "passbook_policies.delete_policy" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:policies") - success_message = _("Successfully deleted Policy") - - -class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, FormView): - """View to test policy(s)""" - - model = Policy - form_class = PolicyTestForm - permission_required = "passbook_policies.view_policy" - template_name = "administration/policy/test.html" - object = None - - def get_object(self, queryset=None) -> QuerySet: - return ( - Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first() - ) - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs["policy"] = self.get_object() - return super().get_context_data(**kwargs) - - def post(self, *args, **kwargs) -> HttpResponse: - self.object = self.get_object() - return super().post(*args, **kwargs) - - def form_valid(self, form: PolicyTestForm) -> HttpResponse: - policy = self.get_object() - user = form.cleaned_data.get("user") - - p_request = PolicyRequest(user) - p_request.http_request = self.request - p_request.context = form.cleaned_data - - proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None) - result = proc.execute() - if result.passing: - messages.success(self.request, _("User successfully passed policy.")) - else: - messages.error(self.request, _("User didn't pass policy.")) - return self.render_to_response(self.get_context_data(form=form, result=result)) diff --git a/passbook/admin/views/policies_bindings.py b/passbook/admin/views/policies_bindings.py deleted file mode 100644 index c082d0d0..00000000 --- a/passbook/admin/views/policies_bindings.py +++ /dev/null @@ -1,99 +0,0 @@ -"""passbook PolicyBinding administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.db.models import QuerySet -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin -from guardian.shortcuts import get_objects_for_user - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - UserPaginateListMixin, -) -from passbook.lib.views import CreateAssignPermView -from passbook.policies.forms import PolicyBindingForm -from passbook.policies.models import PolicyBinding - - -class PolicyBindingListView( - LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView -): - """Show list of all policies""" - - model = PolicyBinding - permission_required = "passbook_policies.view_policybinding" - ordering = ["order", "target"] - template_name = "administration/policy_binding/list.html" - - def get_queryset(self) -> QuerySet: - # Since `select_subclasses` does not work with a foreign key, we have to do two queries here - # First, get all pbm objects that have bindings attached - objects = ( - get_objects_for_user( - self.request.user, "passbook_policies.view_policybindingmodel" - ) - .filter(policies__isnull=False) - .select_subclasses() - .select_related() - .order_by("pk") - ) - for pbm in objects: - pbm.bindings = get_objects_for_user( - self.request.user, self.permission_required - ).filter(target__pk=pbm.pbm_uuid) - return objects - - -class PolicyBindingCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new PolicyBinding""" - - model = PolicyBinding - permission_required = "passbook_policies.add_policybinding" - form_class = PolicyBindingForm - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:policies-bindings") - success_message = _("Successfully created PolicyBinding") - - -class PolicyBindingUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update policybinding""" - - model = PolicyBinding - permission_required = "passbook_policies.change_policybinding" - form_class = PolicyBindingForm - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:policies-bindings") - success_message = _("Successfully updated PolicyBinding") - - -class PolicyBindingDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete policybinding""" - - model = PolicyBinding - permission_required = "passbook_policies.delete_policybinding" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:policies-bindings") - success_message = _("Successfully deleted PolicyBinding") diff --git a/passbook/admin/views/property_mappings.py b/passbook/admin/views/property_mappings.py deleted file mode 100644 index 9324badf..00000000 --- a/passbook/admin/views/property_mappings.py +++ /dev/null @@ -1,83 +0,0 @@ -"""passbook PropertyMapping administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - InheritanceCreateView, - InheritanceListView, - InheritanceUpdateView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.models import PropertyMapping - - -class PropertyMappingListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - InheritanceListView, -): - """Show list of all property_mappings""" - - model = PropertyMapping - permission_required = "passbook_core.view_propertymapping" - template_name = "administration/property_mapping/list.html" - ordering = "name" - search_fields = ["name", "expression"] - - -class PropertyMappingCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new PropertyMapping""" - - model = PropertyMapping - permission_required = "passbook_core.add_propertymapping" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:property-mappings") - success_message = _("Successfully created Property Mapping") - - -class PropertyMappingUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update property_mapping""" - - model = PropertyMapping - permission_required = "passbook_core.change_propertymapping" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:property-mappings") - success_message = _("Successfully updated Property Mapping") - - -class PropertyMappingDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete property_mapping""" - - model = PropertyMapping - permission_required = "passbook_core.delete_propertymapping" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:property-mappings") - success_message = _("Successfully deleted Property Mapping") diff --git a/passbook/admin/views/providers.py b/passbook/admin/views/providers.py deleted file mode 100644 index 19584ad4..00000000 --- a/passbook/admin/views/providers.py +++ /dev/null @@ -1,83 +0,0 @@ -"""passbook Provider administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - InheritanceCreateView, - InheritanceListView, - InheritanceUpdateView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.models import Provider - - -class ProviderListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - InheritanceListView, -): - """Show list of all providers""" - - model = Provider - permission_required = "passbook_core.add_provider" - template_name = "administration/provider/list.html" - ordering = "pk" - search_fields = ["pk", "name"] - - -class ProviderCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Provider""" - - model = Provider - permission_required = "passbook_core.add_provider" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:providers") - success_message = _("Successfully created Provider") - - -class ProviderUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update provider""" - - model = Provider - permission_required = "passbook_core.change_provider" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:providers") - success_message = _("Successfully updated Provider") - - -class ProviderDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete provider""" - - model = Provider - permission_required = "passbook_core.delete_provider" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:providers") - success_message = _("Successfully deleted Provider") diff --git a/passbook/admin/views/sources.py b/passbook/admin/views/sources.py deleted file mode 100644 index 8d84fd9f..00000000 --- a/passbook/admin/views/sources.py +++ /dev/null @@ -1,81 +0,0 @@ -"""passbook Source administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - InheritanceCreateView, - InheritanceListView, - InheritanceUpdateView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.models import Source - - -class SourceListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - InheritanceListView, -): - """Show list of all sources""" - - model = Source - permission_required = "passbook_core.view_source" - ordering = "name" - template_name = "administration/source/list.html" - search_fields = ["name", "slug"] - - -class SourceCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Source""" - - model = Source - permission_required = "passbook_core.add_source" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:sources") - success_message = _("Successfully created Source") - - -class SourceUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update source""" - - model = Source - permission_required = "passbook_core.change_source" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:sources") - success_message = _("Successfully updated Source") - - -class SourceDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete source""" - - model = Source - permission_required = "passbook_core.delete_source" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:sources") - success_message = _("Successfully deleted Source") diff --git a/passbook/admin/views/stages.py b/passbook/admin/views/stages.py deleted file mode 100644 index 72619b39..00000000 --- a/passbook/admin/views/stages.py +++ /dev/null @@ -1,79 +0,0 @@ -"""passbook Stage administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - InheritanceCreateView, - InheritanceListView, - InheritanceUpdateView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.flows.models import Stage - - -class StageListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - InheritanceListView, -): - """Show list of all stages""" - - model = Stage - template_name = "administration/stage/list.html" - permission_required = "passbook_flows.view_stage" - ordering = "name" - search_fields = ["name"] - - -class StageCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - InheritanceCreateView, -): - """Create new Stage""" - - model = Stage - template_name = "generic/create.html" - permission_required = "passbook_flows.add_stage" - - success_url = reverse_lazy("passbook_admin:stages") - success_message = _("Successfully created Stage") - - -class StageUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - InheritanceUpdateView, -): - """Update stage""" - - model = Stage - permission_required = "passbook_flows.update_application" - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:stages") - success_message = _("Successfully updated Stage") - - -class StageDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete stage""" - - model = Stage - template_name = "generic/delete.html" - permission_required = "passbook_flows.delete_stage" - success_url = reverse_lazy("passbook_admin:stages") - success_message = _("Successfully deleted Stage") diff --git a/passbook/admin/views/stages_bindings.py b/passbook/admin/views/stages_bindings.py deleted file mode 100644 index dfac12cc..00000000 --- a/passbook/admin/views/stages_bindings.py +++ /dev/null @@ -1,79 +0,0 @@ -"""passbook StageBinding administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - UserPaginateListMixin, -) -from passbook.flows.forms import FlowStageBindingForm -from passbook.flows.models import FlowStageBinding -from passbook.lib.views import CreateAssignPermView - - -class StageBindingListView( - LoginRequiredMixin, PermissionListMixin, UserPaginateListMixin, ListView -): - """Show list of all flows""" - - model = FlowStageBinding - permission_required = "passbook_flows.view_flowstagebinding" - ordering = ["target", "order"] - template_name = "administration/stage_binding/list.html" - - -class StageBindingCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new StageBinding""" - - model = FlowStageBinding - permission_required = "passbook_flows.add_flowstagebinding" - form_class = FlowStageBindingForm - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:stage-bindings") - success_message = _("Successfully created StageBinding") - - -class StageBindingUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update FlowStageBinding""" - - model = FlowStageBinding - permission_required = "passbook_flows.change_flowstagebinding" - form_class = FlowStageBindingForm - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:stage-bindings") - success_message = _("Successfully updated StageBinding") - - -class StageBindingDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete FlowStageBinding""" - - model = FlowStageBinding - permission_required = "passbook_flows.delete_flowstagebinding" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:stage-bindings") - success_message = _("Successfully deleted FlowStageBinding") diff --git a/passbook/admin/views/stages_invitations.py b/passbook/admin/views/stages_invitations.py deleted file mode 100644 index 9765cf5c..00000000 --- a/passbook/admin/views/stages_invitations.py +++ /dev/null @@ -1,76 +0,0 @@ -"""passbook Invitation administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpResponseRedirect -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.lib.views import CreateAssignPermView -from passbook.stages.invitation.forms import InvitationForm -from passbook.stages.invitation.models import Invitation -from passbook.stages.invitation.signals import invitation_created - - -class InvitationListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all invitations""" - - model = Invitation - permission_required = "passbook_stages_invitation.view_invitation" - template_name = "administration/stage_invitation/list.html" - ordering = "-expires" - search_fields = ["created_by__username", "expires", "fixed_data"] - - -class InvitationCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Invitation""" - - model = Invitation - form_class = InvitationForm - permission_required = "passbook_stages_invitation.add_invitation" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:stage-invitations") - success_message = _("Successfully created Invitation") - - def form_valid(self, form): - obj = form.save(commit=False) - obj.created_by = self.request.user - obj.save() - invitation_created.send(sender=self, request=self.request, invitation=obj) - return HttpResponseRedirect(self.success_url) - - -class InvitationDeleteView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView -): - """Delete invitation""" - - model = Invitation - permission_required = "passbook_stages_invitation.delete_invitation" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:stage-invitations") - success_message = _("Successfully deleted Invitation") diff --git a/passbook/admin/views/stages_prompts.py b/passbook/admin/views/stages_prompts.py deleted file mode 100644 index fb72e147..00000000 --- a/passbook/admin/views/stages_prompts.py +++ /dev/null @@ -1,88 +0,0 @@ -"""passbook Prompt administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.lib.views import CreateAssignPermView -from passbook.stages.prompt.forms import PromptAdminForm -from passbook.stages.prompt.models import Prompt - - -class PromptListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all prompts""" - - model = Prompt - permission_required = "passbook_stages_prompt.view_prompt" - ordering = "order" - template_name = "administration/stage_prompt/list.html" - search_fields = [ - "field_key", - "label", - "type", - "placeholder", - ] - - -class PromptCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Prompt""" - - model = Prompt - form_class = PromptAdminForm - permission_required = "passbook_stages_prompt.add_prompt" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:stage-prompts") - success_message = _("Successfully created Prompt") - - -class PromptUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update prompt""" - - model = Prompt - form_class = PromptAdminForm - permission_required = "passbook_stages_prompt.change_prompt" - - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:stage-prompts") - success_message = _("Successfully updated Prompt") - - -class PromptDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete prompt""" - - model = Prompt - permission_required = "passbook_stages_prompt.delete_prompt" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:stage-prompts") - success_message = _("Successfully deleted Prompt") diff --git a/passbook/admin/views/tasks.py b/passbook/admin/views/tasks.py deleted file mode 100644 index 7d0be779..00000000 --- a/passbook/admin/views/tasks.py +++ /dev/null @@ -1,23 +0,0 @@ -"""passbook Tasks List""" -from typing import Any, Dict - -from django.views.generic.base import TemplateView - -from passbook.admin.mixins import AdminRequiredMixin -from passbook.lib.tasks import TaskInfo, TaskResultStatus - - -class TaskListView(AdminRequiredMixin, TemplateView): - """Show list of all background tasks""" - - template_name = "administration/task/list.html" - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - kwargs["object_list"] = sorted( - TaskInfo.all().values(), key=lambda x: x.task_name - ) - kwargs["task_successful"] = TaskResultStatus.SUCCESSFUL - kwargs["task_warning"] = TaskResultStatus.WARNING - kwargs["task_error"] = TaskResultStatus.ERROR - return kwargs diff --git a/passbook/admin/views/tokens.py b/passbook/admin/views/tokens.py deleted file mode 100644 index 86e26df3..00000000 --- a/passbook/admin/views/tokens.py +++ /dev/null @@ -1,45 +0,0 @@ -"""passbook Token administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin - -from passbook.admin.views.utils import ( - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.models import Token - - -class TokenListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all tokens""" - - model = Token - permission_required = "passbook_core.view_token" - ordering = "expires" - template_name = "administration/token/list.html" - search_fields = [ - "identifier", - "intent", - "user__username", - "description", - ] - - -class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete token""" - - model = Token - permission_required = "passbook_core.delete_token" - - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:tokens") - success_message = _("Successfully deleted Token") diff --git a/passbook/admin/views/users.py b/passbook/admin/views/users.py deleted file mode 100644 index 215e13f3..00000000 --- a/passbook/admin/views/users.py +++ /dev/null @@ -1,168 +0,0 @@ -"""passbook User administration""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpRequest, HttpResponse -from django.http.response import HttpResponseRedirect -from django.shortcuts import redirect -from django.urls import reverse, reverse_lazy -from django.utils.http import urlencode -from django.utils.translation import gettext as _ -from django.views.generic import DetailView, ListView, UpdateView -from guardian.mixins import ( - PermissionListMixin, - PermissionRequiredMixin, - get_anonymous_user, -) - -from passbook.admin.forms.users import UserForm -from passbook.admin.views.utils import ( - BackSuccessUrlMixin, - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.models import Token, User -from passbook.lib.views import CreateAssignPermView - - -class UserListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all users""" - - model = User - permission_required = "passbook_core.view_user" - ordering = "username" - template_name = "administration/user/list.html" - search_fields = ["username", "name", "attributes"] - - def get_queryset(self): - return super().get_queryset().exclude(pk=get_anonymous_user().pk) - - -class UserCreateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create user""" - - model = User - form_class = UserForm - permission_required = "passbook_core.add_user" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_admin:users") - success_message = _("Successfully created User") - - -class UserUpdateView( - SuccessMessageMixin, - BackSuccessUrlMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update user""" - - model = User - form_class = UserForm - permission_required = "passbook_core.change_user" - - # By default the object's name is user which is used by other checks - context_object_name = "object" - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_admin:users") - success_message = _("Successfully updated User") - - -class UserDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete user""" - - model = User - permission_required = "passbook_core.delete_user" - - # By default the object's name is user which is used by other checks - context_object_name = "object" - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_admin:users") - success_message = _("Successfully deleted User") - - -class UserDisableView( - LoginRequiredMixin, PermissionRequiredMixin, BackSuccessUrlMixin, DeleteMessageView -): - """Disable user""" - - object: User - - model = User - permission_required = "passbook_core.update_user" - - # By default the object's name is user which is used by other checks - context_object_name = "object" - template_name = "administration/user/disable.html" - success_url = reverse_lazy("passbook_admin:users") - success_message = _("Successfully disabled User") - - def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - self.object: User = self.get_object() - success_url = self.get_success_url() - self.object.is_active = False - self.object.save() - return HttpResponseRedirect(success_url) - - -class UserEnableView( - LoginRequiredMixin, PermissionRequiredMixin, BackSuccessUrlMixin, DetailView -): - """Enable user""" - - object: User - - model = User - permission_required = "passbook_core.update_user" - - # By default the object's name is user which is used by other checks - context_object_name = "object" - success_url = reverse_lazy("passbook_admin:users") - success_message = _("Successfully enabled User") - - def get(self, request: HttpRequest, *args, **kwargs): - self.object: User = self.get_object() - success_url = self.get_success_url() - self.object.is_active = True - self.object.save() - return HttpResponseRedirect(success_url) - - -class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): - """Get Password reset link for user""" - - model = User - permission_required = "passbook_core.reset_user_password" - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Create token for user and return link""" - super().get(request, *args, **kwargs) - token, __ = Token.objects.get_or_create( - identifier="password-reset-temp", user=self.object - ) - querystring = urlencode({"token": token.key}) - link = request.build_absolute_uri( - reverse("passbook_flows:default-recovery") + f"?{querystring}" - ) - messages.success( - request, _("Password reset link:
%(link)s
" % {"link": link}) - ) - return redirect("passbook_admin:users") diff --git a/passbook/admin/views/utils.py b/passbook/admin/views/utils.py deleted file mode 100644 index f9be4955..00000000 --- a/passbook/admin/views/utils.py +++ /dev/null @@ -1,124 +0,0 @@ -"""passbook admin util views""" -from typing import Any, Dict, List, Optional -from urllib.parse import urlparse - -from django.contrib import messages -from django.contrib.messages.views import SuccessMessageMixin -from django.contrib.postgres.search import SearchQuery, SearchVector -from django.db.models import QuerySet -from django.http import Http404 -from django.http.request import HttpRequest -from django.views.generic import DeleteView, ListView, UpdateView -from django.views.generic.list import MultipleObjectMixin - -from passbook.lib.utils.reflection import all_subclasses -from passbook.lib.views import CreateAssignPermView - - -class DeleteMessageView(SuccessMessageMixin, DeleteView): - """DeleteView which shows `self.success_message` on successful deletion""" - - def delete(self, request, *args, **kwargs): - messages.success(self.request, self.success_message) - return super().delete(request, *args, **kwargs) - - -class InheritanceListView(ListView): - """ListView for objects using InheritanceManager""" - - def get_context_data(self, **kwargs): - kwargs["types"] = {x.__name__: x for x in all_subclasses(self.model)} - return super().get_context_data(**kwargs) - - def get_queryset(self): - return super().get_queryset().select_subclasses() - - -class SearchListMixin(MultipleObjectMixin): - """Accept search query using `search` querystring parameter. Requires self.search_fields, - a list of all fields to search. Can contain special lookups like __icontains""" - - search_fields: List[str] - - def get_queryset(self) -> QuerySet: - queryset = super().get_queryset() - if "search" in self.request.GET: - raw_query = self.request.GET["search"] - if raw_query == "": - # Empty query, don't search at all - return queryset - search = SearchQuery(raw_query, search_type="websearch") - return queryset.annotate(search=SearchVector(*self.search_fields)).filter( - search=search - ) - return queryset - - -class InheritanceCreateView(CreateAssignPermView): - """CreateView for objects using InheritanceManager""" - - def get_form_class(self): - provider_type = self.request.GET.get("type") - try: - model = next( - x for x in all_subclasses(self.model) if x.__name__ == provider_type - ) - except StopIteration as exc: - raise Http404 from exc - return model().form - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - form_cls = self.get_form_class() - if hasattr(form_cls, "template_name"): - kwargs["base_template"] = form_cls.template_name - return kwargs - - -class InheritanceUpdateView(UpdateView): - """UpdateView for objects using InheritanceManager""" - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - form_cls = self.get_form_class() - if hasattr(form_cls, "template_name"): - kwargs["base_template"] = form_cls.template_name - return kwargs - - def get_form_class(self): - return self.get_object().form - - def get_object(self, queryset=None): - return ( - self.model.objects.filter(pk=self.kwargs.get("pk")) - .select_subclasses() - .first() - ) - - -class BackSuccessUrlMixin: - """Checks if a relative URL has been given as ?back param, and redirect to it. Otherwise - default to self.success_url.""" - - request: HttpRequest - - success_url: Optional[str] - - def get_success_url(self) -> str: - """get_success_url from FormMixin""" - back_param = self.request.GET.get("back") - if back_param: - if not bool(urlparse(back_param).netloc): - return back_param - return str(self.success_url) - - -class UserPaginateListMixin: - """Get paginate_by value from user's attributes, defaulting to 15""" - - request: HttpRequest - - # pylint: disable=unused-argument - def get_paginate_by(self, queryset: QuerySet) -> int: - """get_paginate_by Function of ListView""" - return self.request.user.attributes.get("paginate_by", 15) diff --git a/passbook/api/apps.py b/passbook/api/apps.py deleted file mode 100644 index 529d898a..00000000 --- a/passbook/api/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook API AppConfig""" - -from django.apps import AppConfig - - -class PassbookAPIConfig(AppConfig): - """passbook API Config""" - - name = "passbook.api" - label = "passbook_api" - mountpoint = "api/" - verbose_name = "passbook API" diff --git a/passbook/api/auth.py b/passbook/api/auth.py deleted file mode 100644 index e1e1007f..00000000 --- a/passbook/api/auth.py +++ /dev/null @@ -1,57 +0,0 @@ -"""API Authentication""" -from base64 import b64decode -from typing import Any, Optional, Tuple, Union - -from rest_framework.authentication import BaseAuthentication, get_authorization_header -from rest_framework.request import Request -from structlog import get_logger - -from passbook.core.models import Token, TokenIntents, User - -LOGGER = get_logger() - - -def token_from_header(raw_header: bytes) -> Optional[Token]: - """raw_header in the Format of `Basic dGVzdDp0ZXN0`""" - auth_credentials = raw_header.decode() - # Accept headers with Type format and without - if " " in auth_credentials: - auth_type, auth_credentials = auth_credentials.split() - if auth_type.lower() != "basic": - LOGGER.debug( - "Unsupported authentication type, denying", type=auth_type.lower() - ) - return None - try: - auth_credentials = b64decode(auth_credentials.encode()).decode() - except UnicodeDecodeError: - return None - # Accept credentials with username and without - if ":" in auth_credentials: - _, password = auth_credentials.split(":") - else: - password = auth_credentials - if password == "": - return None - tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API) - if not tokens.exists(): - LOGGER.debug("Token not found") - return None - return tokens.first() - - -class PassbookTokenAuthentication(BaseAuthentication): - """Token-based authentication using HTTP Basic authentication""" - - def authenticate(self, request: Request) -> Union[Tuple[User, Any], None]: - """Token-based authentication using HTTP Basic authentication""" - auth = get_authorization_header(request) - - token = token_from_header(auth) - if not token: - return None - - return (token.user, None) - - def authenticate_header(self, request: Request) -> str: - return 'Basic realm="passbook"' diff --git a/passbook/api/templates/rest_framework/api.html b/passbook/api/templates/rest_framework/api.html deleted file mode 100644 index 604490af..00000000 --- a/passbook/api/templates/rest_framework/api.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "rest_framework/base.html" %} - -{% block branding %} - - passbook - -{% endblock %} diff --git a/passbook/api/urls.py b/passbook/api/urls.py deleted file mode 100644 index 615334c5..00000000 --- a/passbook/api/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -"""passbook api urls""" -from django.urls import include, path - -from passbook.api.v2.urls import urlpatterns as v2_urls - -urlpatterns = [ - path("v2beta/", include(v2_urls)), -] diff --git a/passbook/api/v2/config.py b/passbook/api/v2/config.py deleted file mode 100644 index a528ed41..00000000 --- a/passbook/api/v2/config.py +++ /dev/null @@ -1,46 +0,0 @@ -"""core Configs API""" -from drf_yasg2.utils import swagger_auto_schema -from rest_framework.permissions import AllowAny -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ReadOnlyField, Serializer -from rest_framework.viewsets import ViewSet - -from passbook.lib.config import CONFIG - - -class ConfigSerializer(Serializer): - """Serialize passbook Config into DRF Object""" - - branding_logo = ReadOnlyField() - branding_title = ReadOnlyField() - - error_reporting_enabled = ReadOnlyField() - error_reporting_environment = ReadOnlyField() - error_reporting_send_pii = ReadOnlyField() - - def create(self, request: Request) -> Response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - - -class ConfigsViewSet(ViewSet): - """Read-only view set that returns the current session's Configs""" - - permission_classes = [AllowAny] - - @swagger_auto_schema(responses={200: ConfigSerializer(many=True)}) - def list(self, request: Request) -> Response: - """Retrive public configuration options""" - config = ConfigSerializer( - { - "branding_logo": CONFIG.y("passbook.branding.logo"), - "branding_title": CONFIG.y("passbook.branding.title"), - "error_reporting_enabled": CONFIG.y("error_reporting.enabled"), - "error_reporting_environment": CONFIG.y("error_reporting.environment"), - "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"), - } - ) - return Response(config.data) diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py deleted file mode 100644 index 0aba5a79..00000000 --- a/passbook/api/v2/urls.py +++ /dev/null @@ -1,157 +0,0 @@ -"""api v2 urls""" -from django.urls import path, re_path -from drf_yasg2 import openapi -from drf_yasg2.views import get_schema_view -from rest_framework import routers -from rest_framework.permissions import AllowAny - -from passbook.admin.api.overview import AdministrationOverviewViewSet -from passbook.admin.api.overview_metrics import AdministrationMetricsViewSet -from passbook.admin.api.tasks import TaskViewSet -from passbook.api.v2.config import ConfigsViewSet -from passbook.api.v2.messages import MessagesViewSet -from passbook.audit.api import EventViewSet -from passbook.core.api.applications import ApplicationViewSet -from passbook.core.api.groups import GroupViewSet -from passbook.core.api.propertymappings import PropertyMappingViewSet -from passbook.core.api.providers import ProviderViewSet -from passbook.core.api.sources import SourceViewSet -from passbook.core.api.tokens import TokenViewSet -from passbook.core.api.users import UserViewSet -from passbook.crypto.api import CertificateKeyPairViewSet -from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet -from passbook.outposts.api import ( - DockerServiceConnectionViewSet, - KubernetesServiceConnectionViewSet, - OutpostViewSet, -) -from passbook.policies.api import PolicyBindingViewSet, PolicyViewSet -from passbook.policies.dummy.api import DummyPolicyViewSet -from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet -from passbook.policies.expression.api import ExpressionPolicyViewSet -from passbook.policies.group_membership.api import GroupMembershipPolicyViewSet -from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet -from passbook.policies.password.api import PasswordPolicyViewSet -from passbook.policies.reputation.api import ReputationPolicyViewSet -from passbook.providers.oauth2.api import OAuth2ProviderViewSet, ScopeMappingViewSet -from passbook.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet -from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet -from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet -from passbook.sources.oauth.api import OAuthSourceViewSet -from passbook.sources.saml.api import SAMLSourceViewSet -from passbook.stages.captcha.api import CaptchaStageViewSet -from passbook.stages.consent.api import ConsentStageViewSet -from passbook.stages.dummy.api import DummyStageViewSet -from passbook.stages.email.api import EmailStageViewSet -from passbook.stages.identification.api import IdentificationStageViewSet -from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet -from passbook.stages.otp_static.api import OTPStaticStageViewSet -from passbook.stages.otp_time.api import OTPTimeStageViewSet -from passbook.stages.otp_validate.api import OTPValidateStageViewSet -from passbook.stages.password.api import PasswordStageViewSet -from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet -from passbook.stages.user_delete.api import UserDeleteStageViewSet -from passbook.stages.user_login.api import UserLoginStageViewSet -from passbook.stages.user_logout.api import UserLogoutStageViewSet -from passbook.stages.user_write.api import UserWriteStageViewSet - -router = routers.DefaultRouter() - -router.register("root/messages", MessagesViewSet, basename="messages") -router.register("root/config", ConfigsViewSet, basename="configs") - -router.register( - "admin/overview", AdministrationOverviewViewSet, basename="admin_overview" -) -router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics") -router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") - -router.register("core/applications", ApplicationViewSet) -router.register("core/groups", GroupViewSet) -router.register("core/users", UserViewSet) -router.register("core/tokens", TokenViewSet) - -router.register("outposts/outposts", OutpostViewSet) -router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) -router.register( - "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet -) -router.register("outposts/proxy", ProxyOutpostConfigViewSet) - -router.register("flows/instances", FlowViewSet) -router.register("flows/bindings", FlowStageBindingViewSet) - -router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet) - -router.register("audit/events", EventViewSet) - -router.register("sources/all", SourceViewSet) -router.register("sources/ldap", LDAPSourceViewSet) -router.register("sources/saml", SAMLSourceViewSet) -router.register("sources/oauth", OAuthSourceViewSet) - -router.register("policies/all", PolicyViewSet) -router.register("policies/bindings", PolicyBindingViewSet) -router.register("policies/expression", ExpressionPolicyViewSet) -router.register("policies/group_membership", GroupMembershipPolicyViewSet) -router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) -router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) -router.register("policies/password", PasswordPolicyViewSet) -router.register("policies/reputation", ReputationPolicyViewSet) - -router.register("providers/all", ProviderViewSet) -router.register("providers/proxy", ProxyProviderViewSet) -router.register("providers/oauth2", OAuth2ProviderViewSet) -router.register("providers/saml", SAMLProviderViewSet) - -router.register("propertymappings/all", PropertyMappingViewSet) -router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) -router.register("propertymappings/saml", SAMLPropertyMappingViewSet) -router.register("propertymappings/scope", ScopeMappingViewSet) - -router.register("stages/all", StageViewSet) -router.register("stages/captcha", CaptchaStageViewSet) -router.register("stages/consent", ConsentStageViewSet) -router.register("stages/email", EmailStageViewSet) -router.register("stages/identification", IdentificationStageViewSet) -router.register("stages/invitation", InvitationStageViewSet) -router.register("stages/invitation/invitations", InvitationViewSet) -router.register("stages/otp_static", OTPStaticStageViewSet) -router.register("stages/otp_time", OTPTimeStageViewSet) -router.register("stages/otp_validate", OTPValidateStageViewSet) -router.register("stages/password", PasswordStageViewSet) -router.register("stages/prompt/prompts", PromptViewSet) -router.register("stages/prompt/stages", PromptStageViewSet) -router.register("stages/user_delete", UserDeleteStageViewSet) -router.register("stages/user_login", UserLoginStageViewSet) -router.register("stages/user_logout", UserLogoutStageViewSet) -router.register("stages/user_write", UserWriteStageViewSet) - -router.register("stages/dummy", DummyStageViewSet) -router.register("policies/dummy", DummyPolicyViewSet) - -info = openapi.Info( - title="passbook API", - default_version="v2", - contact=openapi.Contact(email="hello@beryju.org"), - license=openapi.License(name="MIT License"), -) -SchemaView = get_schema_view( - info, - public=True, - permission_classes=(AllowAny,), -) - -urlpatterns = [ - re_path( - r"^swagger(?P\.json|\.yaml)$", - SchemaView.without_ui(cache_timeout=0), - name="schema-json", - ), - path( - "swagger/", - SchemaView.with_ui("swagger", cache_timeout=0), - name="schema-swagger-ui", - ), - path("redoc/", SchemaView.with_ui("redoc", cache_timeout=0), name="schema-redoc"), -] + router.urls diff --git a/passbook/audit/api.py b/passbook/audit/api.py deleted file mode 100644 index e19b5ecf..00000000 --- a/passbook/audit/api.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Audit API Views""" -from django.db.models.aggregates import Count -from django.db.models.fields.json import KeyTextTransform -from drf_yasg2.utils import swagger_auto_schema -from rest_framework.decorators import action -from rest_framework.fields import DictField, IntegerField -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer, Serializer -from rest_framework.viewsets import ReadOnlyModelViewSet - -from passbook.audit.models import Event, EventAction - - -class EventSerializer(ModelSerializer): - """Event Serializer""" - - class Meta: - - model = Event - fields = [ - "pk", - "user", - "action", - "app", - "context", - "client_ip", - "created", - ] - - -class EventTopPerUserSerialier(Serializer): - """Response object of Event's top_per_user""" - - application = DictField() - counted_events = IntegerField() - unique_users = IntegerField() - - def create(self, request: Request) -> Response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - - -class EventViewSet(ReadOnlyModelViewSet): - """Event Read-Only Viewset""" - - queryset = Event.objects.all() - serializer_class = EventSerializer - - @swagger_auto_schema( - method="GET", responses={200: EventTopPerUserSerialier(many=True)} - ) - @action(detail=False, methods=["GET"]) - def top_per_user(self, request: Request): - """Get the top_n events grouped by user count""" - filtered_action = request.query_params.get("filter_action", EventAction.LOGIN) - top_n = request.query_params.get("top_n", 15) - return Response( - Event.objects.filter(action=filtered_action) - .exclude(context__authorized_application=None) - .annotate(application=KeyTextTransform("authorized_application", "context")) - .annotate(user_pk=KeyTextTransform("pk", "user")) - .values("application") - .annotate(counted_events=Count("application")) - .annotate(unique_users=Count("user_pk", distinct=True)) - .values("unique_users", "application", "counted_events") - .order_by("-counted_events")[:top_n] - ) diff --git a/passbook/audit/apps.py b/passbook/audit/apps.py deleted file mode 100644 index b0a9580d..00000000 --- a/passbook/audit/apps.py +++ /dev/null @@ -1,16 +0,0 @@ -"""passbook audit app""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookAuditConfig(AppConfig): - """passbook audit app""" - - name = "passbook.audit" - label = "passbook_audit" - verbose_name = "passbook Audit" - mountpoint = "audit/" - - def ready(self): - import_module("passbook.audit.signals") diff --git a/passbook/audit/middleware.py b/passbook/audit/middleware.py deleted file mode 100644 index 76959126..00000000 --- a/passbook/audit/middleware.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Audit middleware""" -from functools import partial -from typing import Callable - -from django.contrib.auth.models import User -from django.db.models import Model -from django.db.models.signals import post_save, pre_delete -from django.http import HttpRequest, HttpResponse - -from passbook.audit.models import Event, EventAction, model_to_dict -from passbook.audit.signals import EventNewThread -from passbook.core.middleware import LOCAL - - -class AuditMiddleware: - """Register handlers for duration of request-response that log creation/update/deletion - of models""" - - get_response: Callable[[HttpRequest], HttpResponse] - - def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): - self.get_response = get_response - - def __call__(self, request: HttpRequest) -> HttpResponse: - # Connect signal for automatic logging - if hasattr(request, "user") and getattr( - request.user, "is_authenticated", False - ): - post_save_handler = partial( - self.post_save_handler, user=request.user, request=request - ) - pre_delete_handler = partial( - self.pre_delete_handler, user=request.user, request=request - ) - post_save.connect( - post_save_handler, - dispatch_uid=LOCAL.passbook["request_id"], - weak=False, - ) - pre_delete.connect( - pre_delete_handler, - dispatch_uid=LOCAL.passbook["request_id"], - weak=False, - ) - - response = self.get_response(request) - - post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"]) - pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"]) - - return response - - # pylint: disable=unused-argument - def process_exception(self, request: HttpRequest, exception: Exception): - """Unregister handlers in case of exception""" - post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"]) - pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"]) - - @staticmethod - # pylint: disable=unused-argument - def post_save_handler( - user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ - ): - """Signal handler for all object's post_save""" - if isinstance(instance, Event): - return - - action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED - EventNewThread(action, request, user=user, model=model_to_dict(instance)).run() - - @staticmethod - # pylint: disable=unused-argument - def pre_delete_handler( - user: User, request: HttpRequest, sender, instance: Model, **_ - ): - """Signal handler for all object's pre_delete""" - if isinstance(instance, Event): - return - - EventNewThread( - EventAction.MODEL_DELETED, - request, - user=user, - model=model_to_dict(instance), - ).run() diff --git a/passbook/audit/migrations/0002_auto_20200918_2116.py b/passbook/audit/migrations/0002_auto_20200918_2116.py deleted file mode 100644 index d645c11c..00000000 --- a/passbook/audit/migrations/0002_auto_20200918_2116.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-18 21:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_audit", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="event", - name="action", - field=models.TextField( - choices=[ - ("LOGIN", "login"), - ("LOGIN_FAILED", "login_failed"), - ("LOGOUT", "logout"), - ("AUTHORIZE_APPLICATION", "authorize_application"), - ("SUSPICIOUS_REQUEST", "suspicious_request"), - ("SIGN_UP", "sign_up"), - ("PASSWORD_RESET", "password_reset"), - ("INVITE_CREATED", "invitation_created"), - ("INVITE_USED", "invitation_used"), - ("IMPERSONATION_STARTED", "impersonation_started"), - ("IMPERSONATION_ENDED", "impersonation_ended"), - ("CUSTOM", "custom"), - ] - ), - ), - ] diff --git a/passbook/audit/migrations/0003_auto_20200917_1155.py b/passbook/audit/migrations/0003_auto_20200917_1155.py deleted file mode 100644 index d36cee59..00000000 --- a/passbook/audit/migrations/0003_auto_20200917_1155.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-17 11:55 -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -import passbook.audit.models - - -def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - Event = apps.get_model("passbook_audit", "Event") - - db_alias = schema_editor.connection.alias - for event in Event.objects.all(): - event.delete() - # Because event objects cannot be updated, we have to re-create them - event.pk = None - event.user_json = ( - passbook.audit.models.get_user(event.user) if event.user else {} - ) - event._state.adding = True - event.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_audit", "0002_auto_20200918_2116"), - ] - - operations = [ - migrations.AlterField( - model_name="event", - name="action", - field=models.TextField( - choices=[ - ("LOGIN", "login"), - ("LOGIN_FAILED", "login_failed"), - ("LOGOUT", "logout"), - ("AUTHORIZE_APPLICATION", "authorize_application"), - ("SUSPICIOUS_REQUEST", "suspicious_request"), - ("SIGN_UP", "sign_up"), - ("PASSWORD_RESET", "password_reset"), - ("INVITE_CREATED", "invitation_created"), - ("INVITE_USED", "invitation_used"), - ("IMPERSONATION_STARTED", "impersonation_started"), - ("IMPERSONATION_ENDED", "impersonation_ended"), - ("CUSTOM", "custom"), - ] - ), - ), - migrations.AddField( - model_name="event", - name="user_json", - field=models.JSONField(default=dict), - ), - migrations.RunPython(convert_user_to_json), - migrations.RemoveField( - model_name="event", - name="user", - ), - migrations.RenameField( - model_name="event", old_name="user_json", new_name="user" - ), - ] diff --git a/passbook/audit/migrations/0004_auto_20200921_1829.py b/passbook/audit/migrations/0004_auto_20200921_1829.py deleted file mode 100644 index ee5ec816..00000000 --- a/passbook/audit/migrations/0004_auto_20200921_1829.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-21 18:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_audit", "0003_auto_20200917_1155"), - ] - - operations = [ - migrations.AlterField( - model_name="event", - name="action", - field=models.TextField( - choices=[ - ("login", "Login"), - ("login_failed", "Login Failed"), - ("logout", "Logout"), - ("sign_up", "Sign Up"), - ("authorize_application", "Authorize Application"), - ("suspicious_request", "Suspicious Request"), - ("password_set", "Password Set"), - ("invitation_created", "Invite Created"), - ("invitation_used", "Invite Used"), - ("source_linked", "Source Linked"), - ("impersonation_started", "Impersonation Started"), - ("impersonation_ended", "Impersonation Ended"), - ("model_created", "Model Created"), - ("model_updated", "Model Updated"), - ("model_deleted", "Model Deleted"), - ("custom_", "Custom Prefix"), - ] - ), - ), - ] diff --git a/passbook/audit/migrations/0005_auto_20201005_2139.py b/passbook/audit/migrations/0005_auto_20201005_2139.py deleted file mode 100644 index 558c58ab..00000000 --- a/passbook/audit/migrations/0005_auto_20201005_2139.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-05 21:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_audit", "0004_auto_20200921_1829"), - ] - - operations = [ - migrations.AlterField( - model_name="event", - name="action", - field=models.TextField( - choices=[ - ("login", "Login"), - ("login_failed", "Login Failed"), - ("logout", "Logout"), - ("user_write", "User Write"), - ("suspicious_request", "Suspicious Request"), - ("password_set", "Password Set"), - ("invitation_created", "Invite Created"), - ("invitation_used", "Invite Used"), - ("authorize_application", "Authorize Application"), - ("source_linked", "Source Linked"), - ("impersonation_started", "Impersonation Started"), - ("impersonation_ended", "Impersonation Ended"), - ("model_created", "Model Created"), - ("model_updated", "Model Updated"), - ("model_deleted", "Model Deleted"), - ("custom_", "Custom Prefix"), - ] - ), - ), - ] diff --git a/passbook/audit/migrations/0006_auto_20201017_2024.py b/passbook/audit/migrations/0006_auto_20201017_2024.py deleted file mode 100644 index 89a0fdc5..00000000 --- a/passbook/audit/migrations/0006_auto_20201017_2024.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-17 20:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_audit", "0005_auto_20201005_2139"), - ] - - operations = [ - migrations.RemoveField( - model_name="event", - name="date", - ), - migrations.AlterField( - model_name="event", - name="action", - field=models.TextField( - choices=[ - ("login", "Login"), - ("login_failed", "Login Failed"), - ("logout", "Logout"), - ("user_write", "User Write"), - ("suspicious_request", "Suspicious Request"), - ("password_set", "Password Set"), - ("token_view", "Token View"), - ("invitation_created", "Invite Created"), - ("invitation_used", "Invite Used"), - ("authorize_application", "Authorize Application"), - ("source_linked", "Source Linked"), - ("impersonation_started", "Impersonation Started"), - ("impersonation_ended", "Impersonation Ended"), - ("model_created", "Model Created"), - ("model_updated", "Model Updated"), - ("model_deleted", "Model Deleted"), - ("custom_", "Custom Prefix"), - ] - ), - ), - ] diff --git a/passbook/audit/models.py b/passbook/audit/models.py deleted file mode 100644 index c589a534..00000000 --- a/passbook/audit/models.py +++ /dev/null @@ -1,199 +0,0 @@ -"""passbook audit models""" -from inspect import getmodule, stack -from typing import Any, Dict, Optional, Union -from uuid import UUID, uuid4 - -from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import ValidationError -from django.db import models -from django.db.models.base import Model -from django.http import HttpRequest -from django.utils.translation import gettext as _ -from django.views.debug import SafeExceptionReporterFilter -from guardian.utils import get_anonymous_user -from structlog import get_logger - -from passbook.core.middleware import ( - SESSION_IMPERSONATE_ORIGINAL_USER, - SESSION_IMPERSONATE_USER, -) -from passbook.core.models import User -from passbook.lib.utils.http import get_client_ip - -LOGGER = get_logger("passbook.audit") - - -def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: - """Cleanse a dictionary, recursively""" - final_dict = {} - for key, value in source.items(): - try: - if SafeExceptionReporterFilter.hidden_settings.search(key): - final_dict[key] = SafeExceptionReporterFilter.cleansed_substitute - else: - final_dict[key] = value - except TypeError: - final_dict[key] = value - if isinstance(value, dict): - final_dict[key] = cleanse_dict(value) - return final_dict - - -def model_to_dict(model: Model) -> Dict[str, Any]: - """Convert model to dict""" - name = str(model) - if hasattr(model, "name"): - name = model.name - return { - "app": model._meta.app_label, - "model_name": model._meta.model_name, - "pk": model.pk, - "name": name, - } - - -def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]: - """Convert user object to dictionary, optionally including the original user""" - if isinstance(user, AnonymousUser): - user = get_anonymous_user() - user_data = { - "username": user.username, - "pk": user.pk, - "email": user.email, - } - if original_user: - original_data = get_user(original_user) - original_data["on_behalf_of"] = user_data - return original_data - return user_data - - -def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: - """clean source of all Models that would interfere with the JSONField. - Models are replaced with a dictionary of { - app: str, - name: str, - pk: Any - }""" - final_dict = {} - for key, value in source.items(): - if isinstance(value, dict): - final_dict[key] = sanitize_dict(value) - elif isinstance(value, models.Model): - final_dict[key] = sanitize_dict(model_to_dict(value)) - elif isinstance(value, UUID): - final_dict[key] = value.hex - else: - final_dict[key] = value - return final_dict - - -class EventAction(models.TextChoices): - """All possible actions to save into the audit log""" - - LOGIN = "login" - LOGIN_FAILED = "login_failed" - LOGOUT = "logout" - - USER_WRITE = "user_write" - SUSPICIOUS_REQUEST = "suspicious_request" - PASSWORD_SET = "password_set" # noqa # nosec - - TOKEN_VIEW = "token_view" - - INVITE_CREATED = "invitation_created" - INVITE_USED = "invitation_used" - - AUTHORIZE_APPLICATION = "authorize_application" - SOURCE_LINKED = "source_linked" - - IMPERSONATION_STARTED = "impersonation_started" - IMPERSONATION_ENDED = "impersonation_ended" - - MODEL_CREATED = "model_created" - MODEL_UPDATED = "model_updated" - MODEL_DELETED = "model_deleted" - - CUSTOM_PREFIX = "custom_" - - -class Event(models.Model): - """An individual audit log event""" - - event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - user = models.JSONField(default=dict) - action = models.TextField(choices=EventAction.choices) - app = models.TextField() - context = models.JSONField(default=dict, blank=True) - client_ip = models.GenericIPAddressField(null=True) - created = models.DateTimeField(auto_now_add=True) - - @staticmethod - def _get_app_from_request(request: HttpRequest) -> str: - if not isinstance(request, HttpRequest): - return "" - return request.resolver_match.app_name - - @staticmethod - def new( - action: Union[str, EventAction], - app: Optional[str] = None, - _inspect_offset: int = 1, - **kwargs, - ) -> "Event": - """Create new Event instance from arguments. Instance is NOT saved.""" - if not isinstance(action, EventAction): - action = EventAction.CUSTOM_PREFIX + action - if not app: - app = getmodule(stack()[_inspect_offset][0]).__name__ - cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs)) - event = Event(action=action, app=app, context=cleaned_kwargs) - return event - - def from_http( - self, request: HttpRequest, user: Optional[settings.AUTH_USER_MODEL] = None - ) -> "Event": - """Add data from a Django-HttpRequest, allowing the creation of - Events independently from requests. - `user` arguments optionally overrides user from requests.""" - if hasattr(request, "user"): - self.user = get_user( - request.user, - request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None), - ) - if user: - self.user = get_user(user) - # Check if we're currently impersonating, and add that user - if hasattr(request, "session"): - if SESSION_IMPERSONATE_ORIGINAL_USER in request.session: - self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER]) - self.user["on_behalf_of"] = get_user( - request.session[SESSION_IMPERSONATE_USER] - ) - # User 255.255.255.255 as fallback if IP cannot be determined - self.client_ip = get_client_ip(request) or "255.255.255.255" - # If there's no app set, we get it from the requests too - if not self.app: - self.app = Event._get_app_from_request(request) - self.save() - return self - - def save(self, *args, **kwargs): - if not self._state.adding: - raise ValidationError( - "you may not edit an existing %s" % self._meta.model_name - ) - LOGGER.debug( - "Created Audit event", - action=self.action, - context=self.context, - client_ip=self.client_ip, - user=self.user, - ) - return super().save(*args, **kwargs) - - class Meta: - - verbose_name = _("Audit Event") - verbose_name_plural = _("Audit Events") diff --git a/passbook/audit/signals.py b/passbook/audit/signals.py deleted file mode 100644 index 39305191..00000000 --- a/passbook/audit/signals.py +++ /dev/null @@ -1,107 +0,0 @@ -"""passbook audit signal listener""" -from threading import Thread -from typing import Any, Dict, Optional - -from django.contrib.auth.signals import ( - user_logged_in, - user_logged_out, - user_login_failed, -) -from django.dispatch import receiver -from django.http import HttpRequest - -from passbook.audit.models import Event, EventAction -from passbook.core.models import User -from passbook.core.signals import password_changed -from passbook.stages.invitation.models import Invitation -from passbook.stages.invitation.signals import invitation_created, invitation_used -from passbook.stages.user_write.signals import user_write - - -class EventNewThread(Thread): - """Create Event in background thread""" - - action: str - request: HttpRequest - kwargs: Dict[str, Any] - user: Optional[User] = None - - def __init__( - self, action: str, request: HttpRequest, user: Optional[User] = None, **kwargs - ): - super().__init__() - self.action = action - self.request = request - self.user = user - self.kwargs = kwargs - - def run(self): - Event.new(self.action, **self.kwargs).from_http(self.request, user=self.user) - - -@receiver(user_logged_in) -# pylint: disable=unused-argument -def on_user_logged_in(sender, request: HttpRequest, user: User, **_): - """Log successful login""" - thread = EventNewThread(EventAction.LOGIN, request) - thread.user = user - thread.run() - - -@receiver(user_logged_out) -# pylint: disable=unused-argument -def on_user_logged_out(sender, request: HttpRequest, user: User, **_): - """Log successfully logout""" - thread = EventNewThread(EventAction.LOGOUT, request) - thread.user = user - thread.run() - - -@receiver(user_write) -# pylint: disable=unused-argument -def on_user_write( - sender, request: HttpRequest, user: User, data: Dict[str, Any], **kwargs -): - """Log User write""" - thread = EventNewThread(EventAction.USER_WRITE, request, **data) - thread.kwargs["created"] = kwargs.get("created", False) - thread.user = user - thread.run() - - -@receiver(user_login_failed) -# pylint: disable=unused-argument -def on_user_login_failed( - sender, credentials: Dict[str, str], request: HttpRequest, **_ -): - """Failed Login""" - thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials) - thread.run() - - -@receiver(invitation_created) -# pylint: disable=unused-argument -def on_invitation_created(sender, request: HttpRequest, invitation: Invitation, **_): - """Log Invitation creation""" - thread = EventNewThread( - EventAction.INVITE_CREATED, request, invitation_uuid=invitation.invite_uuid.hex - ) - thread.run() - - -@receiver(invitation_used) -# pylint: disable=unused-argument -def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_): - """Log Invitation usage""" - thread = EventNewThread( - EventAction.INVITE_USED, request, invitation_uuid=invitation.invite_uuid.hex - ) - thread.run() - - -@receiver(password_changed) -# pylint: disable=unused-argument -def on_password_changed(sender, user: User, password: str, **_): - """Log password change""" - thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user) - thread.run() diff --git a/passbook/audit/templates/audit/list.html b/passbook/audit/templates/audit/list.html deleted file mode 100644 index 6777b210..00000000 --- a/passbook/audit/templates/audit/list.html +++ /dev/null @@ -1,90 +0,0 @@ -{% extends "base/page.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block page_content %} -
-
-
-

- - {% trans 'Audit Log' %} -

-
-
-
-
-
-
- {% include 'partials/toolbar_search.html' %} - - {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for entry in object_list %} - - - - - - - - {% endfor %} - -
{% trans 'Action' %}{% trans 'Context' %}{% trans 'User' %}{% trans 'Creation Date' %}{% trans 'Client IP' %}
-
-
{{ entry.action }}
- {{ entry.app|default:'-' }} -
-
-
-
- {{ entry.context }} -
- {% if entry.user.on_behalf_of %} - - {% blocktrans with username=entry.user.on_behalf_of.username %} - On behalf of {{ username }} - {% endblocktrans %} - - {% endif %} -
-
-
-
{{ entry.user.username }}
- - {% blocktrans with pk=entry.user.pk %} - ID: {{ pk }} - {% endblocktrans %} - -
-
- - {{ entry.created }} - - - - {{ entry.client_ip }} - -
-
- {% include 'partials/pagination.html' %} -
-
-
-
-{% endblock %} diff --git a/passbook/audit/tests/test_event.py b/passbook/audit/tests/test_event.py deleted file mode 100644 index af297025..00000000 --- a/passbook/audit/tests/test_event.py +++ /dev/null @@ -1,33 +0,0 @@ -"""audit event tests""" - -from django.contrib.contenttypes.models import ContentType -from django.test import TestCase -from guardian.shortcuts import get_anonymous_user - -from passbook.audit.models import Event -from passbook.policies.dummy.models import DummyPolicy - - -class TestAuditEvent(TestCase): - """Test Audit Event""" - - def test_new_with_model(self): - """Create a new Event passing a model as kwarg""" - event = Event.new("unittest", test={"model": get_anonymous_user()}) - event.save() # We save to ensure nothing is un-saveable - model_content_type = ContentType.objects.get_for_model(get_anonymous_user()) - self.assertEqual( - event.context.get("test").get("model").get("app"), - model_content_type.app_label, - ) - - def test_new_with_uuid_model(self): - """Create a new Event passing a model (with UUID PK) as kwarg""" - temp_model = DummyPolicy.objects.create(name="test", result=True) - event = Event.new("unittest", model=temp_model) - event.save() # We save to ensure nothing is un-saveable - model_content_type = ContentType.objects.get_for_model(temp_model) - self.assertEqual( - event.context.get("model").get("app"), model_content_type.app_label - ) - self.assertEqual(event.context.get("model").get("pk"), temp_model.pk.hex) diff --git a/passbook/audit/urls.py b/passbook/audit/urls.py deleted file mode 100644 index 83be4fe2..00000000 --- a/passbook/audit/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -"""passbook audit urls""" -from django.urls import path - -from passbook.audit.views import EventListView - -urlpatterns = [ - # Audit Log - path("audit/", EventListView.as_view(), name="log"), -] diff --git a/passbook/audit/views.py b/passbook/audit/views.py deleted file mode 100644 index e08d5c2f..00000000 --- a/passbook/audit/views.py +++ /dev/null @@ -1,30 +0,0 @@ -"""passbook Event administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import ListView -from guardian.mixins import PermissionListMixin - -from passbook.admin.views.utils import SearchListMixin, UserPaginateListMixin -from passbook.audit.models import Event - - -class EventListView( - PermissionListMixin, - LoginRequiredMixin, - SearchListMixin, - UserPaginateListMixin, - ListView, -): - """Show list of all invitations""" - - model = Event - template_name = "audit/list.html" - permission_required = "passbook_audit.view_event" - ordering = "-created" - - search_fields = [ - "user", - "action", - "app", - "context", - "client_ip", - ] diff --git a/passbook/core/admin.py b/passbook/core/admin.py deleted file mode 100644 index a1f5f545..00000000 --- a/passbook/core/admin.py +++ /dev/null @@ -1,24 +0,0 @@ -"""passbook core admin""" - -from django.apps import AppConfig, apps -from django.contrib import admin -from django.contrib.admin.sites import AlreadyRegistered -from guardian.admin import GuardedModelAdmin -from structlog import get_logger - -LOGGER = get_logger() - - -def admin_autoregister(app: AppConfig): - """Automatically register all models from app""" - for model in app.get_models(): - try: - admin.site.register(model, GuardedModelAdmin) - except AlreadyRegistered: - pass - - -for _app in apps.get_app_configs(): - if _app.label.startswith("passbook_"): - LOGGER.debug("Registering application for dj-admin", application=_app.label) - admin_autoregister(_app) diff --git a/passbook/core/api/applications.py b/passbook/core/api/applications.py deleted file mode 100644 index be65df66..00000000 --- a/passbook/core/api/applications.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Application API Views""" -from django.db.models import QuerySet -from rest_framework.decorators import action -from rest_framework.fields import SerializerMethodField -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet -from rest_framework_guardian.filters import ObjectPermissionsFilter - -from passbook.admin.api.overview_metrics import get_events_per_1h -from passbook.audit.models import EventAction -from passbook.core.models import Application -from passbook.policies.engine import PolicyEngine - - -class ApplicationSerializer(ModelSerializer): - """Application Serializer""" - - launch_url = SerializerMethodField() - - def get_launch_url(self, instance: Application) -> str: - """Get generated launch URL""" - return instance.get_launch_url() or "" - - class Meta: - - model = Application - fields = [ - "pk", - "name", - "slug", - "provider", - "launch_url", - "meta_launch_url", - "meta_icon", - "meta_description", - "meta_publisher", - "policies", - ] - - -class ApplicationViewSet(ModelViewSet): - """Application Viewset""" - - queryset = Application.objects.all() - serializer_class = ApplicationSerializer - lookup_field = "slug" - - def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: - """Custom filter_queryset method which ignores guardian, but still supports sorting""" - for backend in list(self.filter_backends): - if backend == ObjectPermissionsFilter: - continue - queryset = backend().filter_queryset(self.request, queryset, self) - return queryset - - def list(self, request: Request) -> Response: - """Custom list method that checks Policy based access instead of guardian""" - queryset = self._filter_queryset_for_list(self.get_queryset()) - self.paginate_queryset(queryset) - allowed_applications = [] - for application in queryset.order_by("name"): - engine = PolicyEngine(application, self.request.user, self.request) - engine.build() - if engine.passing: - allowed_applications.append(application) - serializer = self.get_serializer(allowed_applications, many=True) - return self.get_paginated_response(serializer.data) - - @action(detail=True) - def metrics(self, request: Request, slug: str): - """Metrics for application logins""" - # TODO: Check app read and audit read perms - app = Application.objects.get(slug=slug) - return Response( - get_events_per_1h( - action=EventAction.AUTHORIZE_APPLICATION, - context__authorized_application__pk=app.pk.hex, - ) - ) diff --git a/passbook/core/api/groups.py b/passbook/core/api/groups.py deleted file mode 100644 index 1af8ab42..00000000 --- a/passbook/core/api/groups.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Groups API Viewset""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.core.models import Group - - -class GroupSerializer(ModelSerializer): - """Group Serializer""" - - class Meta: - - model = Group - fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"] - - -class GroupViewSet(ModelViewSet): - """Group Viewset""" - - queryset = Group.objects.all() - serializer_class = GroupSerializer diff --git a/passbook/core/api/propertymappings.py b/passbook/core/api/propertymappings.py deleted file mode 100644 index 7e72d65b..00000000 --- a/passbook/core/api/propertymappings.py +++ /dev/null @@ -1,30 +0,0 @@ -"""PropertyMapping API Views""" -from rest_framework.serializers import ModelSerializer, SerializerMethodField -from rest_framework.viewsets import ReadOnlyModelViewSet - -from passbook.core.models import PropertyMapping - - -class PropertyMappingSerializer(ModelSerializer): - """PropertyMapping Serializer""" - - __type__ = SerializerMethodField(method_name="get_type") - - def get_type(self, obj): - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("propertymapping", "") - - class Meta: - - model = PropertyMapping - fields = ["pk", "name", "expression", "__type__"] - - -class PropertyMappingViewSet(ReadOnlyModelViewSet): - """PropertyMapping Viewset""" - - queryset = PropertyMapping.objects.all() - serializer_class = PropertyMappingSerializer - - def get_queryset(self): - return PropertyMapping.objects.select_subclasses() diff --git a/passbook/core/api/providers.py b/passbook/core/api/providers.py deleted file mode 100644 index 4b493dca..00000000 --- a/passbook/core/api/providers.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Provider API Views""" -from rest_framework.serializers import ModelSerializer, SerializerMethodField -from rest_framework.viewsets import ReadOnlyModelViewSet - -from passbook.core.models import Provider - - -class ProviderSerializer(ModelSerializer): - """Provider Serializer""" - - __type__ = SerializerMethodField(method_name="get_type") - - def get_type(self, obj): - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("provider", "") - - class Meta: - - model = Provider - fields = ["pk", "name", "authorization_flow", "property_mappings", "__type__"] - - -class ProviderViewSet(ReadOnlyModelViewSet): - """Provider Viewset""" - - queryset = Provider.objects.all() - serializer_class = ProviderSerializer - - def get_queryset(self): - return Provider.objects.select_subclasses() diff --git a/passbook/core/api/sources.py b/passbook/core/api/sources.py deleted file mode 100644 index 0cc49212..00000000 --- a/passbook/core/api/sources.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Source API Views""" -from rest_framework.serializers import ModelSerializer, SerializerMethodField -from rest_framework.viewsets import ReadOnlyModelViewSet - -from passbook.admin.forms.source import SOURCE_SERIALIZER_FIELDS -from passbook.core.models import Source - - -class SourceSerializer(ModelSerializer): - """Source Serializer""" - - __type__ = SerializerMethodField(method_name="get_type") - - def get_type(self, obj): - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("source", "") - - class Meta: - - model = Source - fields = SOURCE_SERIALIZER_FIELDS + ["__type__"] - - -class SourceViewSet(ReadOnlyModelViewSet): - """Source Viewset""" - - queryset = Source.objects.all() - serializer_class = SourceSerializer - - def get_queryset(self): - return Source.objects.select_subclasses() diff --git a/passbook/core/api/tokens.py b/passbook/core/api/tokens.py deleted file mode 100644 index 166d7601..00000000 --- a/passbook/core/api/tokens.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Tokens API Viewset""" -from django.http.response import Http404 -from rest_framework.decorators import action -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.audit.models import Event, EventAction -from passbook.core.models import Token - - -class TokenSerializer(ModelSerializer): - """Token Serializer""" - - class Meta: - - model = Token - fields = ["pk", "identifier", "intent", "user", "description"] - - -class TokenViewSet(ModelViewSet): - """Token Viewset""" - - lookup_field = "identifier" - queryset = Token.filter_not_expired() - serializer_class = TokenSerializer - - @action(detail=True) - def view_key(self, request: Request, identifier: str) -> Response: - """Return token key and log access""" - tokens = Token.filter_not_expired(identifier=identifier) - if not tokens.exists(): - raise Http404 - token = tokens.first() - Event.new(EventAction.TOKEN_VIEW, token=token).from_http(request) - return Response({"key": token.key}) diff --git a/passbook/core/api/users.py b/passbook/core/api/users.py deleted file mode 100644 index 1296e96d..00000000 --- a/passbook/core/api/users.py +++ /dev/null @@ -1,44 +0,0 @@ -"""User API Views""" -from drf_yasg2.utils import swagger_auto_schema -from rest_framework.decorators import action -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ( - BooleanField, - ModelSerializer, - SerializerMethodField, -) -from rest_framework.viewsets import ModelViewSet - -from passbook.core.models import User -from passbook.lib.templatetags.passbook_utils import avatar - - -class UserSerializer(ModelSerializer): - """User Serializer""" - - is_superuser = BooleanField(read_only=True) - avatar = SerializerMethodField() - - def get_avatar(self, user: User) -> str: - """Add user's avatar as URL""" - return avatar(user) - - class Meta: - - model = User - fields = ["pk", "username", "name", "is_superuser", "email", "avatar"] - - -class UserViewSet(ModelViewSet): - """User Viewset""" - - queryset = User.objects.all() - serializer_class = UserSerializer - - @swagger_auto_schema(responses={200: UserSerializer(many=False)}) - @action(detail=False) - # pylint: disable=invalid-name - def me(self, request: Request) -> Response: - """Get information about current user""" - return Response(UserSerializer(request.user).data) diff --git a/passbook/core/apps.py b/passbook/core/apps.py deleted file mode 100644 index 9b827a5a..00000000 --- a/passbook/core/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook core app config""" -from django.apps import AppConfig - - -class PassbookCoreConfig(AppConfig): - """passbook core app config""" - - name = "passbook.core" - label = "passbook_core" - verbose_name = "passbook Core" - mountpoint = "" diff --git a/passbook/core/channels.py b/passbook/core/channels.py deleted file mode 100644 index 5598ef8f..00000000 --- a/passbook/core/channels.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Channels base classes""" -from channels.generic.websocket import JsonWebsocketConsumer -from structlog import get_logger - -from passbook.api.auth import token_from_header -from passbook.core.models import User - -LOGGER = get_logger() - - -class AuthJsonConsumer(JsonWebsocketConsumer): - """Authorize a client with a token""" - - user: User - - def connect(self): - headers = dict(self.scope["headers"]) - if b"authorization" not in headers: - LOGGER.warning("WS Request without authorization header") - self.close() - return False - - raw_header = headers[b"authorization"] - - token = token_from_header(raw_header) - if not token: - LOGGER.warning("Failed to authenticate") - self.close() - return False - - self.user = token.user - return True diff --git a/passbook/core/exceptions.py b/passbook/core/exceptions.py deleted file mode 100644 index 01fc51bf..00000000 --- a/passbook/core/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -"""passbook core exceptions""" -from passbook.lib.sentry import SentryIgnoredException - - -class PropertyMappingExpressionException(SentryIgnoredException): - """Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" diff --git a/passbook/core/expression.py b/passbook/core/expression.py deleted file mode 100644 index 37a397ca..00000000 --- a/passbook/core/expression.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Property Mapping Evaluator""" -from typing import Optional - -from django.http import HttpRequest - -from passbook.core.models import User -from passbook.lib.expression.evaluator import BaseEvaluator - - -class PropertyMappingEvaluator(BaseEvaluator): - """Custom Evalautor that adds some different context variables.""" - - def set_context( - self, user: Optional[User], request: Optional[HttpRequest], **kwargs - ): - """Update context with context from PropertyMapping's evaluate""" - if user: - self._context["user"] = user - if request: - self._context["request"] = request - self._context.update(**kwargs) diff --git a/passbook/core/forms/applications.py b/passbook/core/forms/applications.py deleted file mode 100644 index f2cc801d..00000000 --- a/passbook/core/forms/applications.py +++ /dev/null @@ -1,50 +0,0 @@ -"""passbook Core Application forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.core.models import Application, Provider -from passbook.lib.widgets import GroupedModelChoiceField - - -class ApplicationForm(forms.ModelForm): - """Application Form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["provider"].queryset = ( - Provider.objects.all().order_by("pk").select_subclasses() - ) - - class Meta: - - model = Application - fields = [ - "name", - "slug", - "provider", - "meta_launch_url", - "meta_icon", - "meta_description", - "meta_publisher", - ] - widgets = { - "name": forms.TextInput(), - "meta_launch_url": forms.TextInput(), - "meta_publisher": forms.TextInput(), - "meta_icon": forms.FileInput(), - } - help_texts = { - "meta_launch_url": _( - ( - "If left empty, passbook will try to extract the launch URL " - "based on the selected provider." - ) - ), - } - field_classes = {"provider": GroupedModelChoiceField} - labels = { - "meta_launch_url": _("Launch URL"), - "meta_icon": _("Icon"), - "meta_description": _("Description"), - "meta_publisher": _("Publisher"), - } diff --git a/passbook/core/forms/groups.py b/passbook/core/forms/groups.py deleted file mode 100644 index e33805d0..00000000 --- a/passbook/core/forms/groups.py +++ /dev/null @@ -1,38 +0,0 @@ -"""passbook Core Group forms""" -from django import forms - -from passbook.admin.fields import CodeMirrorWidget, YAMLField -from passbook.core.models import Group, User - - -class GroupForm(forms.ModelForm): - """Group Form""" - - members = forms.ModelMultipleChoiceField( - User.objects.all(), - required=False, - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance.pk: - self.initial["members"] = self.instance.users.values_list("pk", flat=True) - - def save(self, *args, **kwargs): - instance = super().save(*args, **kwargs) - if instance.pk: - instance.users.clear() - instance.users.add(*self.cleaned_data["members"]) - return instance - - class Meta: - - model = Group - fields = ["name", "is_superuser", "parent", "members", "attributes"] - widgets = { - "name": forms.TextInput(), - "attributes": CodeMirrorWidget, - } - field_classes = { - "attributes": YAMLField, - } diff --git a/passbook/core/forms/token.py b/passbook/core/forms/token.py deleted file mode 100644 index bbc316b8..00000000 --- a/passbook/core/forms/token.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Core user token form""" -from django import forms - -from passbook.core.models import Token - - -class UserTokenForm(forms.ModelForm): - """Token form, for tokens created by endusers""" - - class Meta: - - model = Token - fields = [ - "identifier", - "expires", - "expiring", - "description", - ] - widgets = { - "identifier": forms.TextInput(), - "description": forms.TextInput(), - } diff --git a/passbook/core/forms/users.py b/passbook/core/forms/users.py deleted file mode 100644 index f6b725cd..00000000 --- a/passbook/core/forms/users.py +++ /dev/null @@ -1,15 +0,0 @@ -"""passbook core user forms""" - -from django import forms - -from passbook.core.models import User - - -class UserDetailForm(forms.ModelForm): - """Update User Details""" - - class Meta: - - model = User - fields = ["username", "name", "email"] - widgets = {"name": forms.TextInput} diff --git a/passbook/core/middleware.py b/passbook/core/middleware.py deleted file mode 100644 index 116050ba..00000000 --- a/passbook/core/middleware.py +++ /dev/null @@ -1,56 +0,0 @@ -"""passbook admin Middleware to impersonate users""" -from logging import Logger -from threading import local -from typing import Callable -from uuid import uuid4 - -from django.http import HttpRequest, HttpResponse - -SESSION_IMPERSONATE_USER = "passbook_impersonate_user" -SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user" -LOCAL = local() - - -class ImpersonateMiddleware: - """Middleware to impersonate users""" - - get_response: Callable[[HttpRequest], HttpResponse] - - def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): - self.get_response = get_response - - def __call__(self, request: HttpRequest) -> HttpResponse: - # No permission checks are done here, they need to be checked before - # SESSION_IMPERSONATE_USER is set. - - if SESSION_IMPERSONATE_USER in request.session: - request.user = request.session[SESSION_IMPERSONATE_USER] - - return self.get_response(request) - - -class RequestIDMiddleware: - """Add a unique ID to every request""" - - get_response: Callable[[HttpRequest], HttpResponse] - - def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): - self.get_response = get_response - - def __call__(self, request: HttpRequest) -> HttpResponse: - if not hasattr(request, "request_id"): - request_id = uuid4().hex - setattr(request, "request_id", request_id) - LOCAL.passbook = {"request_id": request_id} - response = self.get_response(request) - response["X-passbook-id"] = request.request_id - del LOCAL.passbook["request_id"] - return response - - -# pylint: disable=unused-argument -def structlog_add_request_id(logger: Logger, method_name: str, event_dict): - """If threadlocal has passbook defined, add request_id to log""" - if hasattr(LOCAL, "passbook"): - event_dict["request_id"] = LOCAL.passbook.get("request_id", "") - return event_dict diff --git a/passbook/core/migrations/0001_initial.py b/passbook/core/migrations/0001_initial.py deleted file mode 100644 index f46601d1..00000000 --- a/passbook/core/migrations/0001_initial.py +++ /dev/null @@ -1,355 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:07 - -import uuid - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.db.models.deletion -import django.utils.timezone -import guardian.mixins -from django.conf import settings -from django.db import migrations, models - -import passbook.core.models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0001_initial"), - ("auth", "0011_update_proxy_permissions"), - ] - - operations = [ - migrations.CreateModel( - name="User", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("password", models.CharField(max_length=128, verbose_name="password")), - ( - "last_login", - models.DateTimeField( - blank=True, null=True, verbose_name="last login" - ), - ), - ( - "is_superuser", - models.BooleanField( - default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", - verbose_name="superuser status", - ), - ), - ( - "username", - models.CharField( - error_messages={ - "unique": "A user with that username already exists." - }, - help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - max_length=150, - unique=True, - validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() - ], - verbose_name="username", - ), - ), - ( - "first_name", - models.CharField( - blank=True, max_length=30, verbose_name="first name" - ), - ), - ( - "last_name", - models.CharField( - blank=True, max_length=150, verbose_name="last name" - ), - ), - ( - "email", - models.EmailField( - blank=True, max_length=254, verbose_name="email address" - ), - ), - ( - "is_staff", - models.BooleanField( - default=False, - help_text="Designates whether the user can log into this admin site.", - verbose_name="staff status", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", - verbose_name="active", - ), - ), - ( - "date_joined", - models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" - ), - ), - ("uuid", models.UUIDField(default=uuid.uuid4, editable=False)), - ("name", models.TextField(help_text="User's display name.")), - ("password_change_date", models.DateTimeField(auto_now_add=True)), - ( - "attributes", - models.JSONField(blank=True, default=dict), - ), - ], - options={ - "permissions": (("reset_user_password", "Reset Password"),), - }, - bases=(guardian.mixins.GuardianUserMixin, models.Model), - managers=[ - ("objects", django.contrib.auth.models.UserManager()), - ], - ), - migrations.CreateModel( - name="PropertyMapping", - fields=[ - ( - "pm_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.TextField()), - ("expression", models.TextField()), - ], - options={ - "verbose_name": "Property Mapping", - "verbose_name_plural": "Property Mappings", - }, - ), - migrations.CreateModel( - name="Source", - fields=[ - ( - "policybindingmodel_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.PolicyBindingModel", - ), - ), - ("name", models.TextField(help_text="Source's display Name.")), - ( - "slug", - models.SlugField(help_text="Internal source name, used in URLs."), - ), - ("enabled", models.BooleanField(default=True)), - ( - "property_mappings", - models.ManyToManyField( - blank=True, default=None, to="passbook_core.PropertyMapping" - ), - ), - ], - bases=("passbook_policies.policybindingmodel",), - ), - migrations.CreateModel( - name="UserSourceConnection", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created", models.DateTimeField(auto_now_add=True)), - ("last_updated", models.DateTimeField(auto_now=True)), - ( - "source", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_core.Source", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "unique_together": {("user", "source")}, - }, - ), - migrations.CreateModel( - name="Token", - fields=[ - ( - "token_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "expires", - models.DateTimeField( - default=passbook.core.models.default_token_duration - ), - ), - ("expiring", models.BooleanField(default=True)), - ("description", models.TextField(blank=True, default="")), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Token", - "verbose_name_plural": "Tokens", - }, - ), - migrations.CreateModel( - name="Provider", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "property_mappings", - models.ManyToManyField( - blank=True, default=None, to="passbook_core.PropertyMapping" - ), - ), - ], - ), - migrations.CreateModel( - name="Group", - fields=[ - ( - "group_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.CharField(max_length=80, verbose_name="name")), - ( - "attributes", - models.JSONField(blank=True, default=dict), - ), - ( - "parent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="children", - to="passbook_core.Group", - ), - ), - ], - options={ - "unique_together": {("name", "parent")}, - }, - ), - migrations.CreateModel( - name="Application", - fields=[ - ( - "policybindingmodel_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.PolicyBindingModel", - ), - ), - ("name", models.TextField(help_text="Application's display Name.")), - ( - "slug", - models.SlugField( - help_text="Internal application name, used in URLs." - ), - ), - ("skip_authorization", models.BooleanField(default=False)), - ("meta_launch_url", models.URLField(blank=True, default="")), - ("meta_icon_url", models.TextField(blank=True, default="")), - ("meta_description", models.TextField(blank=True, default="")), - ("meta_publisher", models.TextField(blank=True, default="")), - ( - "provider", - models.OneToOneField( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - to="passbook_core.Provider", - ), - ), - ], - bases=("passbook_policies.policybindingmodel",), - ), - migrations.AddField( - model_name="user", - name="groups", - field=models.ManyToManyField(to="passbook_core.Group"), - ), - migrations.AddField( - model_name="user", - name="sources", - field=models.ManyToManyField( - through="passbook_core.UserSourceConnection", to="passbook_core.Source" - ), - ), - migrations.AddField( - model_name="user", - name="user_permissions", - field=models.ManyToManyField( - blank=True, - help_text="Specific permissions for this user.", - related_name="user_set", - related_query_name="user", - to="auth.Permission", - verbose_name="user permissions", - ), - ), - ] diff --git a/passbook/core/migrations/0002_auto_20200523_1133.py b/passbook/core/migrations/0002_auto_20200523_1133.py deleted file mode 100644 index 79855d62..00000000 --- a/passbook/core/migrations/0002_auto_20200523_1133.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 11:33 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0003_auto_20200523_1133"), - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.RemoveField( - model_name="application", - name="skip_authorization", - ), - migrations.AddField( - model_name="source", - name="authentication_flow", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Flow to use when authenticating existing users.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="source_authentication", - to="passbook_flows.Flow", - ), - ), - migrations.AddField( - model_name="source", - name="enrollment_flow", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Flow to use when enrolling new users.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="source_enrollment", - to="passbook_flows.Flow", - ), - ), - migrations.AddField( - model_name="provider", - name="authorization_flow", - field=models.ForeignKey( - help_text="Flow used when authorizing this provider.", - on_delete=django.db.models.deletion.CASCADE, - related_name="provider_authorization", - to="passbook_flows.Flow", - ), - ), - ] diff --git a/passbook/core/migrations/0003_default_user.py b/passbook/core/migrations/0003_default_user.py deleted file mode 100644 index c043038e..00000000 --- a/passbook/core/migrations/0003_default_user.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 16:40 - -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - # We have to use a direct import here, otherwise we get an object manager error - from passbook.core.models import User - - db_alias = schema_editor.connection.alias - - pbadmin, _ = User.objects.using(db_alias).get_or_create( - username="pbadmin", email="root@localhost", name="passbook Default Admin" - ) - pbadmin.set_password("pbadmin", signal=False) # noqa # nosec - pbadmin.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0002_auto_20200523_1133"), - ] - - operations = [ - migrations.RemoveField( - model_name="user", - name="is_superuser", - ), - migrations.RemoveField( - model_name="user", - name="is_staff", - ), - migrations.RunPython(create_default_user), - migrations.AddField( - model_name="user", - name="is_superuser", - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name="user", name="is_staff", field=models.BooleanField(default=False) - ), - ] diff --git a/passbook/core/migrations/0004_auto_20200703_2213.py b/passbook/core/migrations/0004_auto_20200703_2213.py deleted file mode 100644 index b56c3d95..00000000 --- a/passbook/core/migrations/0004_auto_20200703_2213.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.0.7 on 2020-07-03 22:13 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0003_default_user"), - ] - - operations = [ - migrations.AlterModelOptions( - name="application", - options={ - "verbose_name": "Application", - "verbose_name_plural": "Applications", - }, - ), - migrations.AlterModelOptions( - name="user", - options={ - "permissions": (("reset_user_password", "Reset Password"),), - "verbose_name": "User", - "verbose_name_plural": "Users", - }, - ), - ] diff --git a/passbook/core/migrations/0005_token_intent.py b/passbook/core/migrations/0005_token_intent.py deleted file mode 100644 index 975bae05..00000000 --- a/passbook/core/migrations/0005_token_intent.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.0.7 on 2020-07-05 21:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0004_auto_20200703_2213"), - ] - - operations = [ - migrations.AddField( - model_name="token", - name="intent", - field=models.TextField( - choices=[ - ("verification", "Intent Verification"), - ("api", "Intent Api"), - ], - default="verification", - ), - ), - ] diff --git a/passbook/core/migrations/0006_auto_20200709_1608.py b/passbook/core/migrations/0006_auto_20200709_1608.py deleted file mode 100644 index 527b3aa4..00000000 --- a/passbook/core/migrations/0006_auto_20200709_1608.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-09 16:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0005_token_intent"), - ] - - operations = [ - migrations.AlterField( - model_name="source", - name="slug", - field=models.SlugField( - help_text="Internal source name, used in URLs.", unique=True - ), - ), - ] diff --git a/passbook/core/migrations/0007_auto_20200815_1841.py b/passbook/core/migrations/0007_auto_20200815_1841.py deleted file mode 100644 index 6733e05d..00000000 --- a/passbook/core/migrations/0007_auto_20200815_1841.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.1 on 2020-08-15 18:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0006_auto_20200709_1608"), - ] - - operations = [ - migrations.AlterField( - model_name="user", - name="first_name", - field=models.CharField( - blank=True, max_length=150, verbose_name="first name" - ), - ), - ] diff --git a/passbook/core/migrations/0008_auto_20200824_1532.py b/passbook/core/migrations/0008_auto_20200824_1532.py deleted file mode 100644 index 23ac8543..00000000 --- a/passbook/core/migrations/0008_auto_20200824_1532.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.1 on 2020-08-24 15:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - ("passbook_core", "0007_auto_20200815_1841"), - ] - - operations = [ - migrations.RemoveField( - model_name="user", - name="groups", - field=models.ManyToManyField(to="passbook_core.Group"), - ), - migrations.AddField( - model_name="user", - name="groups", - field=models.ManyToManyField( - blank=True, - help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", - related_name="user_set", - related_query_name="user", - to="auth.Group", - verbose_name="groups", - ), - ), - migrations.AddField( - model_name="user", - name="pb_groups", - field=models.ManyToManyField(to="passbook_core.Group"), - ), - ] diff --git a/passbook/core/migrations/0009_group_is_superuser.py b/passbook/core/migrations/0009_group_is_superuser.py deleted file mode 100644 index 3efe597a..00000000 --- a/passbook/core/migrations/0009_group_is_superuser.py +++ /dev/null @@ -1,61 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-15 19:53 -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -import passbook.core.models - - -def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - db_alias = schema_editor.connection.alias - Group = apps.get_model("passbook_core", "Group") - User = apps.get_model("passbook_core", "User") - - # Creates a default admin group - group, _ = Group.objects.using(db_alias).get_or_create( - is_superuser=True, - defaults={ - "name": "passbook Admins", - }, - ) - group.users.set(User.objects.filter(username="pbadmin")) - group.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0008_auto_20200824_1532"), - ] - - operations = [ - migrations.RemoveField( - model_name="user", - name="is_superuser", - ), - migrations.RemoveField( - model_name="user", - name="is_staff", - ), - migrations.AlterField( - model_name="user", - name="pb_groups", - field=models.ManyToManyField( - related_name="users", to="passbook_core.Group" - ), - ), - migrations.AddField( - model_name="group", - name="is_superuser", - field=models.BooleanField( - default=False, help_text="Users added to this group will be superusers." - ), - ), - migrations.RunPython(create_default_admin_group), - migrations.AlterModelManagers( - name="user", - managers=[ - ("objects", passbook.core.models.UserManager()), - ], - ), - ] diff --git a/passbook/core/migrations/0010_auto_20200917_1021.py b/passbook/core/migrations/0010_auto_20200917_1021.py deleted file mode 100644 index 29c3ae7a..00000000 --- a/passbook/core/migrations/0010_auto_20200917_1021.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-17 10:21 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0009_group_is_superuser"), - ] - - operations = [ - migrations.AlterModelOptions( - name="user", - options={ - "permissions": ( - ("reset_user_password", "Reset Password"), - ("impersonate", "Can impersonate other users"), - ), - "verbose_name": "User", - "verbose_name_plural": "Users", - }, - ), - ] diff --git a/passbook/core/migrations/0011_provider_name_temp.py b/passbook/core/migrations/0011_provider_name_temp.py deleted file mode 100644 index 932029f7..00000000 --- a/passbook/core/migrations/0011_provider_name_temp.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-03 17:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0010_auto_20200917_1021"), - ] - - operations = [ - migrations.AddField( - model_name="provider", - name="name_temp", - field=models.TextField(default=""), - preserve_default=False, - ), - ] diff --git a/passbook/core/migrations/0012_auto_20201003_1737.py b/passbook/core/migrations/0012_auto_20201003_1737.py deleted file mode 100644 index 180dae62..00000000 --- a/passbook/core/migrations/0012_auto_20201003_1737.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-03 17:37 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0011_provider_name_temp"), - ("passbook_providers_oauth2", "0006_remove_oauth2provider_name"), - ("passbook_providers_saml", "0006_remove_samlprovider_name"), - ] - - operations = [ - migrations.RenameField( - model_name="provider", - old_name="name_temp", - new_name="name", - ), - ] diff --git a/passbook/core/migrations/0013_auto_20201003_2132.py b/passbook/core/migrations/0013_auto_20201003_2132.py deleted file mode 100644 index 6aa8ed73..00000000 --- a/passbook/core/migrations/0013_auto_20201003_2132.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-03 21:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0012_auto_20201003_1737"), - ] - - operations = [ - migrations.AddField( - model_name="token", - name="identifier", - field=models.TextField(default=""), - preserve_default=False, - ), - migrations.AlterField( - model_name="token", - name="intent", - field=models.TextField( - choices=[ - ("verification", "Intent Verification"), - ("api", "Intent Api"), - ("recovery", "Intent Recovery"), - ], - default="verification", - ), - ), - migrations.AlterUniqueTogether( - name="token", - unique_together={("identifier", "user")}, - ), - ] diff --git a/passbook/core/migrations/0014_auto_20201018_1158.py b/passbook/core/migrations/0014_auto_20201018_1158.py deleted file mode 100644 index 78c20804..00000000 --- a/passbook/core/migrations/0014_auto_20201018_1158.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-18 11:58 -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -import passbook.core.models - - -def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - db_alias = schema_editor.connection.alias - Token = apps.get_model("passbook_core", "Token") - - for token in Token.objects.using(db_alias).all(): - token.key = token.pk.hex - token.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0013_auto_20201003_2132"), - ] - - operations = [ - migrations.AddField( - model_name="token", - name="key", - field=models.TextField(default=passbook.core.models.default_token_key), - ), - migrations.AlterUniqueTogether( - name="token", - unique_together=set(), - ), - migrations.AlterField( - model_name="token", - name="identifier", - field=models.SlugField(max_length=255), - ), - migrations.AddIndex( - model_name="token", - index=models.Index(fields=["key"], name="passbook_co_key_e45007_idx"), - ), - migrations.AddIndex( - model_name="token", - index=models.Index( - fields=["identifier"], name="passbook_co_identif_1a34a8_idx" - ), - ), - migrations.RunPython(set_default_token_key), - ] diff --git a/passbook/core/migrations/0015_application_icon.py b/passbook/core/migrations/0015_application_icon.py deleted file mode 100644 index 5db76b9e..00000000 --- a/passbook/core/migrations/0015_application_icon.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-23 17:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0014_auto_20201018_1158"), - ] - - operations = [ - migrations.RemoveField( - model_name="application", - name="meta_icon_url", - ), - migrations.AddField( - model_name="application", - name="meta_icon", - field=models.FileField( - blank=True, default="", upload_to="application-icons/" - ), - ), - ] diff --git a/passbook/core/models.py b/passbook/core/models.py deleted file mode 100644 index da9da6af..00000000 --- a/passbook/core/models.py +++ /dev/null @@ -1,370 +0,0 @@ -"""passbook core models""" -from datetime import timedelta -from typing import Any, Dict, Optional, Type -from uuid import uuid4 - -from django.contrib.auth.models import AbstractUser -from django.contrib.auth.models import UserManager as DjangoUserManager -from django.db import models -from django.db.models import Q, QuerySet -from django.forms import ModelForm -from django.http import HttpRequest -from django.utils.functional import cached_property -from django.utils.timezone import now -from django.utils.translation import gettext_lazy as _ -from guardian.mixins import GuardianUserMixin -from model_utils.managers import InheritanceManager -from structlog import get_logger - -from passbook.core.exceptions import PropertyMappingExpressionException -from passbook.core.signals import password_changed -from passbook.core.types import UILoginButton -from passbook.flows.models import Flow -from passbook.lib.models import CreatedUpdatedModel -from passbook.policies.models import PolicyBindingModel - -LOGGER = get_logger() -PASSBOOK_USER_DEBUG = "passbook_user_debug" - - -def default_token_duration(): - """Default duration a Token is valid""" - return now() + timedelta(minutes=30) - - -def default_token_key(): - """Default token key""" - return uuid4().hex - - -class Group(models.Model): - """Custom Group model which supports a basic hierarchy""" - - group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - name = models.CharField(_("name"), max_length=80) - is_superuser = models.BooleanField( - default=False, help_text=_("Users added to this group will be superusers.") - ) - - parent = models.ForeignKey( - "Group", - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name="children", - ) - attributes = models.JSONField(default=dict, blank=True) - - def __str__(self): - return f"Group {self.name}" - - class Meta: - - unique_together = ( - ( - "name", - "parent", - ), - ) - - -class UserManager(DjangoUserManager): - """Custom user manager that doesn't assign is_superuser and is_staff""" - - def create_user(self, username, email=None, password=None, **extra_fields): - """Custom user manager that doesn't assign is_superuser and is_staff""" - return self._create_user(username, email, password, **extra_fields) - - -class User(GuardianUserMixin, AbstractUser): - """Custom User model to allow easier adding o f user-based settings""" - - uuid = models.UUIDField(default=uuid4, editable=False) - name = models.TextField(help_text=_("User's display name.")) - - sources = models.ManyToManyField("Source", through="UserSourceConnection") - pb_groups = models.ManyToManyField("Group", related_name="users") - password_change_date = models.DateTimeField(auto_now_add=True) - - attributes = models.JSONField(default=dict, blank=True) - - objects = UserManager() - - def group_attributes(self) -> Dict[str, Any]: - """Get a dictionary containing the attributes from all groups the user belongs to, - including the users attributes""" - final_attributes = {} - for group in self.pb_groups.all().order_by("name"): - final_attributes.update(group.attributes) - final_attributes.update(self.attributes) - return final_attributes - - @cached_property - def is_superuser(self) -> bool: - """Get supseruser status based on membership in a group with superuser status""" - return self.pb_groups.filter(is_superuser=True).exists() - - @property - def is_staff(self) -> bool: - """superuser == staff user""" - return self.is_superuser # type: ignore - - def set_password(self, password, signal=True): - if self.pk and signal: - password_changed.send(sender=self, user=self, password=password) - self.password_change_date = now() - return super().set_password(password) - - class Meta: - - permissions = ( - ("reset_user_password", "Reset Password"), - ("impersonate", "Can impersonate other users"), - ) - verbose_name = _("User") - verbose_name_plural = _("Users") - - -class Provider(models.Model): - """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" - - name = models.TextField() - - authorization_flow = models.ForeignKey( - Flow, - on_delete=models.CASCADE, - help_text=_("Flow used when authorizing this provider."), - related_name="provider_authorization", - ) - - property_mappings = models.ManyToManyField( - "PropertyMapping", default=None, blank=True - ) - - objects = InheritanceManager() - - @property - def launch_url(self) -> Optional[str]: - """URL to this provider and initiate authorization for the user. - Can return None for providers that are not URL-based""" - return None - - @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" - raise NotImplementedError - - def __str__(self): - return self.name - - -class Application(PolicyBindingModel): - """Every Application which uses passbook for authentication/identification/authorization - needs an Application record. Other authentication types can subclass this Model to - add custom fields and other properties""" - - name = models.TextField(help_text=_("Application's display Name.")) - slug = models.SlugField(help_text=_("Internal application name, used in URLs.")) - provider = models.OneToOneField( - "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT - ) - - meta_launch_url = models.URLField(default="", blank=True) - # For template applications, this can be set to /static/passbook/applications/* - meta_icon = models.FileField(upload_to="application-icons/", default="", blank=True) - meta_description = models.TextField(default="", blank=True) - meta_publisher = models.TextField(default="", blank=True) - - def get_launch_url(self) -> Optional[str]: - """Get launch URL if set, otherwise attempt to get launch URL based on provider.""" - if self.meta_launch_url: - return self.meta_launch_url - if self.provider: - return self.get_provider().launch_url - return None - - def get_provider(self) -> Optional[Provider]: - """Get casted provider instance""" - if not self.provider: - return None - return Provider.objects.get_subclass(pk=self.provider.pk) - - def __str__(self): - return self.name - - class Meta: - - verbose_name = _("Application") - verbose_name_plural = _("Applications") - - -class Source(PolicyBindingModel): - """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" - - name = models.TextField(help_text=_("Source's display Name.")) - slug = models.SlugField( - help_text=_("Internal source name, used in URLs."), unique=True - ) - - enabled = models.BooleanField(default=True) - property_mappings = models.ManyToManyField( - "PropertyMapping", default=None, blank=True - ) - - authentication_flow = models.ForeignKey( - Flow, - blank=True, - null=True, - default=None, - on_delete=models.SET_NULL, - help_text=_("Flow to use when authenticating existing users."), - related_name="source_authentication", - ) - enrollment_flow = models.ForeignKey( - Flow, - blank=True, - null=True, - default=None, - on_delete=models.SET_NULL, - help_text=_("Flow to use when enrolling new users."), - related_name="source_enrollment", - ) - - objects = InheritanceManager() - - @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" - raise NotImplementedError - - @property - def ui_login_button(self) -> Optional[UILoginButton]: - """If source uses a http-based flow, return UI Information about the login - button. If source doesn't use http-based flow, return None.""" - return None - - @property - def ui_additional_info(self) -> Optional[str]: - """Return additional Info, such as a callback URL. Show in the administration interface.""" - return None - - @property - def ui_user_settings(self) -> Optional[str]: - """Entrypoint to integrate with User settings. Can either return None if no - user settings are available, or a string with the URL to fetch.""" - return None - - def __str__(self): - return self.name - - -class UserSourceConnection(CreatedUpdatedModel): - """Connection between User and Source.""" - - user = models.ForeignKey(User, on_delete=models.CASCADE) - source = models.ForeignKey(Source, on_delete=models.CASCADE) - - class Meta: - - unique_together = (("user", "source"),) - - -class ExpiringModel(models.Model): - """Base Model which can expire, and is automatically cleaned up.""" - - expires = models.DateTimeField(default=default_token_duration) - expiring = models.BooleanField(default=True) - - @classmethod - def filter_not_expired(cls, **kwargs) -> QuerySet: - """Filer for tokens which are not expired yet or are not expiring, - and match filters in `kwargs`""" - expired = Q(expires__lt=now(), expiring=True) - return cls.objects.exclude(expired).filter(**kwargs) - - @property - def is_expired(self) -> bool: - """Check if token is expired yet.""" - return now() > self.expires - - class Meta: - - abstract = True - - -class TokenIntents(models.TextChoices): - """Intents a Token can be created for.""" - - # Single use token - INTENT_VERIFICATION = "verification" - - # Allow access to API - INTENT_API = "api" - - # Recovery use for the recovery app - INTENT_RECOVERY = "recovery" - - -class Token(ExpiringModel): - """Token used to authenticate the User for API Access or confirm another Stage like Email.""" - - token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - identifier = models.SlugField(max_length=255) - key = models.TextField(default=default_token_key) - intent = models.TextField( - choices=TokenIntents.choices, default=TokenIntents.INTENT_VERIFICATION - ) - user = models.ForeignKey("User", on_delete=models.CASCADE, related_name="+") - description = models.TextField(default="", blank=True) - - def __str__(self): - description = f"{self.identifier}" - if self.expiring: - description += f" (expires={self.expires})" - return description - - class Meta: - - verbose_name = _("Token") - verbose_name_plural = _("Tokens") - indexes = [ - models.Index(fields=["identifier"]), - models.Index(fields=["key"]), - ] - - -class PropertyMapping(models.Model): - """User-defined key -> x mapping which can be used by providers to expose extra data.""" - - pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - name = models.TextField() - expression = models.TextField() - - objects = InheritanceManager() - - @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" - raise NotImplementedError - - def evaluate( - self, user: Optional[User], request: Optional[HttpRequest], **kwargs - ) -> Any: - """Evaluate `self.expression` using `**kwargs` as Context.""" - from passbook.core.expression import PropertyMappingEvaluator - - evaluator = PropertyMappingEvaluator() - evaluator.set_context(user, request, **kwargs) - try: - return evaluator.evaluate(self.expression) - except (ValueError, SyntaxError) as exc: - raise PropertyMappingExpressionException from exc - - def __str__(self): - return f"Property Mapping {self.name}" - - class Meta: - - verbose_name = _("Property Mapping") - verbose_name_plural = _("Property Mappings") diff --git a/passbook/core/signals.py b/passbook/core/signals.py deleted file mode 100644 index d6c9446f..00000000 --- a/passbook/core/signals.py +++ /dev/null @@ -1,5 +0,0 @@ -"""passbook core signals""" -from django.core.signals import Signal - -# Arguments: user: User, password: str -password_changed = Signal() diff --git a/passbook/core/tasks.py b/passbook/core/tasks.py deleted file mode 100644 index 34aa6fb5..00000000 --- a/passbook/core/tasks.py +++ /dev/null @@ -1,63 +0,0 @@ -"""passbook core tasks""" -from datetime import datetime -from io import StringIO - -from boto3.exceptions import Boto3Error -from botocore.exceptions import BotoCoreError, ClientError -from dbbackup.db.exceptions import CommandConnectorError -from django.contrib.humanize.templatetags.humanize import naturaltime -from django.core import management -from django.utils.timezone import now -from structlog import get_logger - -from passbook.core.models import ExpiringModel -from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus -from passbook.root.celery import CELERY_APP - -LOGGER = get_logger() - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def clean_expired_models(self: MonitoredTask): - """Remove expired objects""" - messages = [] - for cls in ExpiringModel.__subclasses__(): - cls: ExpiringModel - amount, _ = ( - cls.objects.all() - .exclude(expiring=False) - .exclude(expiring=True, expires__gt=now()) - .delete() - ) - LOGGER.debug("Deleted expired models", model=cls, amount=amount) - messages.append(f"Deleted {amount} expired {cls._meta.verbose_name_plural}") - self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def backup_database(self: MonitoredTask): # pragma: no cover - """Database backup""" - self.result_timeout_hours = 25 - try: - start = datetime.now() - out = StringIO() - management.call_command("dbbackup", quiet=True, stdout=out) - self.set_status( - TaskResult( - TaskResultStatus.SUCCESSFUL, - [ - f"Successfully finished database backup {naturaltime(start)}", - out.getvalue(), - ], - ) - ) - LOGGER.info("Successfully backed up database.") - except ( - IOError, - BotoCoreError, - ClientError, - Boto3Error, - PermissionError, - CommandConnectorError, - ) as exc: - self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) diff --git a/passbook/core/templates/403_csrf.html b/passbook/core/templates/403_csrf.html deleted file mode 100644 index 9fd0de3a..00000000 --- a/passbook/core/templates/403_csrf.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends 'login/base.html' %} - -{% load static %} -{% load i18n %} -{% load passbook_utils %} - -{% block card_title %} -{{ title }} (403) -{% endblock %} - -{% block card %} -
-

{{ main }}

- {% if no_referer %} -

{{ no_referer1 }}

-

{{ no_referer2 }}

-

{{ no_referer3 }}

- {% endif %} - {% if no_cookie %} -

{{ no_cookie1 }}

-

{{ no_cookie2 }}

- {% endif %} - {% if 'back' in request.GET %} - {% trans 'Back' %} - {% endif %} -
-{% endblock %} diff --git a/passbook/core/templates/base/page.html b/passbook/core/templates/base/page.html deleted file mode 100644 index dca47d93..00000000 --- a/passbook/core/templates/base/page.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base/skeleton.html" %} - -{% load i18n %} - -{% block body %} - -
- {% trans 'Skip to content' %} - {% block page_content %} - {% endblock %} -
-{% endblock %} diff --git a/passbook/core/templates/base/skeleton.html b/passbook/core/templates/base/skeleton.html deleted file mode 100644 index 1174d475..00000000 --- a/passbook/core/templates/base/skeleton.html +++ /dev/null @@ -1,41 +0,0 @@ -{% load static %} -{% load i18n %} -{% load passbook_utils %} - - - - - - - - - - {% block title %}{% trans title|default:config.passbook.branding.title %}{% endblock %} - - - - - - - - - {% block head %} - {% endblock %} - - - {% if 'passbook_impersonate_user' in request.session %} -
-
-
- {% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %} - {% trans 'Stop impersonation' %} -
-
-
- {% endif %} - {% block body %} - {% endblock %} - {% block scripts %} - {% endblock %} - - diff --git a/passbook/core/templates/error/generic.html b/passbook/core/templates/error/generic.html deleted file mode 100644 index b32f942e..00000000 --- a/passbook/core/templates/error/generic.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends 'base/page.html' %} - -{% load i18n %} -{% load passbook_utils %} - -{% block body %} -
-
-
- -

- {% trans title %} -

-
- {% if message %} -

{% trans message %}

- {% endif %} -
- {% if 'back' in request.GET %} - {% trans 'Back' %} - {% endif %} - {% trans 'Go to home' %} -
-
-
-{% endblock %} diff --git a/passbook/core/templates/generic/autosubmit_form.html b/passbook/core/templates/generic/autosubmit_form.html deleted file mode 100644 index 902930d4..00000000 --- a/passbook/core/templates/generic/autosubmit_form.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "login/base.html" %} - -{% load passbook_utils %} -{% load i18n %} - -{% block title %} -{{ title }} -{% endblock %} - -{% block card %} -
- {% csrf_token %} - {% for key, value in attrs.items %} - - {% endfor %} -
-
- - - - - -
-
-
-
- -
-
-
-{% endblock %} diff --git a/passbook/core/templates/generic/autosubmit_form_full.html b/passbook/core/templates/generic/autosubmit_form_full.html deleted file mode 100644 index 687e7656..00000000 --- a/passbook/core/templates/generic/autosubmit_form_full.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "login/base_full.html" %} - -{% load passbook_utils %} -{% load i18n %} - -{% block title %} -{{ title }} -{% endblock %} - -{% block card %} -
- {% csrf_token %} - {% for key, value in attrs.items %} - - {% endfor %} -
-
- - - - - -
-
-
-
- -
-
-
- -{% endblock %} diff --git a/passbook/core/templates/generic/delete.html b/passbook/core/templates/generic/delete.html deleted file mode 100644 index 7f74699e..00000000 --- a/passbook/core/templates/generic/delete.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends container_template|default:"administration/base.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block content %} -
-
- {% block above_form %} -

- {% blocktrans with object_type=object|verbose_name %} - Delete {{ object_type }} - {% endblocktrans %} -

- {% endblock %} -
-
-
-
-
-
-
-
- {% csrf_token %} -

- {% blocktrans with object_type=object|verbose_name name=object %} - Are you sure you want to delete {{ object_type }} "{{ object }}"? - {% endblocktrans %} -

- -
- -
-
-
-
-
-
-
-{% endblock %} diff --git a/passbook/core/templates/library.html b/passbook/core/templates/library.html deleted file mode 100644 index a2f4d46a..00000000 --- a/passbook/core/templates/library.html +++ /dev/null @@ -1,53 +0,0 @@ -{% load i18n %} - -
-
-
-

- - {% trans 'Applications' %} -

-
-
-
- {% if applications %} - - {% else %} -
-
- -

{% trans 'No Applications available.' %}

-
- {% trans "Either no applications are defined, or you don't have access to any." %} -
- {% if perms.passbook_core.add_application %} - - {% trans 'Create Application' %} - - {% endif %} -
-
- {% endif %} -
-
diff --git a/passbook/core/templates/login/base.html b/passbook/core/templates/login/base.html deleted file mode 100644 index 6af0e804..00000000 --- a/passbook/core/templates/login/base.html +++ /dev/null @@ -1,59 +0,0 @@ -{% load static %} -{% load i18n %} - - - - - - diff --git a/passbook/core/templates/login/base_full.html b/passbook/core/templates/login/base_full.html deleted file mode 100644 index 583978a5..00000000 --- a/passbook/core/templates/login/base_full.html +++ /dev/null @@ -1,75 +0,0 @@ -{% extends 'base/skeleton.html' %} - -{% load static %} -{% load i18n %} -{% load passbook_utils %} - -{% block head %} -{{ block.super }} - -{% endblock %} - -{% block body %} -
- - - - - - - - - - - -
- - -{% endblock %} diff --git a/passbook/core/templates/login/form_with_user.html b/passbook/core/templates/login/form_with_user.html deleted file mode 100644 index da9defe3..00000000 --- a/passbook/core/templates/login/form_with_user.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'login/form.html' %} - -{% load i18n %} -{% load passbook_utils %} - -{% block above_form %} -
-
-
- - {{ user.username }} -
- -
-
-{% endblock %} diff --git a/passbook/core/templates/login/loading.html b/passbook/core/templates/login/loading.html deleted file mode 100644 index be302695..00000000 --- a/passbook/core/templates/login/loading.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends 'login/base.html' %} - -{% load static %} -{% load i18n %} -{% load passbook_utils %} - -{% block title %} -{% trans title %} -{% endblock %} - -{% block head %} - -{% endblock %} - -{% block card %} - -
-
-
-
-
-{% endblock %} diff --git a/passbook/core/templates/partials/form.html b/passbook/core/templates/partials/form.html deleted file mode 100644 index c954ecb1..00000000 --- a/passbook/core/templates/partials/form.html +++ /dev/null @@ -1,73 +0,0 @@ -{% load passbook_utils %} -{% load i18n %} - -{% csrf_token %} -{% if form.non_field_errors %} -
-

- {{ form.non_field_errors }} -

-
-{% endif %} -{% for field in form %} -{% if field.field.widget|fieldtype == 'HiddenInput' %} - {{ field }} -{% else %} -
- {% if field.field.widget|fieldtype == 'RadioSelect' %} - - {% for c in field %} -
- - -
- {% endfor %} - {% elif field.field.widget|fieldtype == 'Select' %} - -
- {{ field }} -
- {% if field.help_text %} - - {{ field.help_text }} - - {% endif %} - {% elif field.field.widget|fieldtype == 'CheckboxInput' %} - - {% if field.help_text %} - - {{ field.help_text }} - - {% endif %} - {% else %} - - {{ field|css_class:'pf-c-form-control' }} - {% if field.help_text %} - - {{ field.help_text }} - - {% endif %} - {% endif %} - {% for error in field.errors %} -

- {{ error }} -

- {% endfor %} -
-{% endif %} -{% endfor %} diff --git a/passbook/core/templates/partials/form_horizontal.html b/passbook/core/templates/partials/form_horizontal.html deleted file mode 100644 index 4bc2bff4..00000000 --- a/passbook/core/templates/partials/form_horizontal.html +++ /dev/null @@ -1,108 +0,0 @@ -{% load passbook_utils %} -{% load i18n %} - -{% csrf_token %} -{% for field in form %} -
- {% if field.field.widget|fieldtype == 'RadioSelect' %} -
- -
-
- {% for c in field %} -
- - -
- {% endfor %} - {% if field.help_text %} -

{{ field.help_text }}

- {% endif %} -
- {% elif field.field.widget|fieldtype == 'Select' %} -
- -
-
-
- {{ field|css_class:"pf-c-form-control" }} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} -
-
- {% elif field.field.widget|fieldtype == 'CheckboxInput' %} -
-
-
- {{ field|css_class:"pf-c-check__input" }} - -
- {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} -
-
- {% elif field.field.widget|fieldtype == "FileInput" %} -
- -
-
-
- {{ field|css_class:"pf-c-form-control" }} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} - {% if field.value %} - - {% blocktrans with current=field.value %} - Currently set to {{current}}. - {% endblocktrans %} - - {% endif %} -
-
- {% else %} -
- -
-
-
- {{ field|css_class:'pf-c-form-control' }} - {% if field.help_text %} -

{{ field.help_text|safe }}

- {% endif %} -
-
- {% endif %} - {% for error in field.errors %} -

- {{ error }} -

- {% endfor %} -
-{% endfor %} diff --git a/passbook/core/templates/partials/pagination.html b/passbook/core/templates/partials/pagination.html deleted file mode 100644 index b64dfb05..00000000 --- a/passbook/core/templates/partials/pagination.html +++ /dev/null @@ -1,42 +0,0 @@ -{% load i18n %} -{% load passbook_utils %} - -
-
-
-
-
- - {% blocktrans with start_index=page_obj.start_index end_index=page_obj.end_index total_items=paginator.count %} - {{ start_index }} - {{ end_index }} of {{ total_items }} - {% endblocktrans %} - -
-
- -
-
-
diff --git a/passbook/core/templates/shell.html b/passbook/core/templates/shell.html deleted file mode 100644 index ae0b4640..00000000 --- a/passbook/core/templates/shell.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "base/skeleton.html" %} - -{% block body %} - -{% endblock %} diff --git a/passbook/core/templates/user/settings.html b/passbook/core/templates/user/settings.html deleted file mode 100644 index eb347592..00000000 --- a/passbook/core/templates/user/settings.html +++ /dev/null @@ -1,78 +0,0 @@ -{% load i18n %} -{% load passbook_user_settings %} - -
-
-
-
-

- - {% trans 'User Settings' %} -

-

{% trans "Configure settings relevant to your user profile." %}

-
-
-
-
-
-
-
- {% trans 'Update details' %} -
-
-
- {% include 'partials/form_horizontal.html' with form=form %} - {% block beneath_form %} - {% endblock %} -
-
-
- - {% if unenrollment_enabled %} - {% trans "Delete account" %} - {% endif %} -
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
- {% user_stages as user_stages_loc %} - {% for stage in user_stages_loc %} -
-
-
- -
-
-
-
-
- {% endfor %} - {% user_sources as user_sources_loc %} - {% for source in user_sources_loc %} -
-
-
- -
-
-
-
-
- {% endfor %} -
-
diff --git a/passbook/core/templates/user/token_list.html b/passbook/core/templates/user/token_list.html deleted file mode 100644 index 210124be..00000000 --- a/passbook/core/templates/user/token_list.html +++ /dev/null @@ -1,100 +0,0 @@ -{% load i18n %} - -
-
-

{% trans "Tokens can be used to access passbook's API." %}

-
- {% if object_list %} -
-
- {% include 'partials/toolbar_search.html' %} -
- - - {% trans 'Create' %} - -
-
-
- {% include 'partials/pagination.html' %} -
-
- - - - - - - - - - - - {% for token in object_list %} - - - - - - - - {% endfor %} - -
{% trans 'Identifier' %}{% trans 'Expires?' %}{% trans 'Expiry Date' %}{% trans 'Description' %}
-
{{ token.identifier }}
-
- - {{ token.expiring|yesno:"Yes,No" }} - - - - {% if not token.expiring %} - - - {% else %} - {{ token.expires }} - {% endif %} - - - - {{ token.description }} - - - - - {% trans 'Edit' %} - -
-
- - - {% trans 'Delete' %} - -
-
- - {% trans 'Copy token' %} - -
-
- {% include 'partials/pagination.html' %} -
- {% else %} -
-
- -

- {% trans 'No Tokens.' %} -

-
- {% trans 'Currently no tokens exist. Click the button below to create one.' %} -
- - - {% trans 'Create' %} - -
-
-
-
- {% endif %} -
diff --git a/passbook/core/templatetags/passbook_user_settings.py b/passbook/core/templatetags/passbook_user_settings.py deleted file mode 100644 index 6748c0f4..00000000 --- a/passbook/core/templatetags/passbook_user_settings.py +++ /dev/null @@ -1,44 +0,0 @@ -"""passbook user settings template tags""" -from typing import Iterable - -from django import template -from django.template.context import RequestContext - -from passbook.core.models import Source -from passbook.flows.models import Stage -from passbook.policies.engine import PolicyEngine - -register = template.Library() - - -@register.simple_tag(takes_context=True) -# pylint: disable=unused-argument -def user_stages(context: RequestContext) -> list[str]: - """Return list of all stages which apply to user""" - _all_stages: Iterable[Stage] = Stage.objects.all().select_subclasses() - matching_stages: list[str] = [] - for stage in _all_stages: - user_settings = stage.ui_user_settings - if not user_settings: - continue - matching_stages.append(user_settings) - return matching_stages - - -@register.simple_tag(takes_context=True) -def user_sources(context: RequestContext) -> list[str]: - """Return a list of all sources which are enabled for the user""" - user = context.get("request").user - _all_sources: Iterable[Source] = Source.objects.filter( - enabled=True - ).select_subclasses() - matching_sources: list[str] = [] - for source in _all_sources: - user_settings = source.ui_user_settings - if not user_settings: - continue - policy_engine = PolicyEngine(source, user, context.get("request")) - policy_engine.build() - if policy_engine.passing: - matching_sources.append(user_settings) - return matching_sources diff --git a/passbook/core/tests/test_impersonation.py b/passbook/core/tests/test_impersonation.py deleted file mode 100644 index a7bdc26e..00000000 --- a/passbook/core/tests/test_impersonation.py +++ /dev/null @@ -1,55 +0,0 @@ -"""impersonation tests""" -from django.shortcuts import reverse -from django.test.testcases import TestCase - -from passbook.core.models import User - - -class TestImpersonation(TestCase): - """impersonation tests""" - - def setUp(self) -> None: - super().setUp() - self.other_user = User.objects.create(username="to-impersonate") - self.pbadmin = User.objects.get(username="pbadmin") - - def test_impersonate_simple(self): - """test simple impersonation and un-impersonation""" - self.client.force_login(self.pbadmin) - - self.client.get( - reverse( - "passbook_core:impersonate-init", kwargs={"user_id": self.other_user.pk} - ) - ) - - response = self.client.get(reverse("passbook_api:user-me")) - self.assertIn(self.other_user.username, response.content.decode()) - self.assertNotIn(self.pbadmin.username, response.content.decode()) - - self.client.get(reverse("passbook_core:impersonate-end")) - - response = self.client.get(reverse("passbook_api:user-me")) - self.assertNotIn(self.other_user.username, response.content.decode()) - self.assertIn(self.pbadmin.username, response.content.decode()) - - def test_impersonate_denied(self): - """test impersonation without permissions""" - self.client.force_login(self.other_user) - - self.client.get( - reverse( - "passbook_core:impersonate-init", kwargs={"user_id": self.pbadmin.pk} - ) - ) - - response = self.client.get(reverse("passbook_api:user-me")) - self.assertIn(self.other_user.username, response.content.decode()) - self.assertNotIn(self.pbadmin.username, response.content.decode()) - - def test_un_impersonate_empty(self): - """test un-impersonation without impersonating first""" - self.client.force_login(self.other_user) - - response = self.client.get(reverse("passbook_core:impersonate-end")) - self.assertRedirects(response, reverse("passbook_core:shell")) diff --git a/passbook/core/tests/test_tasks.py b/passbook/core/tests/test_tasks.py deleted file mode 100644 index 448190a2..00000000 --- a/passbook/core/tests/test_tasks.py +++ /dev/null @@ -1,18 +0,0 @@ -"""passbook core task tests""" -from django.test import TestCase -from django.utils.timezone import now -from guardian.shortcuts import get_anonymous_user - -from passbook.core.models import Token -from passbook.core.tasks import clean_expired_models - - -class TestTasks(TestCase): - """Test Tasks""" - - def test_token_cleanup(self): - """Test Token cleanup task""" - Token.objects.create(expires=now(), user=get_anonymous_user()) - self.assertEqual(Token.objects.all().count(), 1) - clean_expired_models.delay().get() - self.assertEqual(Token.objects.all().count(), 0) diff --git a/passbook/core/tests/test_views_overview.py b/passbook/core/tests/test_views_overview.py deleted file mode 100644 index 4d2bc3f5..00000000 --- a/passbook/core/tests/test_views_overview.py +++ /dev/null @@ -1,42 +0,0 @@ -"""passbook user view tests""" -import string -from random import SystemRandom - -from django.shortcuts import reverse -from django.test import TestCase - -from passbook.core.models import User - - -class TestOverviewViews(TestCase): - """Test Overview Views""" - - def setUp(self): - super().setUp() - self.user = User.objects.create_user( - username="unittest user", - email="unittest@example.com", - password="".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) - for _ in range(8) - ), - ) - self.client.force_login(self.user) - - def test_shell(self): - """Test shell""" - self.assertEqual( - self.client.get(reverse("passbook_core:shell")).status_code, 200 - ) - - def test_overview(self): - """Test overview""" - self.assertEqual( - self.client.get(reverse("passbook_core:overview")).status_code, 200 - ) - - def test_user_settings(self): - """Test user settings""" - self.assertEqual( - self.client.get(reverse("passbook_core:user-settings")).status_code, 200 - ) diff --git a/passbook/core/tests/test_views_user.py b/passbook/core/tests/test_views_user.py deleted file mode 100644 index c38a601f..00000000 --- a/passbook/core/tests/test_views_user.py +++ /dev/null @@ -1,30 +0,0 @@ -"""passbook user view tests""" -import string -from random import SystemRandom - -from django.shortcuts import reverse -from django.test import TestCase - -from passbook.core.models import User - - -class TestUserViews(TestCase): - """Test User Views""" - - def setUp(self): - super().setUp() - self.user = User.objects.create_user( - username="unittest user", - email="unittest@example.com", - password="".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) - for _ in range(8) - ), - ) - self.client.force_login(self.user) - - def test_user_settings(self): - """Test UserSettingsView""" - self.assertEqual( - self.client.get(reverse("passbook_core:user-settings")).status_code, 200 - ) diff --git a/passbook/core/types.py b/passbook/core/types.py deleted file mode 100644 index 22c043cf..00000000 --- a/passbook/core/types.py +++ /dev/null @@ -1,20 +0,0 @@ -"""passbook core dataclasses""" -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class UILoginButton: - """Dataclass for Source's ui_login_button""" - - # Name, ran through i18n - name: str - - # URL Which Button points to - url: str - - # Icon name, ran through django's static - icon_path: Optional[str] = None - - # Icon URL, used as-is - icon_url: Optional[str] = None diff --git a/passbook/core/urls.py b/passbook/core/urls.py deleted file mode 100644 index 52430a90..00000000 --- a/passbook/core/urls.py +++ /dev/null @@ -1,39 +0,0 @@ -"""passbook URL Configuration""" -from django.urls import path - -from passbook.core.views import impersonate, library, shell, user - -urlpatterns = [ - path("", shell.ShellView.as_view(), name="shell"), - # User views - path("-/user/", user.UserSettingsView.as_view(), name="user-settings"), - path("-/user/tokens/", user.TokenListView.as_view(), name="user-tokens"), - path( - "-/user/tokens/create/", - user.TokenCreateView.as_view(), - name="user-tokens-create", - ), - path( - "-/user/tokens//update/", - user.TokenUpdateView.as_view(), - name="user-tokens-update", - ), - path( - "-/user/tokens//delete/", - user.TokenDeleteView.as_view(), - name="user-tokens-delete", - ), - # Libray - path("library/", library.LibraryView.as_view(), name="overview"), - # Impersonation - path( - "-/impersonation//", - impersonate.ImpersonateInitView.as_view(), - name="impersonate-init", - ), - path( - "-/impersonation/end/", - impersonate.ImpersonateEndView.as_view(), - name="impersonate-end", - ), -] diff --git a/passbook/core/views/error.py b/passbook/core/views/error.py deleted file mode 100644 index 4a59a3b7..00000000 --- a/passbook/core/views/error.py +++ /dev/null @@ -1,67 +0,0 @@ -"""passbook core error views""" - -from django.http.response import ( - HttpResponseBadRequest, - HttpResponseForbidden, - HttpResponseNotFound, - HttpResponseServerError, -) -from django.template.response import TemplateResponse -from django.views.generic import TemplateView - - -class BadRequestTemplateResponse(TemplateResponse, HttpResponseBadRequest): - """Combine Template response with Http Code 400""" - - -class ForbiddenTemplateResponse(TemplateResponse, HttpResponseForbidden): - """Combine Template response with Http Code 403""" - - -class NotFoundTemplateResponse(TemplateResponse, HttpResponseNotFound): - """Combine Template response with Http Code 404""" - - -class ServerErrorTemplateResponse(TemplateResponse, HttpResponseServerError): - """Combine Template response with Http Code 500""" - - -class BadRequestView(TemplateView): - """Show Bad Request message""" - - extra_context = {"title": "Bad Request"} - - response_class = BadRequestTemplateResponse - template_name = "error/generic.html" - - -class ForbiddenView(TemplateView): - """Show Forbidden message""" - - extra_context = {"title": "Forbidden"} - - response_class = ForbiddenTemplateResponse - template_name = "error/generic.html" - - -class NotFoundView(TemplateView): - """Show Not Found message""" - - extra_context = {"title": "Not Found"} - - response_class = NotFoundTemplateResponse - template_name = "error/generic.html" - - -class ServerErrorView(TemplateView): - """Show Server Error message""" - - extra_context = {"title": "Server Error"} - - response_class = ServerErrorTemplateResponse - template_name = "error/generic.html" - - # pylint: disable=useless-super-delegation - def dispatch(self, *args, **kwargs): # pragma: no cover - """Little wrapper so django accepts this function""" - return super().dispatch(*args, **kwargs) diff --git a/passbook/core/views/impersonate.py b/passbook/core/views/impersonate.py deleted file mode 100644 index 4c6d5a52..00000000 --- a/passbook/core/views/impersonate.py +++ /dev/null @@ -1,58 +0,0 @@ -"""passbook impersonation views""" - -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.views import View -from structlog import get_logger - -from passbook.audit.models import Event, EventAction -from passbook.core.middleware import ( - SESSION_IMPERSONATE_ORIGINAL_USER, - SESSION_IMPERSONATE_USER, -) -from passbook.core.models import User - -LOGGER = get_logger() - - -class ImpersonateInitView(View): - """Initiate Impersonation""" - - def get(self, request: HttpRequest, user_id: int) -> HttpResponse: - """Impersonation handler, checks permissions""" - if not request.user.has_perm("impersonate"): - LOGGER.debug( - "User attempted to impersonate without permissions", user=request.user - ) - return HttpResponse("Unauthorized", status=401) - - user_to_be = get_object_or_404(User, pk=user_id) - - request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user - request.session[SESSION_IMPERSONATE_USER] = user_to_be - - Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) - - return redirect("passbook_core:shell") - - -class ImpersonateEndView(View): - """End User impersonation""" - - def get(self, request: HttpRequest) -> HttpResponse: - """End Impersonation handler""" - if ( - SESSION_IMPERSONATE_USER not in request.session - or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session - ): - LOGGER.debug("Can't end impersonation", user=request.user) - return redirect("passbook_core:shell") - - original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER] - - del request.session[SESSION_IMPERSONATE_USER] - del request.session[SESSION_IMPERSONATE_ORIGINAL_USER] - - Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) - - return redirect("passbook_core:shell") diff --git a/passbook/core/views/library.py b/passbook/core/views/library.py deleted file mode 100644 index 8f1d9a74..00000000 --- a/passbook/core/views/library.py +++ /dev/null @@ -1,23 +0,0 @@ -"""passbook library view""" - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import TemplateView - -from passbook.core.models import Application -from passbook.policies.engine import PolicyEngine - - -class LibraryView(LoginRequiredMixin, TemplateView): - """Overview for logged in user, incase user opens passbook directly - and is not being forwarded""" - - template_name = "library.html" - - def get_context_data(self, **kwargs): - kwargs["applications"] = [] - for application in Application.objects.all().order_by("name"): - engine = PolicyEngine(application, self.request.user, self.request) - engine.build() - if engine.passing: - kwargs["applications"].append(application) - return super().get_context_data(**kwargs) diff --git a/passbook/core/views/user.py b/passbook/core/views/user.py deleted file mode 100644 index fddf88b3..00000000 --- a/passbook/core/views/user.py +++ /dev/null @@ -1,137 +0,0 @@ -"""passbook core user views""" -from typing import Any, Dict - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.db.models.query import QuerySet -from django.http.response import HttpResponse -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import ListView, UpdateView -from guardian.mixins import PermissionListMixin, PermissionRequiredMixin -from guardian.shortcuts import get_objects_for_user - -from passbook.admin.views.utils import ( - DeleteMessageView, - SearchListMixin, - UserPaginateListMixin, -) -from passbook.core.forms.token import UserTokenForm -from passbook.core.forms.users import UserDetailForm -from passbook.core.models import Token, TokenIntents -from passbook.flows.models import Flow, FlowDesignation -from passbook.lib.views import CreateAssignPermView - - -class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView): - """Update User settings""" - - template_name = "user/settings.html" - form_class = UserDetailForm - - success_message = _("Successfully updated user.") - success_url = reverse_lazy("passbook_core:user-settings") - - def get_object(self): - return self.request.user - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - unenrollment_flow = Flow.with_policy( - self.request, designation=FlowDesignation.UNRENOLLMENT - ) - kwargs["unenrollment_enabled"] = bool(unenrollment_flow) - return kwargs - - -class TokenListView( - LoginRequiredMixin, - PermissionListMixin, - UserPaginateListMixin, - SearchListMixin, - ListView, -): - """Show list of all tokens""" - - model = Token - ordering = "expires" - permission_required = "passbook_core.view_token" - - template_name = "user/token_list.html" - search_fields = [ - "identifier", - "intent", - "description", - ] - - def get_queryset(self) -> QuerySet: - return super().get_queryset().filter(intent=TokenIntents.INTENT_API) - - -class TokenCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Token""" - - model = Token - form_class = UserTokenForm - permission_required = "passbook_core.add_token" - - template_name = "generic/create.html" - success_url = reverse_lazy("passbook_core:user-tokens") - success_message = _("Successfully created Token") - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - kwargs["container_template"] = "user/base.html" - return kwargs - - def form_valid(self, form: UserTokenForm) -> HttpResponse: - form.instance.user = self.request.user - form.instance.intent = TokenIntents.INTENT_API - return super().form_valid(form) - - -class TokenUpdateView( - SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView -): - """Update token""" - - model = Token - form_class = UserTokenForm - permission_required = "passbook_core.update_token" - template_name = "generic/update.html" - success_url = reverse_lazy("passbook_core:user-tokens") - success_message = _("Successfully updated Token") - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - kwargs["container_template"] = "user/base.html" - return kwargs - - def get_object(self) -> Token: - identifier = self.kwargs.get("identifier") - return get_objects_for_user( - self.request.user, "passbook_core.update_token", self.model - ).filter(intent=TokenIntents.INTENT_API, identifier=identifier) - - -class TokenDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView): - """Delete token""" - - model = Token - permission_required = "passbook_core.delete_token" - template_name = "generic/delete.html" - success_url = reverse_lazy("passbook_core:user-tokens") - success_message = _("Successfully deleted Token") - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - kwargs["container_template"] = "user/base.html" - return kwargs diff --git a/passbook/crypto/api.py b/passbook/crypto/api.py deleted file mode 100644 index 1a3f6080..00000000 --- a/passbook/crypto/api.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Crypto API Views""" -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import load_pem_x509_certificate -from rest_framework.serializers import ModelSerializer, ValidationError -from rest_framework.viewsets import ModelViewSet - -from passbook.crypto.models import CertificateKeyPair - - -class CertificateKeyPairSerializer(ModelSerializer): - """CertificateKeyPair Serializer""" - - def validate_certificate_data(self, value): - """Verify that input is a valid PEM x509 Certificate""" - try: - load_pem_x509_certificate(value.encode("utf-8"), default_backend()) - except ValueError: - raise ValidationError("Unable to load certificate.") - return value - - def validate_key_data(self, value): - """Verify that input is a valid PEM RSA Key""" - # Since this field is optional, data can be empty. - if value == "": - return value - try: - load_pem_private_key( - str.encode("\n".join([x.strip() for x in value.split("\n")])), - password=None, - backend=default_backend(), - ) - except ValueError: - raise ValidationError("Unable to load private key.") - return value - - class Meta: - - model = CertificateKeyPair - fields = ["pk", "name", "certificate_data", "key_data"] - - -class CertificateKeyPairViewSet(ModelViewSet): - """CertificateKeyPair Viewset""" - - queryset = CertificateKeyPair.objects.all() - serializer_class = CertificateKeyPairSerializer diff --git a/passbook/crypto/apps.py b/passbook/crypto/apps.py deleted file mode 100644 index fa41d0dc..00000000 --- a/passbook/crypto/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook crypto app config""" -from django.apps import AppConfig - - -class PassbookCryptoConfig(AppConfig): - """passbook crypto app config""" - - name = "passbook.crypto" - label = "passbook_crypto" - verbose_name = "passbook Crypto" diff --git a/passbook/crypto/builder.py b/passbook/crypto/builder.py deleted file mode 100644 index 3fcc5a2c..00000000 --- a/passbook/crypto/builder.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Create self-signed certificates""" -import datetime -import uuid - -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID - - -class CertificateBuilder: - """Build self-signed certificates""" - - __public_key = None - __private_key = None - __builder = None - __certificate = None - - def __init__(self): - self.__public_key = None - self.__private_key = None - self.__builder = None - self.__certificate = None - - def build(self): - """Build self-signed certificate""" - one_day = datetime.timedelta(1, 0, 0) - self.__private_key = rsa.generate_private_key( - public_exponent=65537, key_size=2048, backend=default_backend() - ) - self.__public_key = self.__private_key.public_key() - self.__builder = ( - x509.CertificateBuilder() - .subject_name( - x509.Name( - [ - x509.NameAttribute( - NameOID.COMMON_NAME, - "passbook Self-signed Certificate", - ), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "passbook"), - x509.NameAttribute( - NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed" - ), - ] - ) - ) - .issuer_name( - x509.Name( - [ - x509.NameAttribute( - NameOID.COMMON_NAME, - "passbook Self-signed Certificate", - ), - ] - ) - ) - .not_valid_before(datetime.datetime.today() - one_day) - .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=365)) - .serial_number(int(uuid.uuid4())) - .public_key(self.__public_key) - ) - self.__certificate = self.__builder.sign( - private_key=self.__private_key, - algorithm=hashes.SHA256(), - backend=default_backend(), - ) - - @property - def private_key(self): - """Return private key in PEM format""" - return self.__private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ).decode("utf-8") - - @property - def certificate(self): - """Return certificate in PEM format""" - return self.__certificate.public_bytes( - encoding=serialization.Encoding.PEM, - ).decode("utf-8") diff --git a/passbook/crypto/forms.py b/passbook/crypto/forms.py deleted file mode 100644 index 79d5f710..00000000 --- a/passbook/crypto/forms.py +++ /dev/null @@ -1,57 +0,0 @@ -"""passbook Crypto forms""" -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import load_pem_x509_certificate -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.crypto.models import CertificateKeyPair - - -class CertificateKeyPairForm(forms.ModelForm): - """CertificateKeyPair Form""" - - def clean_certificate_data(self): - """Verify that input is a valid PEM x509 Certificate""" - certificate_data = self.cleaned_data["certificate_data"] - try: - load_pem_x509_certificate( - certificate_data.encode("utf-8"), default_backend() - ) - except ValueError: - raise forms.ValidationError("Unable to load certificate.") - return certificate_data - - def clean_key_data(self): - """Verify that input is a valid PEM RSA Key""" - key_data = self.cleaned_data["key_data"] - # Since this field is optional, data can be empty. - if key_data == "": - return key_data - try: - load_pem_private_key( - str.encode("\n".join([x.strip() for x in key_data.split("\n")])), - password=None, - backend=default_backend(), - ) - except ValueError: - raise forms.ValidationError("Unable to load private key.") - return key_data - - class Meta: - - model = CertificateKeyPair - fields = [ - "name", - "certificate_data", - "key_data", - ] - widgets = { - "name": forms.TextInput(), - "certificate_data": forms.Textarea(attrs={"class": "monospaced"}), - "key_data": forms.Textarea(attrs={"class": "monospaced"}), - } - labels = { - "certificate_data": _("Certificate"), - "key_data": _("Private Key"), - } diff --git a/passbook/crypto/migrations/0002_create_self_signed_kp.py b/passbook/crypto/migrations/0002_create_self_signed_kp.py deleted file mode 100644 index 66239b81..00000000 --- a/passbook/crypto/migrations/0002_create_self_signed_kp.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 23:07 - -from django.db import migrations - - -def create_self_signed(apps, schema_editor): - CertificateKeyPair = apps.get_model("passbook_crypto", "CertificateKeyPair") - db_alias = schema_editor.connection.alias - from passbook.crypto.builder import CertificateBuilder - - builder = CertificateBuilder() - builder.build() - CertificateKeyPair.objects.using(db_alias).create( - name="passbook Self-signed Certificate", - certificate_data=builder.certificate, - key_data=builder.private_key, - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0001_initial"), - ] - - operations = [migrations.RunPython(create_self_signed)] diff --git a/passbook/crypto/models.py b/passbook/crypto/models.py deleted file mode 100644 index d7cf0742..00000000 --- a/passbook/crypto/models.py +++ /dev/null @@ -1,87 +0,0 @@ -"""passbook crypto models""" -from binascii import hexlify -from hashlib import md5 -from typing import Optional -from uuid import uuid4 - -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import Certificate, load_pem_x509_certificate -from django.db import models -from django.utils.translation import gettext_lazy as _ - -from passbook.lib.models import CreatedUpdatedModel - - -class CertificateKeyPair(CreatedUpdatedModel): - """CertificateKeyPair that can be used for signing or encrypting if `key_data` - is set, otherwise it can be used to verify remote data.""" - - kp_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - name = models.TextField() - certificate_data = models.TextField(help_text=_("PEM-encoded Certificate data")) - key_data = models.TextField( - help_text=_( - "Optional Private Key. If this is set, you can use this keypair for encryption." - ), - blank=True, - default="", - ) - - _cert: Optional[Certificate] = None - _private_key: Optional[RSAPrivateKey] = None - _public_key: Optional[RSAPublicKey] = None - - @property - def certificate(self) -> Certificate: - """Get python cryptography Certificate instance""" - if not self._cert: - self._cert = load_pem_x509_certificate( - self.certificate_data.encode("utf-8"), default_backend() - ) - return self._cert - - @property - def public_key(self) -> Optional[RSAPublicKey]: - """Get public key of the private key""" - if not self._public_key: - self._public_key = self.private_key.public_key() - return self._public_key - - @property - def private_key(self) -> Optional[RSAPrivateKey]: - """Get python cryptography PrivateKey instance""" - if not self._private_key and self._private_key != "": - self._private_key = load_pem_private_key( - str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])), - password=None, - backend=default_backend(), - ) - return self._private_key - - @property - def fingerprint(self) -> str: - """Get SHA256 Fingerprint of certificate_data""" - return hexlify(self.certificate.fingerprint(hashes.SHA256()), ":").decode( - "utf-8" - ) - - @property - def kid(self): - """Get Key ID used for JWKS""" - return "{0}".format( - md5(self.key_data.encode("utf-8")).hexdigest() # nosec - if self.key_data - else "" - ) - - def __str__(self) -> str: - return f"Certificate-Key Pair {self.name}" - - class Meta: - - verbose_name = _("Certificate-Key Pair") - verbose_name_plural = _("Certificate-Key Pairs") diff --git a/passbook/crypto/tests.py b/passbook/crypto/tests.py deleted file mode 100644 index d8b8c692..00000000 --- a/passbook/crypto/tests.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Crypto tests""" -from django.test import TestCase - -from passbook.crypto.api import CertificateKeyPairSerializer -from passbook.crypto.forms import CertificateKeyPairForm -from passbook.crypto.models import CertificateKeyPair - - -class TestCrypto(TestCase): - """Test Crypto validation""" - - def test_form(self): - """Test form validation""" - keypair = CertificateKeyPair.objects.first() - self.assertTrue( - CertificateKeyPairForm( - { - "name": keypair.name, - "certificate_data": keypair.certificate_data, - "key_data": keypair.key_data, - } - ).is_valid() - ) - self.assertFalse( - CertificateKeyPairForm( - {"name": keypair.name, "certificate_data": "test", "key_data": "test"} - ).is_valid() - ) - - def test_serializer(self): - """Test API Validation""" - keypair = CertificateKeyPair.objects.first() - self.assertTrue( - CertificateKeyPairSerializer( - data={ - "name": keypair.name, - "certificate_data": keypair.certificate_data, - "key_data": keypair.key_data, - } - ).is_valid() - ) - self.assertFalse( - CertificateKeyPairSerializer( - data={ - "name": keypair.name, - "certificate_data": "test", - "key_data": "test", - } - ).is_valid() - ) diff --git a/passbook/flows/api.py b/passbook/flows/api.py deleted file mode 100644 index c8e0493c..00000000 --- a/passbook/flows/api.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Flow API Views""" -from django.core.cache import cache -from rest_framework.serializers import ModelSerializer, SerializerMethodField -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet - -from passbook.flows.models import Flow, FlowStageBinding, Stage -from passbook.flows.planner import cache_key - - -class FlowSerializer(ModelSerializer): - """Flow Serializer""" - - cache_count = SerializerMethodField() - - def get_cache_count(self, flow: Flow): - """Get count of cached flows""" - return len(cache.keys(f"{cache_key(flow)}*")) - - class Meta: - - model = Flow - fields = [ - "pk", - "name", - "slug", - "title", - "designation", - "background", - "stages", - "policies", - "cache_count", - ] - - -class FlowViewSet(ModelViewSet): - """Flow Viewset""" - - queryset = Flow.objects.all() - serializer_class = FlowSerializer - - -class FlowStageBindingSerializer(ModelSerializer): - """FlowStageBinding Serializer""" - - class Meta: - - model = FlowStageBinding - fields = [ - "pk", - "target", - "stage", - "evaluate_on_plan", - "re_evaluate_policies", - "order", - "policies", - ] - - -class FlowStageBindingViewSet(ModelViewSet): - """FlowStageBinding Viewset""" - - queryset = FlowStageBinding.objects.all() - serializer_class = FlowStageBindingSerializer - filterset_fields = "__all__" - - -class StageSerializer(ModelSerializer): - """Stage Serializer""" - - __type__ = SerializerMethodField(method_name="get_type") - verbose_name = SerializerMethodField(method_name="get_verbose_name") - - def get_type(self, obj: Stage) -> str: - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("stage", "") - - def get_verbose_name(self, obj: Stage) -> str: - """Get verbose name for UI""" - return obj._meta.verbose_name - - class Meta: - - model = Stage - fields = ["pk", "name", "__type__", "verbose_name"] - - -class StageViewSet(ReadOnlyModelViewSet): - """Stage Viewset""" - - queryset = Stage.objects.all() - serializer_class = StageSerializer - - def get_queryset(self): - return Stage.objects.select_subclasses() diff --git a/passbook/flows/apps.py b/passbook/flows/apps.py deleted file mode 100644 index 5fd0b16f..00000000 --- a/passbook/flows/apps.py +++ /dev/null @@ -1,16 +0,0 @@ -"""passbook flows app config""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookFlowsConfig(AppConfig): - """passbook flows app config""" - - name = "passbook.flows" - label = "passbook_flows" - mountpoint = "flows/" - verbose_name = "passbook Flows" - - def ready(self): - import_module("passbook.flows.signals") diff --git a/passbook/flows/forms.py b/passbook/flows/forms.py deleted file mode 100644 index 9562e05c..00000000 --- a/passbook/flows/forms.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Flow and Stage forms""" - -from django import forms -from django.core.validators import FileExtensionValidator -from django.forms import ValidationError -from django.utils.translation import gettext_lazy as _ - -from passbook.flows.models import Flow, FlowStageBinding, Stage -from passbook.flows.transfer.importer import FlowImporter -from passbook.lib.widgets import GroupedModelChoiceField - - -class FlowForm(forms.ModelForm): - """Flow Form""" - - class Meta: - - model = Flow - fields = [ - "name", - "title", - "slug", - "designation", - "background", - ] - widgets = { - "name": forms.TextInput(), - "title": forms.TextInput(), - "background": forms.FileInput(), - } - - -class FlowStageBindingForm(forms.ModelForm): - """FlowStageBinding Form""" - - stage = GroupedModelChoiceField( - queryset=Stage.objects.all().select_subclasses(), to_field_name="stage_uuid" - ) - - class Meta: - - model = FlowStageBinding - fields = [ - "target", - "stage", - "evaluate_on_plan", - "re_evaluate_policies", - "order", - ] - widgets = { - "name": forms.TextInput(), - } - - -class FlowImportForm(forms.Form): - """Form used for flow importing""" - - flow = forms.FileField( - validators=[FileExtensionValidator(allowed_extensions=["pbflow"])] - ) - - def clean_flow(self): - """Check if the flow is valid and rewind the file to the start""" - flow = self.cleaned_data["flow"].read() - valid = FlowImporter(flow.decode()).validate() - if not valid: - raise ValidationError(_("Flow invalid.")) - self.cleaned_data["flow"].seek(0) - return self.cleaned_data["flow"] diff --git a/passbook/flows/management/commands/apply_flow.py b/passbook/flows/management/commands/apply_flow.py deleted file mode 100644 index 84e63206..00000000 --- a/passbook/flows/management/commands/apply_flow.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Apply flow from commandline""" -from django.core.management.base import BaseCommand, no_translations - -from passbook.flows.transfer.importer import FlowImporter - - -class Command(BaseCommand): # pragma: no cover - """Apply flow from commandline""" - - @no_translations - def handle(self, *args, **options): - """Apply all flows in order, abort when one fails to import""" - for flow_path in options.get("flows", []): - with open(flow_path, "r") as flow_file: - importer = FlowImporter(flow_file.read()) - valid = importer.validate() - if not valid: - raise ValueError("Flow invalid") - importer.apply() - - def add_arguments(self, parser): - parser.add_argument("flows", nargs="+", type=str) diff --git a/passbook/flows/management/commands/benchmark.py b/passbook/flows/management/commands/benchmark.py deleted file mode 100644 index 1cd6e7a0..00000000 --- a/passbook/flows/management/commands/benchmark.py +++ /dev/null @@ -1,117 +0,0 @@ -"""passbook benchmark command""" -from csv import DictWriter -from multiprocessing import Manager, Process, cpu_count -from sys import stdout -from time import time - -from django import db -from django.core.management.base import BaseCommand -from django.test import RequestFactory -from structlog import get_logger - -from passbook import __version__ -from passbook.core.models import User -from passbook.flows.models import Flow -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner - -LOGGER = get_logger() - - -class FlowPlanProcess(Process): # pragma: no cover - """Test process which executes flow planner""" - - def __init__(self, index, return_dict, flow, user) -> None: - super().__init__() - self.index = index - self.return_dict = return_dict - self.flow = flow - self.user = user - self.request = RequestFactory().get("/") - - def run(self): - print(f"Proc {self.index} Running") - - def test_inner(): - planner = FlowPlanner(self.flow) - planner.use_cache = False - planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: self.user}) - - diffs = [] - for _ in range(1000): - start = time() - test_inner() - end = time() - diffs.append(end - start) - self.return_dict[self.index] = diffs - - -class Command(BaseCommand): # pragma: no cover - """Benchmark passbook""" - - def add_arguments(self, parser): - parser.add_argument( - "-p", - "--processes", - default=cpu_count(), - action="store", - help="How many processes should be started.", - ) - parser.add_argument( - "--csv", - action="store_true", - help="Output results as CSV", - ) - - def benchmark_flows(self, proc_count): - """Get full recovery link""" - flow = Flow.objects.get(slug="default-authentication-flow") - user = User.objects.get(username="pbadmin") - manager = Manager() - return_dict = manager.dict() - - jobs = [] - db.connections.close_all() - for i in range(proc_count): - proc = FlowPlanProcess(i, return_dict, flow, user) - jobs.append(proc) - proc.start() - - for proc in jobs: - proc.join() - return return_dict.values() - - def handle(self, *args, **options): - """Start benchmark""" - proc_count = options.get("processes", 1) - all_values = self.benchmark_flows(proc_count) - if options.get("csv"): - self.output_csv(all_values) - else: - self.output_overview(all_values) - - def output_overview(self, values): - """Output results human readable""" - total_max: int = max([max(inner) for inner in values]) - total_min: int = min([min(inner) for inner in values]) - total_avg = sum([sum(inner) for inner in values]) / sum( - [len(inner) for inner in values] - ) - - print(f"Version: {__version__}") - print(f"Processes: {len(values)}") - print(f"\tMax: {total_max * 100}ms") - print(f"\tMin: {total_min * 100}ms") - print(f"\tAvg: {total_avg * 100}ms") - - def output_csv(self, values): - """Output results as CSV""" - proc_count = len(values) - fieldnames = [f"proc_{idx}" for idx in range(proc_count)] - writer = DictWriter(stdout, fieldnames=fieldnames) - - writer.writeheader() - for run_idx in range(len(values[0])): - row_dict = {} - for proc_idx in range(proc_count): - row_dict[f"proc_{proc_idx}"] = values[proc_idx][run_idx] * 100 - writer.writerow(row_dict) diff --git a/passbook/flows/markers.py b/passbook/flows/markers.py deleted file mode 100644 index 04bd5c29..00000000 --- a/passbook/flows/markers.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Stage Markers""" -from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional - -from django.http.request import HttpRequest -from structlog import get_logger - -from passbook.core.models import User -from passbook.flows.models import Stage -from passbook.policies.engine import PolicyEngine -from passbook.policies.models import PolicyBinding - -if TYPE_CHECKING: - from passbook.flows.planner import FlowPlan - -LOGGER = get_logger() - - -@dataclass -class StageMarker: - """Base stage marker class, no extra attributes, and has no special handler.""" - - # pylint: disable=unused-argument - def process( - self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] - ) -> Optional[Stage]: - """Process callback for this marker. This should be overridden by sub-classes. - If a stage should be removed, return None.""" - return stage - - -@dataclass -class ReevaluateMarker(StageMarker): - """Reevaluate Marker, forces stage's policies to be evaluated again.""" - - binding: PolicyBinding - user: User - - def process( - self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest] - ) -> Optional[Stage]: - """Re-evaluate policies bound to stage, and if they fail, remove from plan""" - engine = PolicyEngine(self.binding, self.user) - engine.use_cache = False - if http_request: - engine.request.http_request = http_request - engine.request.context = plan.context - engine.build() - result = engine.result - if result.passing: - return stage - LOGGER.warning( - "f(plan_inst)[re-eval marker]: stage failed re-evaluation", - stage=stage, - messages=result.messages, - ) - return None diff --git a/passbook/flows/migrations/0001_initial.py b/passbook/flows/migrations/0001_initial.py deleted file mode 100644 index 13984d11..00000000 --- a/passbook/flows/migrations/0001_initial.py +++ /dev/null @@ -1,138 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:07 - -import uuid - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Flow", - fields=[ - ( - "flow_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.TextField()), - ("slug", models.SlugField(unique=True)), - ( - "designation", - models.CharField( - choices=[ - ("authentication", "Authentication"), - ("invalidation", "Invalidation"), - ("enrollment", "Enrollment"), - ("unenrollment", "Unrenollment"), - ("recovery", "Recovery"), - ("password_change", "Password Change"), - ], - max_length=100, - ), - ), - ( - "pbm", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - related_name="+", - to="passbook_policies.PolicyBindingModel", - ), - ), - ], - options={ - "verbose_name": "Flow", - "verbose_name_plural": "Flows", - }, - bases=("passbook_policies.policybindingmodel",), - ), - migrations.CreateModel( - name="Stage", - fields=[ - ( - "stage_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.TextField()), - ], - ), - migrations.CreateModel( - name="FlowStageBinding", - fields=[ - ( - "policybindingmodel_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - to="passbook_policies.PolicyBindingModel", - ), - ), - ( - "fsb_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "re_evaluate_policies", - models.BooleanField( - default=False, - help_text="When this option is enabled, the planner will re-evaluate policies bound to this.", - ), - ), - ("order", models.IntegerField()), - ( - "flow", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_flows.Flow", - ), - ), - ( - "stage", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_flows.Stage", - ), - ), - ], - options={ - "verbose_name": "Flow Stage Binding", - "verbose_name_plural": "Flow Stage Bindings", - "ordering": ["order", "flow"], - "unique_together": {("flow", "stage", "order")}, - }, - bases=("passbook_policies.policybindingmodel",), - ), - migrations.AddField( - model_name="flow", - name="stages", - field=models.ManyToManyField( - blank=True, - through="passbook_flows.FlowStageBinding", - to="passbook_flows.Stage", - ), - ), - ] diff --git a/passbook/flows/migrations/0003_auto_20200523_1133.py b/passbook/flows/migrations/0003_auto_20200523_1133.py deleted file mode 100644 index 4ef2f188..00000000 --- a/passbook/flows/migrations/0003_auto_20200523_1133.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 11:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="flow", - name="designation", - field=models.CharField( - choices=[ - ("authentication", "Authentication"), - ("authorization", "Authorization"), - ("invalidation", "Invalidation"), - ("enrollment", "Enrollment"), - ("unenrollment", "Unrenollment"), - ("recovery", "Recovery"), - ("password_change", "Password Change"), - ], - max_length=100, - ), - ), - ] diff --git a/passbook/flows/migrations/0006_auto_20200629_0857.py b/passbook/flows/migrations/0006_auto_20200629_0857.py deleted file mode 100644 index b1799cd5..00000000 --- a/passbook/flows/migrations/0006_auto_20200629_0857.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-29 08:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0003_auto_20200523_1133"), - ] - - operations = [ - migrations.AlterField( - model_name="flow", - name="designation", - field=models.CharField( - choices=[ - ("authentication", "Authentication"), - ("authorization", "Authorization"), - ("invalidation", "Invalidation"), - ("enrollment", "Enrollment"), - ("unenrollment", "Unrenollment"), - ("recovery", "Recovery"), - ("stage_setup", "Stage Setup"), - ], - max_length=100, - ), - ), - ] diff --git a/passbook/flows/migrations/0007_auto_20200703_2059.py b/passbook/flows/migrations/0007_auto_20200703_2059.py deleted file mode 100644 index 64bcc0a6..00000000 --- a/passbook/flows/migrations/0007_auto_20200703_2059.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 3.0.7 on 2020-07-03 20:59 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_policies", "0002_auto_20200528_1647"), - ("passbook_flows", "0006_auto_20200629_0857"), - ] - - operations = [ - migrations.AlterModelOptions( - name="flowstagebinding", - options={ - "ordering": ["order", "target"], - "verbose_name": "Flow Stage Binding", - "verbose_name_plural": "Flow Stage Bindings", - }, - ), - migrations.RenameField( - model_name="flowstagebinding", - old_name="flow", - new_name="target", - ), - migrations.RenameField( - model_name="flow", - old_name="pbm", - new_name="policybindingmodel_ptr", - ), - migrations.AlterUniqueTogether( - name="flowstagebinding", - unique_together={("target", "stage", "order")}, - ), - migrations.AlterField( - model_name="flow", - name="policybindingmodel_ptr", - field=models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - to="passbook_policies.PolicyBindingModel", - ), - ), - ] diff --git a/passbook/flows/migrations/0008_default_flows.py b/passbook/flows/migrations/0008_default_flows.py deleted file mode 100644 index c9cd4e84..00000000 --- a/passbook/flows/migrations/0008_default_flows.py +++ /dev/null @@ -1,113 +0,0 @@ -# Generated by Django 3.0.3 on 2020-05-08 14:30 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.flows.models import FlowDesignation -from passbook.stages.identification.models import Templates, UserFields - - -def create_default_authentication_flow( - apps: Apps, schema_editor: BaseDatabaseSchemaEditor -): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage") - UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage") - IdentificationStage = apps.get_model( - "passbook_stages_identification", "IdentificationStage" - ) - db_alias = schema_editor.connection.alias - - identification_stage, _ = IdentificationStage.objects.using( - db_alias - ).update_or_create( - name="default-authentication-identification", - defaults={ - "user_fields": [UserFields.E_MAIL, UserFields.USERNAME], - "template": Templates.DEFAULT_LOGIN, - }, - ) - - password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create( - name="default-authentication-password", - defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]}, - ) - - login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( - name="default-authentication-login" - ) - - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-authentication-flow", - designation=FlowDesignation.AUTHENTICATION, - defaults={ - "name": "Welcome to passbook!", - }, - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, - stage=identification_stage, - defaults={ - "order": 0, - }, - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, - stage=password_stage, - defaults={ - "order": 1, - }, - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, - stage=login_stage, - defaults={ - "order": 2, - }, - ) - - -def create_default_invalidation_flow( - apps: Apps, schema_editor: BaseDatabaseSchemaEditor -): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - UserLogoutStage = apps.get_model("passbook_stages_user_logout", "UserLogoutStage") - db_alias = schema_editor.connection.alias - - UserLogoutStage.objects.using(db_alias).update_or_create( - name="default-invalidation-logout" - ) - - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-invalidation-flow", - designation=FlowDesignation.INVALIDATION, - defaults={ - "name": "Logout", - }, - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, - stage=UserLogoutStage.objects.using(db_alias).first(), - defaults={ - "order": 0, - }, - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0007_auto_20200703_2059"), - ("passbook_stages_user_login", "0001_initial"), - ("passbook_stages_user_logout", "0001_initial"), - ("passbook_stages_password", "0001_initial"), - ("passbook_stages_identification", "0001_initial"), - ] - - operations = [ - migrations.RunPython(create_default_authentication_flow), - migrations.RunPython(create_default_invalidation_flow), - ] diff --git a/passbook/flows/migrations/0009_source_flows.py b/passbook/flows/migrations/0009_source_flows.py deleted file mode 100644 index 778aab9b..00000000 --- a/passbook/flows/migrations/0009_source_flows.py +++ /dev/null @@ -1,158 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 15:47 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.flows.models import FlowDesignation -from passbook.stages.prompt.models import FieldTypes - -FLOW_POLICY_EXPRESSION = """# This policy ensures that this flow can only be used when the user -# is in a SSO Flow (meaning they come from an external IdP) -return pb_is_sso_flow""" -PROMPT_POLICY_EXPRESSION = """# Check if we've not been given a username by the external IdP -# and trigger the enrollment flow -return 'username' not in context.get('prompt_data', {})""" - - -def create_default_source_enrollment_flow( - apps: Apps, schema_editor: BaseDatabaseSchemaEditor -): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding") - - ExpressionPolicy = apps.get_model( - "passbook_policies_expression", "ExpressionPolicy" - ) - - PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage") - Prompt = apps.get_model("passbook_stages_prompt", "Prompt") - UserWriteStage = apps.get_model("passbook_stages_user_write", "UserWriteStage") - UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage") - - db_alias = schema_editor.connection.alias - - # Create a policy that only allows this flow when doing an SSO Request - flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( - name="default-source-enrollment-if-sso", - defaults={"expression": FLOW_POLICY_EXPRESSION}, - ) - - # This creates a Flow used by sources to enroll users - # It makes sure that a username is set, and if not, prompts the user for a Username - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-source-enrollment", - designation=FlowDesignation.ENROLLMENT, - defaults={ - "name": "Welcome to passbook!", - }, - ) - PolicyBinding.objects.using(db_alias).update_or_create( - policy=flow_policy, target=flow, defaults={"order": 0} - ) - - # PromptStage to ask user for their username - prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( - name="Welcome to passbook! Please select a username.", - ) - prompt, _ = Prompt.objects.using(db_alias).update_or_create( - field_key="username", - defaults={ - "label": "Username", - "type": FieldTypes.TEXT, - "required": True, - "placeholder": "Username", - }, - ) - prompt_stage.fields.add(prompt) - - # Policy to only trigger prompt when no username is given - prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( - name="default-source-enrollment-if-username", - defaults={"expression": PROMPT_POLICY_EXPRESSION}, - ) - - # UserWrite stage to create the user, and login stage to log user in - user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( - name="default-source-enrollment-write" - ) - user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create( - name="default-source-enrollment-login" - ) - - binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, - stage=prompt_stage, - defaults={"order": 0, "re_evaluate_policies": True}, - ) - PolicyBinding.objects.using(db_alias).update_or_create( - policy=prompt_policy, target=binding, defaults={"order": 0} - ) - - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, stage=user_write, defaults={"order": 1} - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, stage=user_login, defaults={"order": 2} - ) - - -def create_default_source_authentication_flow( - apps: Apps, schema_editor: BaseDatabaseSchemaEditor -): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding") - - ExpressionPolicy = apps.get_model( - "passbook_policies_expression", "ExpressionPolicy" - ) - - UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage") - - db_alias = schema_editor.connection.alias - - # Create a policy that only allows this flow when doing an SSO Request - flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( - name="default-source-authentication-if-sso", - defaults={ - "expression": FLOW_POLICY_EXPRESSION, - }, - ) - - # This creates a Flow used by sources to authenticate users - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-source-authentication", - designation=FlowDesignation.AUTHENTICATION, - defaults={ - "name": "Welcome to passbook!", - }, - ) - PolicyBinding.objects.using(db_alias).update_or_create( - policy=flow_policy, target=flow, defaults={"order": 0} - ) - - user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create( - name="default-source-authentication-login" - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, stage=user_login, defaults={"order": 0} - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0008_default_flows"), - ("passbook_policies", "0001_initial"), - ("passbook_policies_expression", "0001_initial"), - ("passbook_stages_prompt", "0001_initial"), - ("passbook_stages_user_write", "0001_initial"), - ("passbook_stages_user_login", "0001_initial"), - ] - - operations = [ - migrations.RunPython(create_default_source_enrollment_flow), - migrations.RunPython(create_default_source_authentication_flow), - ] diff --git a/passbook/flows/migrations/0010_provider_flows.py b/passbook/flows/migrations/0010_provider_flows.py deleted file mode 100644 index e8b4bc28..00000000 --- a/passbook/flows/migrations/0010_provider_flows.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-24 11:34 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.flows.models import FlowDesignation - - -def create_default_provider_authorization_flow( - apps: Apps, schema_editor: BaseDatabaseSchemaEditor -): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - - ConsentStage = apps.get_model("passbook_stages_consent", "ConsentStage") - - db_alias = schema_editor.connection.alias - - # Empty flow for providers where consent is implicitly given - Flow.objects.using(db_alias).update_or_create( - slug="default-provider-authorization-implicit-consent", - designation=FlowDesignation.AUTHORIZATION, - defaults={"name": "Authorize Application"}, - ) - - # Flow with consent form to obtain explicit user consent - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-provider-authorization-explicit-consent", - designation=FlowDesignation.AUTHORIZATION, - defaults={"name": "Authorize Application"}, - ) - stage, _ = ConsentStage.objects.using(db_alias).update_or_create( - name="default-provider-authorization-consent" - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, stage=stage, defaults={"order": 0} - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0009_source_flows"), - ("passbook_stages_consent", "0001_initial"), - ] - - operations = [migrations.RunPython(create_default_provider_authorization_flow)] diff --git a/passbook/flows/migrations/0011_flow_title.py b/passbook/flows/migrations/0011_flow_title.py deleted file mode 100644 index 7a0cd540..00000000 --- a/passbook/flows/migrations/0011_flow_title.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 3.1 on 2020-08-28 13:14 -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def add_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - slug_title_map = { - "default-authentication-flow": "Welcome to passbook!", - "default-invalidation-flow": "Default Invalidation Flow", - "default-source-enrollment": "Welcome to passbook!", - "default-source-authentication": "Welcome to passbook!", - "default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)", - "default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)", - "default-password-change": "Change password", - } - db_alias = schema_editor.connection.alias - Flow = apps.get_model("passbook_flows", "Flow") - for flow in Flow.objects.using(db_alias).all(): - if flow.slug in slug_title_map: - flow.title = slug_title_map[flow.slug] - else: - flow.title = flow.name - flow.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0010_provider_flows"), - ] - - operations = [ - migrations.AlterModelOptions( - name="flow", - options={ - "permissions": [("export_flow", "Can export a Flow")], - "verbose_name": "Flow", - "verbose_name_plural": "Flows", - }, - ), - migrations.AddField( - model_name="flow", - name="title", - field=models.TextField(default="", blank=True), - preserve_default=False, - ), - migrations.RunPython(add_title_for_defaults), - migrations.AlterField( - model_name="flow", - name="title", - field=models.TextField(), - ), - ] diff --git a/passbook/flows/migrations/0012_auto_20200908_1542.py b/passbook/flows/migrations/0012_auto_20200908_1542.py deleted file mode 100644 index 7fad0204..00000000 --- a/passbook/flows/migrations/0012_auto_20200908_1542.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-08 15:42 - -import django.db.models.deletion -from django.db import migrations, models - -import passbook.lib.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0011_flow_title"), - ] - - operations = [ - migrations.AlterField( - model_name="flowstagebinding", - name="stage", - field=passbook.lib.models.InheritanceForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="passbook_flows.stage" - ), - ), - migrations.AlterField( - model_name="stage", - name="name", - field=models.TextField(unique=True), - ), - ] diff --git a/passbook/flows/migrations/0013_auto_20200924_1605.py b/passbook/flows/migrations/0013_auto_20200924_1605.py deleted file mode 100644 index eb3683c6..00000000 --- a/passbook/flows/migrations/0013_auto_20200924_1605.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-24 16:05 - -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.flows.models import FlowDesignation - - -def update_flow_designation(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - Flow = apps.get_model("passbook_flows", "Flow") - db_alias = schema_editor.connection.alias - - for flow in Flow.objects.using(db_alias).all(): - if flow.designation == "stage_setup": - flow.designation = FlowDesignation.STAGE_CONFIGURATION - flow.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0012_auto_20200908_1542"), - ] - - operations = [ - migrations.AlterField( - model_name="flow", - name="designation", - field=models.CharField( - choices=[ - ("authentication", "Authentication"), - ("authorization", "Authorization"), - ("invalidation", "Invalidation"), - ("enrollment", "Enrollment"), - ("unenrollment", "Unrenollment"), - ("recovery", "Recovery"), - ("stage_configuration", "Stage Configuration"), - ], - max_length=100, - ), - ), - migrations.RunPython(update_flow_designation), - ] diff --git a/passbook/flows/migrations/0014_auto_20200925_2332.py b/passbook/flows/migrations/0014_auto_20200925_2332.py deleted file mode 100644 index bfe53e10..00000000 --- a/passbook/flows/migrations/0014_auto_20200925_2332.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-25 23:32 - -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -# First stage for default-source-enrollment flow (prompt stage) -# needs to have its policy re-evaluated -def update_default_source_enrollment_flow_binding( - apps: Apps, schema_editor: BaseDatabaseSchemaEditor -): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - db_alias = schema_editor.connection.alias - - flows = Flow.objects.using(db_alias).filter(slug="default-source-enrollment") - if not flows.exists(): - return - flow = flows.first() - - binding = FlowStageBinding.objects.get(target=flow, order=0) - binding.re_evaluate_policies = True - binding.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0013_auto_20200924_1605"), - ] - - operations = [ - migrations.AlterModelOptions( - name="flowstagebinding", - options={ - "ordering": ["target", "order"], - "verbose_name": "Flow Stage Binding", - "verbose_name_plural": "Flow Stage Bindings", - }, - ), - migrations.AlterField( - model_name="flowstagebinding", - name="re_evaluate_policies", - field=models.BooleanField( - default=False, - help_text="When this option is enabled, the planner will re-evaluate policies bound to this binding.", - ), - ), - migrations.RunPython(update_default_source_enrollment_flow_binding), - ] diff --git a/passbook/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py b/passbook/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py deleted file mode 100644 index f494d709..00000000 --- a/passbook/flows/migrations/0015_flowstagebinding_evaluate_on_plan.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-20 12:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0014_auto_20200925_2332"), - ] - - operations = [ - migrations.AlterField( - model_name="flowstagebinding", - name="re_evaluate_policies", - field=models.BooleanField( - default=False, - help_text="Evaluate policies when the Stage is present to the user.", - ), - ), - migrations.AddField( - model_name="flowstagebinding", - name="evaluate_on_plan", - field=models.BooleanField( - default=True, - help_text="Evaluate policies during the Flow planning process. Disable this for input-based policies.", - ), - ), - ] diff --git a/passbook/flows/migrations/0016_auto_20201202_1307.py b/passbook/flows/migrations/0016_auto_20201202_1307.py deleted file mode 100644 index 49d72e28..00000000 --- a/passbook/flows/migrations/0016_auto_20201202_1307.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 3.1.3 on 2020-12-02 13:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0015_flowstagebinding_evaluate_on_plan"), - ] - - operations = [ - migrations.AddField( - model_name="flow", - name="background", - field=models.FileField( - blank=True, - default="../static/dist/assets/images/flow_background.jpg", - help_text="Background shown during execution", - upload_to="flow-backgrounds/", - ), - ), - migrations.AlterField( - model_name="flow", - name="designation", - field=models.CharField( - choices=[ - ("authentication", "Authentication"), - ("authorization", "Authorization"), - ("invalidation", "Invalidation"), - ("enrollment", "Enrollment"), - ("unenrollment", "Unrenollment"), - ("recovery", "Recovery"), - ("stage_configuration", "Stage Configuration"), - ], - help_text="Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits passbook.", - max_length=100, - ), - ), - migrations.AlterField( - model_name="flow", - name="slug", - field=models.SlugField(help_text="Visible in the URL.", unique=True), - ), - migrations.AlterField( - model_name="flow", - name="title", - field=models.TextField(help_text="Shown as the Title in Flow pages."), - ), - ] diff --git a/passbook/flows/models.py b/passbook/flows/models.py deleted file mode 100644 index ee92f1e8..00000000 --- a/passbook/flows/models.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Flow models""" -from typing import TYPE_CHECKING, Optional, Type -from uuid import uuid4 - -from django.db import models -from django.forms import ModelForm -from django.http import HttpRequest -from django.utils.translation import gettext_lazy as _ -from model_utils.managers import InheritanceManager -from rest_framework.serializers import BaseSerializer -from structlog import get_logger - -from passbook.lib.models import InheritanceForeignKey, SerializerModel -from passbook.policies.models import PolicyBindingModel - -if TYPE_CHECKING: - from passbook.flows.stage import StageView - -LOGGER = get_logger() - - -class NotConfiguredAction(models.TextChoices): - """Decides how the FlowExecutor should proceed when a stage isn't configured""" - - SKIP = "skip" - # CONFIGURE = "configure" - - -class FlowDesignation(models.TextChoices): - """Designation of what a Flow should be used for. At a later point, this - should be replaced by a database entry.""" - - AUTHENTICATION = "authentication" - AUTHORIZATION = "authorization" - INVALIDATION = "invalidation" - ENROLLMENT = "enrollment" - UNRENOLLMENT = "unenrollment" - RECOVERY = "recovery" - STAGE_CONFIGURATION = "stage_configuration" - - -class Stage(SerializerModel): - """Stage is an instance of a component used in a flow. This can verify the user, - enroll the user or offer a way of recovery""" - - stage_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - name = models.TextField(unique=True) - - objects = InheritanceManager() - - @property - def type(self) -> Type["StageView"]: - """Return StageView class that implements logic for this stage""" - # This is a bit of a workaround, since we can't set class methods with setattr - if hasattr(self, "__in_memory_type"): - return getattr(self, "__in_memory_type") - raise NotImplementedError - - @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" - raise NotImplementedError - - @property - def ui_user_settings(self) -> Optional[str]: - """Entrypoint to integrate with User settings. Can either return None if no - user settings are available, or a string with the URL to fetch.""" - return None - - def __str__(self): - if hasattr(self, "__in_memory_type"): - return f"In-memory Stage {getattr(self, '__in_memory_type')}" - return f"Stage {self.name}" - - -def in_memory_stage(view: Type["StageView"]) -> Stage: - """Creates an in-memory stage instance, based on a `_type` as view.""" - stage = Stage() - # Because we can't pickle a locally generated function, - # we set the view as a separate property and reference a generic function - # that returns that member - setattr(stage, "__in_memory_type", view) - return stage - - -class Flow(SerializerModel, PolicyBindingModel): - """Flow describes how a series of Stages should be executed to authenticate/enroll/recover - a user. Additionally, policies can be applied, to specify which users - have access to this flow.""" - - flow_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - name = models.TextField() - slug = models.SlugField(unique=True, help_text=_("Visible in the URL.")) - - title = models.TextField(help_text=_("Shown as the Title in Flow pages.")) - - designation = models.CharField( - max_length=100, - choices=FlowDesignation.choices, - help_text=_( - ( - "Decides what this Flow is used for. For example, the Authentication flow " - "is redirect to when an un-authenticated user visits passbook." - ) - ), - ) - - background = models.FileField( - upload_to="flow-backgrounds/", - default="../static/dist/assets/images/flow_background.jpg", - blank=True, - help_text=_("Background shown during execution"), - ) - - stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True) - - @property - def serializer(self) -> BaseSerializer: - from passbook.flows.api import FlowSerializer - - return FlowSerializer - - @staticmethod - def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]: - """Get a Flow by `**flow_filter` and check if the request from `request` can access it.""" - from passbook.policies.engine import PolicyEngine - - flows = Flow.objects.filter(**flow_filter).order_by("slug") - for flow in flows: - engine = PolicyEngine(flow, request.user, request) - engine.build() - result = engine.result - if result.passing: - LOGGER.debug("with_policy: flow passing", flow=flow) - return flow - LOGGER.warning( - "with_policy: flow not passing", flow=flow, messages=result.messages - ) - LOGGER.debug("with_policy: no flow found", filters=flow_filter) - return None - - def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]: - """Get a related flow with `designation`. Currently this only queries - Flows by `designation`, but will eventually use `self` for related lookups.""" - return Flow.with_policy(request, designation=designation) - - def __str__(self) -> str: - return f"Flow {self.name} ({self.slug})" - - class Meta: - - verbose_name = _("Flow") - verbose_name_plural = _("Flows") - - permissions = [ - ("export_flow", "Can export a Flow"), - ] - - -class FlowStageBinding(SerializerModel, PolicyBindingModel): - """Relationship between Flow and Stage. Order is required and unique for - each flow-stage Binding. Additionally, policies can be specified, which determine if - this Binding applies to the current user""" - - fsb_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - target = models.ForeignKey("Flow", on_delete=models.CASCADE) - stage = InheritanceForeignKey(Stage, on_delete=models.CASCADE) - - evaluate_on_plan = models.BooleanField( - default=True, - help_text=_( - ( - "Evaluate policies during the Flow planning process. " - "Disable this for input-based policies." - ) - ), - ) - re_evaluate_policies = models.BooleanField( - default=False, - help_text=_("Evaluate policies when the Stage is present to the user."), - ) - - order = models.IntegerField() - - objects = InheritanceManager() - - @property - def serializer(self) -> BaseSerializer: - from passbook.flows.api import FlowStageBindingSerializer - - return FlowStageBindingSerializer - - def __str__(self) -> str: - return f"{self.target} #{self.order} -> {self.stage}" - - class Meta: - - ordering = ["target", "order"] - - verbose_name = _("Flow Stage Binding") - verbose_name_plural = _("Flow Stage Bindings") - unique_together = (("target", "stage", "order"),) - - -class ConfigurableStage(models.Model): - """Abstract base class for a Stage that can be configured by the enduser. - The stage should create a default flow with the configure_stage designation during - migration.""" - - configure_flow = models.ForeignKey( - Flow, - on_delete=models.SET_NULL, - null=True, - 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." - ) - ), - ) - - class Meta: - - abstract = True diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py deleted file mode 100644 index f6913e90..00000000 --- a/passbook/flows/planner.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Flows Planner""" -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional - -from django.core.cache import cache -from django.http import HttpRequest -from sentry_sdk.hub import Hub -from sentry_sdk.tracing import Span -from structlog import get_logger - -from passbook.audit.models import cleanse_dict -from passbook.core.models import User -from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException -from passbook.flows.markers import ReevaluateMarker, StageMarker -from passbook.flows.models import Flow, FlowStageBinding, Stage -from passbook.policies.engine import PolicyEngine - -LOGGER = get_logger() - -PLAN_CONTEXT_PENDING_USER = "pending_user" -PLAN_CONTEXT_SSO = "is_sso" -PLAN_CONTEXT_APPLICATION = "application" - - -def cache_key(flow: Flow, user: Optional[User] = None) -> str: - """Generate Cache key for flow""" - prefix = f"flow_{flow.pk}" - if user: - prefix += f"#{user.pk}" - return prefix - - -@dataclass -class FlowPlan: - """This data-class is the output of a FlowPlanner. It holds a flat list - of all Stages that should be run.""" - - flow_pk: str - - stages: List[Stage] = field(default_factory=list) - context: Dict[str, Any] = field(default_factory=dict) - markers: List[StageMarker] = field(default_factory=list) - - def append(self, stage: Stage, marker: Optional[StageMarker] = None): - """Append `stage` to all stages, optionall with stage marker""" - self.stages.append(stage) - self.markers.append(marker or StageMarker()) - - def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]: - """Return next pending stage from the bottom of the list""" - if not self.has_stages: - return None - stage = self.stages[0] - marker = self.markers[0] - - if marker.__class__ is not StageMarker: - LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker) - marked_stage = marker.process(self, stage, http_request) - if not marked_stage: - LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage) - self.stages.remove(stage) - self.markers.remove(marker) - if not self.has_stages: - return None - # pylint: disable=not-callable - return self.next(http_request) - return marked_stage - - def pop(self): - """Pop next pending stage from bottom of list""" - self.markers.pop(0) - self.stages.pop(0) - - @property - def has_stages(self) -> bool: - """Check if there are any stages left in this plan""" - return len(self.markers) + len(self.stages) > 0 - - -class FlowPlanner: - """Execute all policies to plan out a flat list of all Stages - that should be applied.""" - - use_cache: bool - allow_empty_flows: bool - - flow: Flow - - def __init__(self, flow: Flow): - self.use_cache = True - self.allow_empty_flows = False - self.flow = flow - - def plan( - self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None - ) -> FlowPlan: - """Check each of the flows' policies, check policies for each stage with PolicyBinding - and return ordered list""" - with Hub.current.start_span(op="flow.planner.plan") as span: - span: Span - span.set_data("flow", self.flow) - span.set_data("request", request) - - LOGGER.debug("f(plan): Starting planning process", flow=self.flow) - # Bit of a workaround here, if there is a pending user set in the default context - # we use that user for our cache key - # to make sure they don't get the generic response - if default_context and PLAN_CONTEXT_PENDING_USER in default_context: - user = default_context[PLAN_CONTEXT_PENDING_USER] - else: - user = request.user - # First off, check the flow's direct policy bindings - # to make sure the user even has access to the flow - engine = PolicyEngine(self.flow, user, request) - if default_context: - span.set_data("default_context", cleanse_dict(default_context)) - engine.request.context = default_context - engine.build() - result = engine.result - if not result.passing: - raise FlowNonApplicableException(result.messages) - # User is passing so far, check if we have a cached plan - cached_plan_key = cache_key(self.flow, user) - cached_plan = cache.get(cached_plan_key, None) - if cached_plan and self.use_cache: - LOGGER.debug( - "f(plan): Taking plan from cache", - flow=self.flow, - key=cached_plan_key, - ) - # Reset the context as this isn't factored into caching - cached_plan.context = default_context or {} - return cached_plan - LOGGER.debug("f(plan): building plan", flow=self.flow) - plan = self._build_plan(user, request, default_context) - cache.set(cache_key(self.flow, user), plan) - if not plan.stages and not self.allow_empty_flows: - raise EmptyFlowException() - return plan - - def _build_plan( - self, - user: User, - request: HttpRequest, - default_context: Optional[Dict[str, Any]], - ) -> FlowPlan: - """Build flow plan by checking each stage in their respective - order and checking the applied policies""" - with Hub.current.start_span(op="flow.planner.build_plan") as span: - span: Span - span.set_data("flow", self.flow) - span.set_data("user", user) - span.set_data("request", request) - - plan = FlowPlan(flow_pk=self.flow.pk.hex) - if default_context: - plan.context = default_context - # Check Flow policies - for binding in FlowStageBinding.objects.filter( - target__pk=self.flow.pk - ).order_by("order"): - binding: FlowStageBinding - stage = binding.stage - marker = StageMarker() - if binding.evaluate_on_plan: - LOGGER.debug( - "f(plan): evaluating on plan", - stage=binding.stage, - flow=self.flow, - ) - engine = PolicyEngine(binding, user, request) - engine.request.context = plan.context - engine.build() - if engine.passing: - LOGGER.debug( - "f(plan): Stage passing", - stage=binding.stage, - flow=self.flow, - ) - else: - stage = None - else: - LOGGER.debug( - "f(plan): not evaluating on plan", - stage=binding.stage, - flow=self.flow, - ) - if binding.re_evaluate_policies and stage: - LOGGER.debug( - "f(plan): Stage has re-evaluate marker", - stage=binding.stage, - flow=self.flow, - ) - marker = ReevaluateMarker(binding=binding, user=user) - if stage: - plan.append(stage, marker) - LOGGER.debug( - "f(plan): Finished building", - flow=self.flow, - ) - return plan diff --git a/passbook/flows/signals.py b/passbook/flows/signals.py deleted file mode 100644 index d6c275b2..00000000 --- a/passbook/flows/signals.py +++ /dev/null @@ -1,37 +0,0 @@ -"""passbook flow signals""" -from django.core.cache import cache -from django.db.models.signals import post_save -from django.dispatch import receiver -from structlog import get_logger - -LOGGER = get_logger() - - -def delete_cache_prefix(prefix: str) -> int: - """Delete keys prefixed with `prefix` and return count of deleted keys.""" - keys = cache.keys(prefix) - cache.delete_many(keys) - return len(keys) - - -@receiver(post_save) -# pylint: disable=unused-argument -def invalidate_flow_cache(sender, instance, **_): - """Invalidate flow cache when flow is updated""" - from passbook.flows.models import Flow, FlowStageBinding, Stage - from passbook.flows.planner import cache_key - - if isinstance(instance, Flow): - total = delete_cache_prefix(f"{cache_key(instance)}*") - LOGGER.debug("Invalidating Flow cache", flow=instance, len=total) - if isinstance(instance, FlowStageBinding): - total = delete_cache_prefix(f"{cache_key(instance.target)}*") - LOGGER.debug( - "Invalidating Flow cache from FlowStageBinding", binding=instance, len=total - ) - if isinstance(instance, Stage): - total = 0 - for binding in FlowStageBinding.objects.filter(stage=instance): - prefix = cache_key(binding.target) - total += delete_cache_prefix(f"{prefix}*") - LOGGER.debug("Invalidating Flow cache from Stage", stage=instance, len=total) diff --git a/passbook/flows/stage.py b/passbook/flows/stage.py deleted file mode 100644 index 5b110742..00000000 --- a/passbook/flows/stage.py +++ /dev/null @@ -1,29 +0,0 @@ -"""passbook stage Base view""" -from typing import Any, Dict - -from django.http import HttpRequest -from django.utils.translation import gettext_lazy as _ -from django.views.generic import TemplateView - -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.views import FlowExecutorView - - -class StageView(TemplateView): - """Abstract Stage, inherits TemplateView but can be combined with FormView""" - - template_name = "login/form_with_user.html" - - executor: FlowExecutorView - - request: HttpRequest = None - - def __init__(self, executor: FlowExecutorView): - self.executor = executor - - def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: - kwargs["title"] = self.executor.flow.title - if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: - kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - kwargs["primary_action"] = _("Continue") - return super().get_context_data(**kwargs) diff --git a/passbook/flows/templates/flows/denied_shell.html b/passbook/flows/templates/flows/denied_shell.html deleted file mode 100644 index 82e0dc72..00000000 --- a/passbook/flows/templates/flows/denied_shell.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'login/base.html' %} - -{% load static %} -{% load i18n %} -{% load passbook_utils %} - -{% block card_title %} -{% trans 'Permission denied' %} -{% endblock %} - -{% block title %} -{% trans 'Permission denied' %} -{% endblock %} - -{% block card %} -
- {% csrf_token %} - {% include 'partials/form.html' %} -
-

- - {% trans 'Request has been denied.' %} -

- {% if error %} -
-

- {{ error }} -

- {% endif %} - {% if policy_result %} -
- - {% trans 'Explanation:' %} - -
    - {% for source_result in policy_result.source_results %} -
  • - {% blocktrans with name=source_result.source_policy.name result=source_result.passing %} - Policy '{{ name }}' returned result '{{ result }}' - {% endblocktrans %} - {% if source_result.messages %} -
      - {% for message in source_result.messages %} -
    • {{ message }}
    • - {% endfor %} -
    - {% endif %} -
  • - {% endfor %} -
- {% endif %} -
- {% if 'back' in request.GET %} - {% trans 'Back' %} - {% endif %} -
-{% endblock %} diff --git a/passbook/flows/templates/flows/error.html b/passbook/flows/templates/flows/error.html deleted file mode 100644 index 8b00ac92..00000000 --- a/passbook/flows/templates/flows/error.html +++ /dev/null @@ -1,22 +0,0 @@ -{% load i18n %} - - - - - diff --git a/passbook/flows/templates/flows/shell.html b/passbook/flows/templates/flows/shell.html deleted file mode 100644 index 6d2f9993..00000000 --- a/passbook/flows/templates/flows/shell.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends 'login/base_full.html' %} - -{% load static %} -{% load i18n %} - -{% block head %} -{{ block.super }} - -{% endblock %} - -{% block main_container %} - -{% endblock %} diff --git a/passbook/flows/tests/test_misc.py b/passbook/flows/tests/test_misc.py deleted file mode 100644 index 40e4bc0e..00000000 --- a/passbook/flows/tests/test_misc.py +++ /dev/null @@ -1,25 +0,0 @@ -"""miscellaneous flow tests""" -from django.test import TestCase - -from passbook.flows.api import StageSerializer, StageViewSet -from passbook.flows.models import Stage -from passbook.stages.dummy.models import DummyStage - - -class TestFlowsMisc(TestCase): - """miscellaneous tests""" - - def test_models(self): - """Test that ui_user_settings returns none""" - self.assertIsNone(Stage().ui_user_settings) - - def test_api_serializer(self): - """Test that stage serializer returns the correct type""" - obj = DummyStage() - self.assertEqual(StageSerializer().get_type(obj), "dummy") - self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage") - - def test_api_viewset(self): - """Test that stage serializer returns the correct type""" - dummy = DummyStage.objects.create() - self.assertIn(dummy, StageViewSet().get_queryset()) diff --git a/passbook/flows/tests/test_models.py b/passbook/flows/tests/test_models.py deleted file mode 100644 index e466f2fb..00000000 --- a/passbook/flows/tests/test_models.py +++ /dev/null @@ -1,31 +0,0 @@ -"""flow model tests""" -from typing import Callable, Type - -from django.forms import ModelForm -from django.test import TestCase - -from passbook.flows.models import Stage -from passbook.flows.stage import StageView - - -class TestStageProperties(TestCase): - """Generic model properties tests""" - - -def stage_tester_factory(model: Type[Stage]) -> Callable: - """Test a form""" - - def tester(self: TestStageProperties): - model_inst = model() - self.assertTrue(issubclass(model_inst.form, ModelForm)) - self.assertTrue(issubclass(model_inst.type, StageView)) - - return tester - - -for stage_type in Stage.__subclasses__(): - setattr( - TestStageProperties, - f"test_stage_{stage_type.__name__}", - stage_tester_factory(stage_type), - ) diff --git a/passbook/flows/tests/test_planner.py b/passbook/flows/tests/test_planner.py deleted file mode 100644 index eaa5f386..00000000 --- a/passbook/flows/tests/test_planner.py +++ /dev/null @@ -1,189 +0,0 @@ -"""flow planner tests""" -from unittest.mock import MagicMock, Mock, PropertyMock, patch - -from django.contrib.sessions.middleware import SessionMiddleware -from django.core.cache import cache -from django.http import HttpRequest -from django.shortcuts import reverse -from django.test import RequestFactory, TestCase -from guardian.shortcuts import get_anonymous_user - -from passbook.core.models import User -from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException -from passbook.flows.markers import ReevaluateMarker, StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key -from passbook.policies.dummy.models import DummyPolicy -from passbook.policies.models import PolicyBinding -from passbook.policies.types import PolicyResult -from passbook.stages.dummy.models import DummyStage - -POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) -CACHE_MOCK = Mock(wraps=cache) - -POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) - - -def dummy_get_response(request: HttpRequest): # pragma: no cover - """Dummy get_response for SessionMiddleware""" - return None - - -class TestFlowPlanner(TestCase): - """Test planner logic""" - - def setUp(self): - self.request_factory = RequestFactory() - - def test_empty_plan(self): - """Test that empty plan raises exception""" - flow = Flow.objects.create( - name="test-empty", - slug="test-empty", - designation=FlowDesignation.AUTHENTICATION, - ) - request = self.request_factory.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - request.user = get_anonymous_user() - - with self.assertRaises(EmptyFlowException): - planner = FlowPlanner(flow) - planner.plan(request) - - @patch( - "passbook.policies.engine.PolicyEngine.result", - POLICY_RETURN_FALSE, - ) - def test_non_applicable_plan(self): - """Test that empty plan raises exception""" - flow = Flow.objects.create( - name="test-empty", - slug="test-empty", - designation=FlowDesignation.AUTHENTICATION, - ) - request = self.request_factory.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - request.user = get_anonymous_user() - - with self.assertRaises(FlowNonApplicableException): - planner = FlowPlanner(flow) - planner.plan(request) - - @patch("passbook.flows.planner.cache", CACHE_MOCK) - def test_planner_cache(self): - """Test planner cache""" - flow = Flow.objects.create( - name="test-cache", - slug="test-cache", - designation=FlowDesignation.AUTHENTICATION, - ) - FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy"), order=0 - ) - request = self.request_factory.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - request.user = get_anonymous_user() - - planner = FlowPlanner(flow) - planner.plan(request) - self.assertEqual( - CACHE_MOCK.set.call_count, 1 - ) # Ensure plan is written to cache - planner = FlowPlanner(flow) - planner.plan(request) - self.assertEqual( - CACHE_MOCK.set.call_count, 1 - ) # Ensure nothing is written to cache - self.assertEqual(CACHE_MOCK.get.call_count, 2) # Get is called twice - - def test_planner_default_context(self): - """Test planner with default_context""" - flow = Flow.objects.create( - name="test-default-context", - slug="test-default-context", - designation=FlowDesignation.AUTHENTICATION, - ) - FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy"), order=0 - ) - - user = User.objects.create(username="test-user") - request = self.request_factory.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - request.user = user - planner = FlowPlanner(flow) - planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user}) - key = cache_key(flow, user) - self.assertTrue(cache.get(key) is not None) - - def test_planner_marker_reevaluate(self): - """Test that the planner creates the proper marker""" - flow = Flow.objects.create( - name="test-default-context", - slug="test-default-context", - designation=FlowDesignation.AUTHENTICATION, - ) - - FlowStageBinding.objects.create( - target=flow, - stage=DummyStage.objects.create(name="dummy1"), - order=0, - re_evaluate_policies=True, - ) - - request = self.request_factory.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - request.user = get_anonymous_user() - - planner = FlowPlanner(flow) - plan = planner.plan(request) - - self.assertIsInstance(plan.markers[0], ReevaluateMarker) - - def test_planner_reevaluate_actual(self): - """Test planner with re-evaluate""" - flow = Flow.objects.create( - name="test-default-context", - slug="test-default-context", - designation=FlowDesignation.AUTHENTICATION, - ) - false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) - - binding = FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 - ) - binding2 = FlowStageBinding.objects.create( - target=flow, - stage=DummyStage.objects.create(name="dummy2"), - order=1, - re_evaluate_policies=True, - ) - - PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) - - request = self.request_factory.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - request.user = get_anonymous_user() - - middleware = SessionMiddleware(dummy_get_response) - middleware.process_request(request) - request.session.save() - - # Here we patch the dummy policy to evaluate to true so the stage is included - with patch( - "passbook.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE - ): - planner = FlowPlanner(flow) - plan = planner.plan(request) - - self.assertEqual(plan.stages[0], binding.stage) - self.assertEqual(plan.stages[1], binding2.stage) - - self.assertIsInstance(plan.markers[0], StageMarker) - self.assertIsInstance(plan.markers[1], ReevaluateMarker) diff --git a/passbook/flows/tests/test_transfer.py b/passbook/flows/tests/test_transfer.py deleted file mode 100644 index 0808c96e..00000000 --- a/passbook/flows/tests/test_transfer.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Test flow transfer""" -from json import dumps - -from django.test import TransactionTestCase - -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.transfer.common import DataclassEncoder -from passbook.flows.transfer.exporter import FlowExporter -from passbook.flows.transfer.importer import FlowImporter, transaction_rollback -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.models import PolicyBinding -from passbook.providers.oauth2.generators import generate_client_id -from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage -from passbook.stages.user_login.models import UserLoginStage - - -class TestFlowTransfer(TransactionTestCase): - """Test flow transfer""" - - def test_bundle_invalid_format(self): - """Test bundle with invalid format""" - importer = FlowImporter('{"version": 3}') - self.assertFalse(importer.validate()) - importer = FlowImporter( - '{"version": 1,"entries":[{"identifiers":{},"attrs":{},"model": "passbook_core.User"}]}' - ) - self.assertFalse(importer.validate()) - - def test_export_validate_import(self): - """Test export and validate it""" - flow_slug = generate_client_id() - with transaction_rollback(): - login_stage = UserLoginStage.objects.create(name=generate_client_id()) - - flow = Flow.objects.create( - slug=flow_slug, - designation=FlowDesignation.AUTHENTICATION, - name=generate_client_id(), - title=generate_client_id(), - ) - FlowStageBinding.objects.update_or_create( - target=flow, - stage=login_stage, - order=0, - ) - - exporter = FlowExporter(flow) - export = exporter.export() - self.assertEqual(len(export.entries), 3) - export_json = exporter.export_to_string() - - importer = FlowImporter(export_json) - self.assertTrue(importer.validate()) - self.assertTrue(importer.apply()) - - self.assertTrue(Flow.objects.filter(slug=flow_slug).exists()) - - def test_export_validate_import_policies(self): - """Test export and validate it""" - flow_slug = generate_client_id() - stage_name = generate_client_id() - with transaction_rollback(): - flow_policy = ExpressionPolicy.objects.create( - name=generate_client_id(), - expression="return True", - ) - flow = Flow.objects.create( - slug=flow_slug, - designation=FlowDesignation.AUTHENTICATION, - name=generate_client_id(), - title=generate_client_id(), - ) - PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0) - - user_login = UserLoginStage.objects.create(name=stage_name) - fsb = FlowStageBinding.objects.create( - target=flow, stage=user_login, order=0 - ) - PolicyBinding.objects.create(policy=flow_policy, target=fsb, order=0) - - exporter = FlowExporter(flow) - export = exporter.export() - - export_json = dumps(export, cls=DataclassEncoder) - - importer = FlowImporter(export_json) - self.assertTrue(importer.validate()) - self.assertTrue(importer.apply()) - self.assertTrue(UserLoginStage.objects.filter(name=stage_name).exists()) - self.assertTrue(Flow.objects.filter(slug=flow_slug).exists()) - - def test_export_validate_import_prompt(self): - """Test export and validate it""" - with transaction_rollback(): - # First stage fields - username_prompt = Prompt.objects.create( - field_key="username", label="Username", order=0, type=FieldTypes.TEXT - ) - password = Prompt.objects.create( - field_key="password", - label="Password", - order=1, - type=FieldTypes.PASSWORD, - ) - password_repeat = Prompt.objects.create( - field_key="password_repeat", - label="Password (repeat)", - order=2, - type=FieldTypes.PASSWORD, - ) - - # Stages - first_stage = PromptStage.objects.create(name=generate_client_id()) - first_stage.fields.set([username_prompt, password, password_repeat]) - first_stage.save() - - flow = Flow.objects.create( - name=generate_client_id(), - slug=generate_client_id(), - designation=FlowDesignation.ENROLLMENT, - title=generate_client_id(), - ) - - FlowStageBinding.objects.create(target=flow, stage=first_stage, order=0) - - exporter = FlowExporter(flow) - export = exporter.export() - export_json = dumps(export, cls=DataclassEncoder) - - importer = FlowImporter(export_json) - - self.assertTrue(importer.validate()) - self.assertTrue(importer.apply()) diff --git a/passbook/flows/tests/test_transfer_docs.py b/passbook/flows/tests/test_transfer_docs.py deleted file mode 100644 index cad4a953..00000000 --- a/passbook/flows/tests/test_transfer_docs.py +++ /dev/null @@ -1,29 +0,0 @@ -"""test example flows in docs""" -from glob import glob -from pathlib import Path -from typing import Callable - -from django.test import TransactionTestCase - -from passbook.flows.transfer.importer import FlowImporter - - -class TestTransferDocs(TransactionTestCase): - """Empty class, test methods are added dynamically""" - - -def pbflow_tester(file_name: str) -> Callable: - """This is used instead of subTest for better visibility""" - - def tester(self: TestTransferDocs): - with open(file_name, "r") as flow_json: - importer = FlowImporter(flow_json.read()) - self.assertTrue(importer.validate()) - self.assertTrue(importer.apply()) - - return tester - - -for flow_file in glob("website/static/flows/*.pbflow"): - method_name = Path(flow_file).stem.replace("-", "_").replace(".", "_") - setattr(TestTransferDocs, f"test_flow_{method_name}", pbflow_tester(flow_file)) diff --git a/passbook/flows/tests/test_views.py b/passbook/flows/tests/test_views.py deleted file mode 100644 index 1dcec611..00000000 --- a/passbook/flows/tests/test_views.py +++ /dev/null @@ -1,353 +0,0 @@ -"""flow views tests""" -from unittest.mock import MagicMock, PropertyMock, patch - -from django.http import HttpRequest, HttpResponse -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException -from passbook.flows.markers import ReevaluateMarker, StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import FlowPlan -from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN -from passbook.lib.config import CONFIG -from passbook.policies.dummy.models import DummyPolicy -from passbook.policies.http import AccessDeniedResponse -from passbook.policies.models import PolicyBinding -from passbook.policies.types import PolicyResult -from passbook.stages.dummy.models import DummyStage - -POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) -POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True)) - - -def to_stage_response(request: HttpRequest, source: HttpResponse): - """Mock for to_stage_response that returns the original response, so we can check - inheritance and member attributes""" - return source - - -TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response) - - -class TestFlowExecutor(TestCase): - """Test views logic""" - - def setUp(self): - self.client = Client() - - def test_existing_plan_diff_flow(self): - """Check that a plan for a different flow cancels the current plan""" - flow = Flow.objects.create( - name="test-existing-plan-diff", - slug="test-existing-plan-diff", - designation=FlowDesignation.AUTHENTICATION, - ) - stage = DummyStage.objects.create(name="dummy") - plan = FlowPlan( - flow_pk=flow.pk.hex + "a", stages=[stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - cancel_mock = MagicMock() - with patch("passbook.flows.views.FlowExecutorView.cancel", cancel_mock): - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug} - ), - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(cancel_mock.call_count, 2) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - @patch( - "passbook.policies.engine.PolicyEngine.result", - POLICY_RETURN_FALSE, - ) - def test_invalid_non_applicable_flow(self): - """Tests that a non-applicable flow returns the correct error message""" - flow = Flow.objects.create( - name="test-non-applicable", - slug="test-non-applicable", - designation=FlowDesignation.AUTHENTICATION, - ) - - CONFIG.update_from_dict({"domain": "testserver"}) - response = self.client.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - def test_invalid_empty_flow(self): - """Tests that an empty flow returns the correct error message""" - flow = Flow.objects.create( - name="test-empty", - slug="test-empty", - designation=FlowDesignation.AUTHENTICATION, - ) - - CONFIG.update_from_dict({"domain": "testserver"}) - response = self.client.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - ) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content) - - def test_invalid_flow_redirect(self): - """Tests that an invalid flow still redirects""" - flow = Flow.objects.create( - name="test-empty", - slug="test-empty", - designation=FlowDesignation.AUTHENTICATION, - ) - - CONFIG.update_from_dict({"domain": "testserver"}) - dest = "/unique-string" - url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}) - response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}") - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": dest}, - ) - - def test_multi_stage_flow(self): - """Test a full flow with multiple stages""" - flow = Flow.objects.create( - name="test-full", - slug="test-full", - designation=FlowDesignation.AUTHENTICATION, - ) - FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 - ) - FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1 - ) - - exec_url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug} - ) - # First Request, start planning, renders form - response = self.client.get(exec_url) - self.assertEqual(response.status_code, 200) - # Check that two stages are in plan - session = self.client.session - plan: FlowPlan = session[SESSION_KEY_PLAN] - self.assertEqual(len(plan.stages), 2) - # Second request, submit form, one stage left - response = self.client.post(exec_url) - # Second request redirects to the same URL - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, exec_url) - # Check that two stages are in plan - session = self.client.session - plan: FlowPlan = session[SESSION_KEY_PLAN] - self.assertEqual(len(plan.stages), 1) - - def test_reevaluate_remove_last(self): - """Test planner with re-evaluate (last stage is removed)""" - flow = Flow.objects.create( - name="test-default-context", - slug="test-default-context", - designation=FlowDesignation.AUTHENTICATION, - ) - false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) - - binding = FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 - ) - binding2 = FlowStageBinding.objects.create( - target=flow, - stage=DummyStage.objects.create(name="dummy2"), - order=1, - re_evaluate_policies=True, - ) - - PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) - - # Here we patch the dummy policy to evaluate to true so the stage is included - with patch( - "passbook.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE - ): - - exec_url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug} - ) - # First request, run the planner - response = self.client.get(exec_url) - self.assertEqual(response.status_code, 200) - - plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] - - self.assertEqual(plan.stages[0], binding.stage) - self.assertEqual(plan.stages[1], binding2.stage) - - self.assertIsInstance(plan.markers[0], StageMarker) - self.assertIsInstance(plan.markers[1], ReevaluateMarker) - - # Second request, this passes the first dummy stage - response = self.client.post(exec_url) - self.assertEqual(response.status_code, 302) - - # third request, this should trigger the re-evaluate - # We do this request without the patch, so the policy results in false - response = self.client.post(exec_url) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, reverse("passbook_core:shell")) - - def test_reevaluate_remove_middle(self): - """Test planner with re-evaluate (middle stage is removed)""" - flow = Flow.objects.create( - name="test-default-context", - slug="test-default-context", - designation=FlowDesignation.AUTHENTICATION, - ) - false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) - - binding = FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 - ) - binding2 = FlowStageBinding.objects.create( - target=flow, - stage=DummyStage.objects.create(name="dummy2"), - order=1, - re_evaluate_policies=True, - ) - binding3 = FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2 - ) - - PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) - - # Here we patch the dummy policy to evaluate to true so the stage is included - with patch( - "passbook.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE - ): - - exec_url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug} - ) - # First request, run the planner - response = self.client.get(exec_url) - - self.assertEqual(response.status_code, 200) - plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] - - self.assertEqual(plan.stages[0], binding.stage) - self.assertEqual(plan.stages[1], binding2.stage) - self.assertEqual(plan.stages[2], binding3.stage) - - self.assertIsInstance(plan.markers[0], StageMarker) - self.assertIsInstance(plan.markers[1], ReevaluateMarker) - self.assertIsInstance(plan.markers[2], StageMarker) - - # Second request, this passes the first dummy stage - response = self.client.post(exec_url) - self.assertEqual(response.status_code, 302) - - plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] - - self.assertEqual(plan.stages[0], binding2.stage) - self.assertEqual(plan.stages[1], binding3.stage) - - self.assertIsInstance(plan.markers[0], StageMarker) - self.assertIsInstance(plan.markers[1], StageMarker) - - # third request, this should trigger the re-evaluate - # We do this request without the patch, so the policy results in false - response = self.client.post(exec_url) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - def test_reevaluate_remove_consecutive(self): - """Test planner with re-evaluate (consecutive stages are removed)""" - flow = Flow.objects.create( - name="test-default-context", - slug="test-default-context", - designation=FlowDesignation.AUTHENTICATION, - ) - false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2) - - binding = FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0 - ) - binding2 = FlowStageBinding.objects.create( - target=flow, - stage=DummyStage.objects.create(name="dummy2"), - order=1, - re_evaluate_policies=True, - ) - binding3 = FlowStageBinding.objects.create( - target=flow, - stage=DummyStage.objects.create(name="dummy3"), - order=2, - re_evaluate_policies=True, - ) - binding4 = FlowStageBinding.objects.create( - target=flow, stage=DummyStage.objects.create(name="dummy4"), order=2 - ) - - PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0) - PolicyBinding.objects.create(policy=false_policy, target=binding3, order=0) - - # Here we patch the dummy policy to evaluate to true so the stage is included - with patch( - "passbook.policies.dummy.models.DummyPolicy.passes", POLICY_RETURN_TRUE - ): - - exec_url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug} - ) - # First request, run the planner - response = self.client.get(exec_url) - self.assertEqual(response.status_code, 200) - self.assertIn("dummy1", force_str(response.content)) - - plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] - - self.assertEqual(plan.stages[0], binding.stage) - self.assertEqual(plan.stages[1], binding2.stage) - self.assertEqual(plan.stages[2], binding3.stage) - self.assertEqual(plan.stages[3], binding4.stage) - - self.assertIsInstance(plan.markers[0], StageMarker) - self.assertIsInstance(plan.markers[1], ReevaluateMarker) - self.assertIsInstance(plan.markers[2], ReevaluateMarker) - self.assertIsInstance(plan.markers[3], StageMarker) - - # Second request, this passes the first dummy stage - response = self.client.post(exec_url) - self.assertEqual(response.status_code, 302) - - # third request, this should trigger the re-evaluate - # A get request will evaluate the policies and this will return stage 4 - # but it won't save it, hence we cant' check the plan - response = self.client.get(exec_url) - self.assertEqual(response.status_code, 200) - self.assertIn("dummy4", force_str(response.content)) - - # fourth request, this confirms the last stage (dummy4) - # We do this request without the patch, so the policy results in false - response = self.client.post(exec_url) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) diff --git a/passbook/flows/tests/test_views_helper.py b/passbook/flows/tests/test_views_helper.py deleted file mode 100644 index a9749d81..00000000 --- a/passbook/flows/tests/test_views_helper.py +++ /dev/null @@ -1,47 +0,0 @@ -"""flow views tests""" -from django.shortcuts import reverse -from django.test import Client, TestCase - -from passbook.flows.models import Flow, FlowDesignation -from passbook.flows.planner import FlowPlan -from passbook.flows.views import SESSION_KEY_PLAN - - -class TestHelperView(TestCase): - """Test helper views logic""" - - def setUp(self): - self.client = Client() - - def test_default_view(self): - """Test that ToDefaultFlow returns the expected URL""" - flow = Flow.objects.filter( - designation=FlowDesignation.INVALIDATION, - ).first() - response = self.client.get( - reverse("passbook_flows:default-invalidation"), - ) - expected_url = reverse( - "passbook_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug} - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, expected_url) - - def test_default_view_invalid_plan(self): - """Test that ToDefaultFlow returns the expected URL (with an invalid plan)""" - flow = Flow.objects.filter( - designation=FlowDesignation.INVALIDATION, - ).first() - plan = FlowPlan(flow_pk=flow.pk.hex + "aa") - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse("passbook_flows:default-invalidation"), - ) - expected_url = reverse( - "passbook_flows:flow-executor-shell", kwargs={"flow_slug": flow.slug} - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, expected_url) diff --git a/passbook/flows/transfer/common.py b/passbook/flows/transfer/common.py deleted file mode 100644 index 8d8af49b..00000000 --- a/passbook/flows/transfer/common.py +++ /dev/null @@ -1,68 +0,0 @@ -"""transfer common classes""" -from dataclasses import asdict, dataclass, field, is_dataclass -from json.encoder import JSONEncoder -from typing import Any, Dict, List -from uuid import UUID - -from passbook.lib.models import SerializerModel -from passbook.lib.sentry import SentryIgnoredException - - -def get_attrs(obj: SerializerModel) -> Dict[str, Any]: - """Get object's attributes via their serializer, and covert it to a normal dict""" - data = dict(obj.serializer(obj).data) - to_remove = ("policies", "stages", "pk", "background") - for to_remove_name in to_remove: - if to_remove_name in data: - data.pop(to_remove_name) - return data - - -@dataclass -class FlowBundleEntry: - """Single entry of a bundle""" - - identifiers: Dict[str, Any] - model: str - attrs: Dict[str, Any] - - @staticmethod - def from_model( - model: SerializerModel, *extra_identifier_names: str - ) -> "FlowBundleEntry": - """Convert a SerializerModel instance to a Bundle Entry""" - identifiers = { - "pk": model.pk, - } - all_attrs = get_attrs(model) - - for extra_identifier_name in extra_identifier_names: - identifiers[extra_identifier_name] = all_attrs.pop(extra_identifier_name) - return FlowBundleEntry( - identifiers=identifiers, - model=f"{model._meta.app_label}.{model._meta.model_name}", - attrs=all_attrs, - ) - - -@dataclass -class FlowBundle: - """Dataclass used for a full export""" - - version: int = field(default=1) - entries: List[FlowBundleEntry] = field(default_factory=list) - - -class DataclassEncoder(JSONEncoder): - """Convert FlowBundleEntry to json""" - - def default(self, o): - if is_dataclass(o): - return asdict(o) - if isinstance(o, UUID): - return str(o) - return super().default(o) - - -class EntryInvalidError(SentryIgnoredException): - """Error raised when an entry is invalid""" diff --git a/passbook/flows/transfer/exporter.py b/passbook/flows/transfer/exporter.py deleted file mode 100644 index 9e97f3ac..00000000 --- a/passbook/flows/transfer/exporter.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Flow exporter""" -from json import dumps -from typing import Iterator, List -from uuid import UUID - -from django.db.models import Q - -from passbook.flows.models import Flow, FlowStageBinding, Stage -from passbook.flows.transfer.common import DataclassEncoder, FlowBundle, FlowBundleEntry -from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel -from passbook.stages.prompt.models import PromptStage - - -class FlowExporter: - """Export flow with attached stages into json""" - - flow: Flow - with_policies: bool - with_stage_prompts: bool - - pbm_uuids: List[UUID] - - def __init__(self, flow: Flow): - self.flow = flow - self.with_policies = True - self.with_stage_prompts = True - - def _prepare_pbm(self): - self.pbm_uuids = [self.flow.pbm_uuid] - for stage_subclass in Stage.__subclasses__(): - if issubclass(stage_subclass, PolicyBindingModel): - self.pbm_uuids += stage_subclass.objects.filter( - flow=self.flow - ).values_list("pbm_uuid", flat=True) - self.pbm_uuids += FlowStageBinding.objects.filter(target=self.flow).values_list( - "pbm_uuid", flat=True - ) - - def walk_stages(self) -> Iterator[FlowBundleEntry]: - """Convert all stages attached to self.flow into FlowBundleEntry objects""" - stages = ( - Stage.objects.filter(flow=self.flow).select_related().select_subclasses() - ) - for stage in stages: - if isinstance(stage, PromptStage): - pass - yield FlowBundleEntry.from_model(stage, "name") - - def walk_stage_bindings(self) -> Iterator[FlowBundleEntry]: - """Convert all bindings attached to self.flow into FlowBundleEntry objects""" - bindings = FlowStageBinding.objects.filter(target=self.flow).select_related() - for binding in bindings: - yield FlowBundleEntry.from_model(binding, "target", "stage", "order") - - def walk_policies(self) -> Iterator[FlowBundleEntry]: - """Walk over all policies. This is done at the beginning of the export for stages that have - a direct foreign key to a policy.""" - # Special case for PromptStage as that has a direct M2M to policy, we have to ensure - # all policies referenced in there we also include here - prompt_stages = PromptStage.objects.filter(flow=self.flow).values_list( - "pk", flat=True - ) - query = Q(bindings__in=self.pbm_uuids) | Q(promptstage__in=prompt_stages) - policies = Policy.objects.filter(query).select_related() - for policy in policies: - yield FlowBundleEntry.from_model(policy) - - def walk_policy_bindings(self) -> Iterator[FlowBundleEntry]: - """Walk over all policybindings relative to us. This is run at the end of the export, as - we are sure all objects exist now.""" - bindings = PolicyBinding.objects.filter( - target__in=self.pbm_uuids - ).select_related() - for binding in bindings: - yield FlowBundleEntry.from_model(binding, "policy", "target", "order") - - def walk_stage_prompts(self) -> Iterator[FlowBundleEntry]: - """Walk over all prompts associated with any PromptStages""" - prompt_stages = PromptStage.objects.filter(flow=self.flow) - for stage in prompt_stages: - for prompt in stage.fields.all(): - yield FlowBundleEntry.from_model(prompt) - - def export(self) -> FlowBundle: - """Create a list of all objects including the flow""" - if self.with_policies: - self._prepare_pbm() - bundle = FlowBundle() - bundle.entries.append(FlowBundleEntry.from_model(self.flow, "slug")) - if self.with_stage_prompts: - bundle.entries.extend(self.walk_stage_prompts()) - if self.with_policies: - bundle.entries.extend(self.walk_policies()) - bundle.entries.extend(self.walk_stages()) - bundle.entries.extend(self.walk_stage_bindings()) - if self.with_policies: - bundle.entries.extend(self.walk_policy_bindings()) - return bundle - - def export_to_string(self) -> str: - """Call export and convert it to json""" - return dumps(self.export(), cls=DataclassEncoder) diff --git a/passbook/flows/transfer/importer.py b/passbook/flows/transfer/importer.py deleted file mode 100644 index f5bf464a..00000000 --- a/passbook/flows/transfer/importer.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Flow importer""" -from contextlib import contextmanager -from copy import deepcopy -from json import loads -from typing import Any, Dict - -from dacite import from_dict -from dacite.exceptions import DaciteError -from django.apps import apps -from django.db import transaction -from django.db.models import Model -from django.db.models.query_utils import Q -from django.db.utils import IntegrityError -from rest_framework.exceptions import ValidationError -from rest_framework.serializers import BaseSerializer, Serializer -from structlog import BoundLogger, get_logger - -from passbook.flows.models import Flow, FlowStageBinding, Stage -from passbook.flows.transfer.common import ( - EntryInvalidError, - FlowBundle, - FlowBundleEntry, -) -from passbook.lib.models import SerializerModel -from passbook.policies.models import Policy, PolicyBinding -from passbook.stages.prompt.models import Prompt - -ALLOWED_MODELS = (Flow, FlowStageBinding, Stage, Policy, PolicyBinding, Prompt) - - -@contextmanager -def transaction_rollback(): - """Enters an atomic transaction and always triggers a rollback at the end of the block.""" - atomic = transaction.atomic() - atomic.__enter__() - yield - atomic.__exit__(IntegrityError, None, None) - - -class FlowImporter: - """Import Flow from json""" - - __import: FlowBundle - - __pk_map: Dict[Any, Model] - - logger: BoundLogger - - def __init__(self, json_input: str): - self.logger = get_logger() - self.__pk_map = {} - import_dict = loads(json_input) - try: - self.__import = from_dict(FlowBundle, import_dict) - except DaciteError as exc: - raise EntryInvalidError from exc - - def __update_pks_for_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Any]: - """Replace any value if it is a known primary key of an other object""" - - def updater(value) -> Any: - if value in self.__pk_map: - self.logger.debug("updating reference in entry", value=value) - return self.__pk_map[value] - return value - - for key, value in attrs.items(): - if isinstance(value, dict): - for idx, _inner_key in enumerate(value): - value[_inner_key] = updater(value[_inner_key]) - elif isinstance(value, list): - for idx, _inner_value in enumerate(value): - attrs[key][idx] = updater(_inner_value) - else: - attrs[key] = updater(value) - return attrs - - def __query_from_identifier(self, attrs: Dict[str, Any]) -> Q: - """Generate an or'd query from all identifiers in an entry""" - # Since identifiers can also be pk-references to other objects (see FlowStageBinding) - # we have to ensure those references are also replaced - main_query = Q(pk=attrs["pk"]) - sub_query = Q() - for identifier, value in attrs.items(): - if identifier == "pk": - continue - sub_query &= Q(**{identifier: value}) - return main_query | sub_query - - def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer: - """Validate a single entry""" - model_app_label, model_name = entry.model.split(".") - model: SerializerModel = apps.get_model(model_app_label, model_name) - if not isinstance(model(), ALLOWED_MODELS): - raise EntryInvalidError(f"Model {model} not allowed") - - # If we try to validate without referencing a possible instance - # we'll get a duplicate error, hence we load the model here and return - # the full serializer for later usage - # Because a model might have multiple unique columns, we chain all identifiers together - # to create an OR query. - updated_identifiers = self.__update_pks_for_attrs(entry.identifiers) - for key, value in list(updated_identifiers.items()): - if isinstance(value, dict) and "pk" in value: - del updated_identifiers[key] - updated_identifiers[f"{key}"] = value["pk"] - existing_models = model.objects.filter( - self.__query_from_identifier(updated_identifiers) - ) - - serializer_kwargs = {} - if existing_models.exists(): - model_instance = existing_models.first() - self.logger.debug( - "initialise serializer with instance", - model=model, - instance=model_instance, - pk=model_instance.pk, - ) - serializer_kwargs["instance"] = model_instance - else: - self.logger.debug( - "initialise new instance", model=model, **updated_identifiers - ) - full_data = self.__update_pks_for_attrs(entry.attrs) - full_data.update(updated_identifiers) - serializer_kwargs["data"] = full_data - - serializer: Serializer = model().serializer(**serializer_kwargs) - try: - serializer.is_valid(raise_exception=True) - except ValidationError as exc: - raise EntryInvalidError(f"Serializer errors {serializer.errors}") from exc - return serializer - - def apply(self) -> bool: - """Apply (create/update) flow json, in database transaction""" - try: - with transaction.atomic(): - if not self._apply_models(): - self.logger.debug("Reverting changes due to error") - raise IntegrityError - except IntegrityError: - return False - else: - self.logger.debug("Committing changes") - return True - - def _apply_models(self) -> bool: - """Apply (create/update) flow json""" - self.__pk_map = {} - entries = deepcopy(self.__import.entries) - for entry in entries: - model_app_label, model_name = entry.model.split(".") - model: SerializerModel = apps.get_model(model_app_label, model_name) - # Validate each single entry - try: - serializer = self._validate_single(entry) - except EntryInvalidError as exc: - self.logger.error("entry not valid", entry=entry, error=exc) - return False - - model = serializer.save() - self.__pk_map[entry.identifiers["pk"]] = model.pk - self.logger.debug("updated model", model=model, pk=model.pk) - return True - - def validate(self) -> bool: - """Validate loaded flow export, ensure all models are allowed - and serializers have no errors""" - self.logger.debug("Starting flow import validaton") - if self.__import.version != 1: - self.logger.warning("Invalid bundle version") - return False - with transaction_rollback(): - successful = self._apply_models() - if not successful: - self.logger.debug("Flow validation failed") - return successful diff --git a/passbook/flows/urls.py b/passbook/flows/urls.py deleted file mode 100644 index 5db88dd7..00000000 --- a/passbook/flows/urls.py +++ /dev/null @@ -1,49 +0,0 @@ -"""flow urls""" -from django.urls import path - -from passbook.flows.models import FlowDesignation -from passbook.flows.views import ( - CancelView, - ConfigureFlowInitView, - FlowExecutorShellView, - FlowExecutorView, - ToDefaultFlow, -) - -urlpatterns = [ - path( - "-/default/authentication/", - ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION), - name="default-authentication", - ), - path( - "-/default/invalidation/", - ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION), - name="default-invalidation", - ), - path( - "-/default/recovery/", - ToDefaultFlow.as_view(designation=FlowDesignation.RECOVERY), - name="default-recovery", - ), - path( - "-/default/enrollment/", - ToDefaultFlow.as_view(designation=FlowDesignation.ENROLLMENT), - name="default-enrollment", - ), - path( - "-/default/unenrollment/", - ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT), - name="default-unenrollment", - ), - path("-/cancel/", CancelView.as_view(), name="cancel"), - path( - "-/configure//", - ConfigureFlowInitView.as_view(), - name="configure", - ), - path("b//", FlowExecutorView.as_view(), name="flow-executor"), - path( - "/", FlowExecutorShellView.as_view(), name="flow-executor-shell" - ), -] diff --git a/passbook/flows/views.py b/passbook/flows/views.py deleted file mode 100644 index c4439101..00000000 --- a/passbook/flows/views.py +++ /dev/null @@ -1,324 +0,0 @@ -"""passbook multi-stage authentication engine""" -from traceback import format_tb -from typing import Any, Dict, Optional - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import ( - Http404, - HttpRequest, - HttpResponse, - HttpResponseRedirect, - JsonResponse, -) -from django.shortcuts import get_object_or_404, redirect, reverse -from django.template.response import TemplateResponse -from django.utils.decorators import method_decorator -from django.views.decorators.clickjacking import xframe_options_sameorigin -from django.views.generic import TemplateView, View -from structlog import get_logger - -from passbook.audit.models import cleanse_dict -from passbook.core.models import PASSBOOK_USER_DEBUG -from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException -from passbook.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner -from passbook.lib.utils.reflection import class_to_path -from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs -from passbook.policies.http import AccessDeniedResponse - -LOGGER = get_logger() -# Argument used to redirect user after login -NEXT_ARG_NAME = "next" -SESSION_KEY_PLAN = "passbook_flows_plan" -SESSION_KEY_APPLICATION_PRE = "passbook_flows_application_pre" -SESSION_KEY_GET = "passbook_flows_get" - - -@method_decorator(xframe_options_sameorigin, name="dispatch") -class FlowExecutorView(View): - """Stage 1 Flow executor, passing requests to Stage Views""" - - flow: Flow - - plan: Optional[FlowPlan] = None - current_stage: Stage - current_stage_view: View - - def setup(self, request: HttpRequest, flow_slug: str): - super().setup(request, flow_slug=flow_slug) - self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) - - def handle_invalid_flow(self, exc: BaseException) -> HttpResponse: - """When a flow is non-applicable check if user is on the correct domain""" - if NEXT_ARG_NAME in self.request.GET: - if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)): - LOGGER.debug("f(exec): Redirecting to next on fail") - return redirect(self.request.GET.get(NEXT_ARG_NAME)) - message = exc.__doc__ if exc.__doc__ else str(exc) - return self.stage_invalid(error_message=message) - - def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: - # Early check if theres an active Plan for the current session - if SESSION_KEY_PLAN in self.request.session: - self.plan = self.request.session[SESSION_KEY_PLAN] - if self.plan.flow_pk != self.flow.pk.hex: - LOGGER.warning( - "f(exec): Found existing plan for other flow, deleteing plan", - flow_slug=flow_slug, - ) - # Existing plan is deleted from session and instance - self.plan = None - self.cancel() - LOGGER.debug("f(exec): Continuing existing plan", flow_slug=flow_slug) - - # Don't check session again as we've either already loaded the plan or we need to plan - if not self.plan: - LOGGER.debug( - "f(exec): No active Plan found, initiating planner", flow_slug=flow_slug - ) - try: - self.plan = self._initiate_plan() - except FlowNonApplicableException as exc: - LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc) - return to_stage_response(self.request, self.handle_invalid_flow(exc)) - except EmptyFlowException as exc: - LOGGER.warning("f(exec): Flow is empty", exc=exc) - return to_stage_response(self.request, self.handle_invalid_flow(exc)) - # We don't save the Plan after getting the next stage - # as it hasn't been successfully passed yet - next_stage = self.plan.next(self.request) - if not next_stage: - LOGGER.debug("f(exec): no more stages, flow is done.") - return self._flow_done() - self.current_stage = next_stage - LOGGER.debug( - "f(exec): Current stage", - current_stage=self.current_stage, - flow_slug=self.flow.slug, - ) - stage_cls = self.current_stage.type - self.current_stage_view = stage_cls(self) - self.current_stage_view.args = self.args - self.current_stage_view.kwargs = self.kwargs - self.current_stage_view.request = request - return super().dispatch(request) - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """pass get request to current stage""" - LOGGER.debug( - "f(exec): Passing GET", - view_class=class_to_path(self.current_stage_view.__class__), - stage=self.current_stage, - flow_slug=self.flow.slug, - ) - try: - stage_response = self.current_stage_view.get(request, *args, **kwargs) - return to_stage_response(request, stage_response) - except Exception as exc: # pylint: disable=broad-except - LOGGER.exception(exc) - return to_stage_response(request, FlowErrorResponse(request, exc)) - - def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """pass post request to current stage""" - LOGGER.debug( - "f(exec): Passing POST", - view_class=class_to_path(self.current_stage_view.__class__), - stage=self.current_stage, - flow_slug=self.flow.slug, - ) - try: - stage_response = self.current_stage_view.post(request, *args, **kwargs) - return to_stage_response(request, stage_response) - except Exception as exc: # pylint: disable=broad-except - LOGGER.exception(exc) - return to_stage_response(request, FlowErrorResponse(request, exc)) - - def _initiate_plan(self) -> FlowPlan: - planner = FlowPlanner(self.flow) - plan = planner.plan(self.request) - self.request.session[SESSION_KEY_PLAN] = plan - return plan - - def _flow_done(self) -> HttpResponse: - """User Successfully passed all stages""" - # Since this is wrapped by the ExecutorShell, the next argument is saved in the session - # extract the next param before cancel as that cleans it - next_param = self.request.session.get(SESSION_KEY_GET, {}).get( - NEXT_ARG_NAME, "passbook_core:shell" - ) - self.cancel() - return redirect_with_qs(next_param) - - def stage_ok(self) -> HttpResponse: - """Callback called by stages upon successful completion. - Persists updated plan and context to session.""" - LOGGER.debug( - "f(exec): Stage ok", - stage_class=class_to_path(self.current_stage_view.__class__), - flow_slug=self.flow.slug, - ) - self.plan.pop() - self.request.session[SESSION_KEY_PLAN] = self.plan - if self.plan.stages: - LOGGER.debug( - "f(exec): Continuing with next stage", - reamining=len(self.plan.stages), - flow_slug=self.flow.slug, - ) - return redirect_with_qs( - "passbook_flows:flow-executor", self.request.GET, **self.kwargs - ) - # User passed all stages - LOGGER.debug( - "f(exec): User passed all stages", - flow_slug=self.flow.slug, - context=cleanse_dict(self.plan.context), - ) - return self._flow_done() - - def stage_invalid(self, error_message: Optional[str] = None) -> HttpResponse: - """Callback used stage when data is correct but a policy denies access - or the user account is disabled. - - Optionally, an exception can be passed, which will be shown if the current user - is a superuser.""" - LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug) - self.cancel() - response = AccessDeniedResponse( - self.request, template="flows/denied_shell.html" - ) - response.error_message = error_message - return to_stage_response(self.request, response) - - def cancel(self): - """Cancel current execution and return a redirect""" - keys_to_delete = [ - SESSION_KEY_APPLICATION_PRE, - SESSION_KEY_PLAN, - SESSION_KEY_GET, - ] - for key in keys_to_delete: - if key in self.request.session: - del self.request.session[key] - - -class FlowErrorResponse(TemplateResponse): - """Response class when an unhandled error occurs during a stage. Normal users - are shown an error message, superusers are shown a full stacktrace.""" - - error: Exception - - def __init__(self, request: HttpRequest, error: Exception) -> None: - # For some reason pyright complains about keyword argument usage here - # pyright: reportGeneralTypeIssues=false - super().__init__(request=request, template="flows/error.html") - self.error = error - - def resolve_context( - self, context: Optional[Dict[str, Any]] - ) -> Optional[Dict[str, Any]]: - if not context: - context = {} - context["error"] = self.error - if self._request.user and self._request.user.is_authenticated: - if self._request.user.is_superuser or self._request.user.attributes.get( - PASSBOOK_USER_DEBUG, False - ): - context["tb"] = "".join(format_tb(self.error.__traceback__)) - return context - - -class FlowExecutorShellView(TemplateView): - """Executor Shell view, loads a dummy card with a spinner - that loads the next stage in the background.""" - - template_name = "flows/shell.html" - - def get_context_data(self, **kwargs) -> Dict[str, Any]: - flow: Flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) - kwargs["background_url"] = flow.background.url - kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs) - self.request.session[SESSION_KEY_GET] = self.request.GET - return kwargs - - -class CancelView(View): - """View which canels the currently active plan""" - - def get(self, request: HttpRequest) -> HttpResponse: - """View which canels the currently active plan""" - if SESSION_KEY_PLAN in request.session: - del request.session[SESSION_KEY_PLAN] - LOGGER.debug("Canceled current plan") - return redirect("passbook_core:shell") - - -class ToDefaultFlow(View): - """Redirect to default flow matching by designation""" - - designation: Optional[FlowDesignation] = None - - def dispatch(self, request: HttpRequest) -> HttpResponse: - flow = Flow.with_policy(request, designation=self.designation) - if not flow: - raise Http404 - # If user already has a pending plan, clear it so we don't have to later. - if SESSION_KEY_PLAN in self.request.session: - plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] - if plan.flow_pk != flow.pk.hex: - LOGGER.warning( - "f(def): Found existing plan for other flow, deleteing plan", - flow_slug=flow.slug, - ) - del self.request.session[SESSION_KEY_PLAN] - return redirect_with_qs( - "passbook_flows:flow-executor-shell", request.GET, flow_slug=flow.slug - ) - - -def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse: - """Convert normal HttpResponse into JSON Response""" - if isinstance(source, HttpResponseRedirect) or source.status_code == 302: - redirect_url = source["Location"] - if request.path != redirect_url: - return JsonResponse({"type": "redirect", "to": redirect_url}) - return source - if isinstance(source, TemplateResponse): - return JsonResponse( - {"type": "template", "body": source.render().content.decode("utf-8")} - ) - # Check for actual HttpResponse (without isinstance as we dont want to check inheritance) - if source.__class__ == HttpResponse: - return JsonResponse( - {"type": "template", "body": source.content.decode("utf-8")} - ) - return source - - -class ConfigureFlowInitView(LoginRequiredMixin, View): - """Initiate planner for selected change flow and redirect to flow executor, - or raise Http404 if no configure_flow has been set.""" - - def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse: - """Initiate planner for selected change flow and redirect to flow executor, - or raise Http404 if no configure_flow has been set.""" - try: - stage: Stage = Stage.objects.get_subclass(pk=stage_uuid) - except Stage.DoesNotExist as exc: - raise Http404 from exc - if not isinstance(stage, ConfigurableStage): - LOGGER.debug("Stage does not inherit ConfigurableStage", stage=stage) - raise Http404 - if not stage.configure_flow: - LOGGER.debug("Stage has no configure_flow set", stage=stage) - raise Http404 - - plan = FlowPlanner(stage.configure_flow).plan( - request, {PLAN_CONTEXT_PENDING_USER: request.user} - ) - request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - self.request.GET, - flow_slug=stage.configure_flow.slug, - ) diff --git a/passbook/lib/apps.py b/passbook/lib/apps.py deleted file mode 100644 index 301735ee..00000000 --- a/passbook/lib/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook lib app config""" -from django.apps import AppConfig - - -class PassbookLibConfig(AppConfig): - """passbook lib app config""" - - name = "passbook.lib" - label = "passbook_lib" - verbose_name = "passbook lib" diff --git a/passbook/lib/config.py b/passbook/lib/config.py deleted file mode 100644 index 73479ebe..00000000 --- a/passbook/lib/config.py +++ /dev/null @@ -1,173 +0,0 @@ -"""passbook core config loader""" -import os -from collections.abc import Mapping -from contextlib import contextmanager -from glob import glob -from json import dumps -from time import time -from typing import Any, Dict -from urllib.parse import urlparse - -import yaml -from django.conf import ImproperlyConfigured -from django.http import HttpRequest - -SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", ""] + glob( - "/etc/passbook/config.d/*.yml", recursive=True -) -ENV_PREFIX = "PASSBOOK" -ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local") - - -def context_processor(request: HttpRequest) -> Dict[str, Any]: - """Context Processor that injects config object into every template""" - kwargs = {"config": CONFIG.raw} - return kwargs - - -class ConfigLoader: - """Search through SEARCH_PATHS and load configuration. Environment variables starting with - `ENV_PREFIX` are also applied. - - A variable like PASSBOOK_POSTGRESQL__HOST would translate to postgresql.host""" - - loaded_file = [] - - __config = {} - - def __init__(self): - super().__init__() - base_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "../..")) - for path in SEARCH_PATHS: - # Check if path is relative, and if so join with base_dir - if not os.path.isabs(path): - path = os.path.join(base_dir, path) - if os.path.isfile(path) and os.path.exists(path): - # Path is an existing file, so we just read it and update our config with it - self.update_from_file(path) - elif os.path.isdir(path) and os.path.exists(path): - # Path is an existing dir, so we try to read the env config from it - env_paths = [ - os.path.join(path, ENVIRONMENT + ".yml"), - os.path.join(path, ENVIRONMENT + ".env.yml"), - ] - for env_file in env_paths: - if os.path.isfile(env_file) and os.path.exists(env_file): - # Update config with env file - self.update_from_file(env_file) - self.update_from_env() - - def _log(self, level: str, message: str, **kwargs): - """Custom Log method, we want to ensure ConfigLoader always logs JSON even when - 'structlog' or 'logging' hasn't been configured yet.""" - output = { - "event": message, - "level": level, - "logger": self.__class__.__module__, - "timestamp": time(), - } - output.update(kwargs) - print(dumps(output)) - - def update(self, root, updatee): - """Recursively update dictionary""" - for key, value in updatee.items(): - if isinstance(value, Mapping): - root[key] = self.update(root.get(key, {}), value) - else: - if isinstance(value, str): - value = self.parse_uri(value) - root[key] = value - return root - - def parse_uri(self, value): - """Parse string values which start with a URI""" - url = urlparse(value) - if url.scheme == "env": - value = os.getenv(url.netloc, url.query) - return value - - def update_from_file(self, path: str): - """Update config from file contents""" - try: - with open(path) as file: - try: - self.update(self.__config, yaml.safe_load(file)) - self._log("debug", "Loaded config", file=path) - self.loaded_file.append(path) - except yaml.YAMLError as exc: - raise ImproperlyConfigured from exc - except PermissionError as exc: - self._log( - "warning", "Permission denied while reading file", path=path, error=exc - ) - - def update_from_dict(self, update: dict): - """Update config from dict""" - self.__config.update(update) - - def update_from_env(self): - """Check environment variables""" - outer = {} - idx = 0 - for key, value in os.environ.items(): - if not key.startswith(ENV_PREFIX): - continue - relative_key = key.replace(f"{ENV_PREFIX}_", "").replace("__", ".").lower() - # Recursively convert path from a.b.c into outer[a][b][c] - current_obj = outer - dot_parts = relative_key.split(".") - for dot_part in dot_parts[:-1]: - if dot_part not in current_obj: - current_obj[dot_part] = {} - current_obj = current_obj[dot_part] - current_obj[dot_parts[-1]] = value - idx += 1 - if idx > 0: - self._log("debug", "Loaded environment variables", count=idx) - self.update(self.__config, outer) - - @contextmanager - def patch(self, path: str, value: Any): - """Context manager for unittests to patch a value""" - original_value = self.y(path) - self.y_set(path, value) - yield - self.y_set(path, original_value) - - @property - def raw(self) -> dict: - """Get raw config dictionary""" - return self.__config - - # pylint: disable=invalid-name - def y(self, path: str, default=None, sep=".") -> Any: - """Access attribute by using yaml path""" - # Walk sub_dicts before parsing path - root = self.raw - # Walk each component of the path - for comp in path.split(sep): - if root and comp in root: - root = root.get(comp) - else: - return default - return root - - def y_set(self, path: str, value: Any, sep="."): - """Set value using same syntax as y()""" - # Walk sub_dicts before parsing path - root = self.raw - # Walk each component of the path - path_parts = path.split(sep) - for comp in path_parts[:-1]: - if comp not in root: - root[comp] = {} - root = root.get(comp) - root[path_parts[-1]] = value - - def y_bool(self, path: str, default=False) -> bool: - """Wrapper for y that converts value into boolean""" - return str(self.y(path, default)).lower() == "true" - - -CONFIG = ConfigLoader() diff --git a/passbook/lib/default.yml b/passbook/lib/default.yml deleted file mode 100644 index 4973ae28..00000000 --- a/passbook/lib/default.yml +++ /dev/null @@ -1,38 +0,0 @@ -# This is the default configuration file -postgresql: - host: localhost - name: passbook - user: passbook - password: 'env://POSTGRES_PASSWORD' - -redis: - host: localhost - password: '' - cache_db: 0 - message_queue_db: 1 - ws_db: 2 - -debug: false -log_level: info - -# Error reporting, sends stacktrace to sentry.beryju.org -error_reporting: - enabled: false - environment: customer - send_pii: false - -outposts: - docker_image_base: "beryju/passbook" # this is prepended to -proxy:version - -passbook: - avatars: gravatar # gravatar or none - branding: - title: passbook - title_show: true - logo: /static/dist/assets/images/logo.svg - # Optionally add links to the footer on the login page - footer_links: - - name: Documentation - href: https://passbook.beryju.org/docs/ - - name: passbook Website - href: https://passbook.beryju.org/ diff --git a/passbook/lib/expression/evaluator.py b/passbook/lib/expression/evaluator.py deleted file mode 100644 index c564e55d..00000000 --- a/passbook/lib/expression/evaluator.py +++ /dev/null @@ -1,112 +0,0 @@ -"""passbook expression policy evaluator""" -import re -from textwrap import indent -from typing import Any, Dict, Iterable, Optional - -from django.core.exceptions import ValidationError -from requests import Session -from sentry_sdk.hub import Hub -from sentry_sdk.tracing import Span -from structlog import get_logger - -from passbook.core.models import User - -LOGGER = get_logger() - - -class BaseEvaluator: - """Validate and evaluate python-based expressions""" - - # Globals that can be used by function - _globals: Dict[str, Any] - # Context passed as locals to exec() - _context: Dict[str, Any] - - # Filename used for exec - _filename: str - - def __init__(self): - # update passbook/policies/expression/templates/policy/expression/form.html - # update website/docs/policies/expression.md - self._globals = { - "regex_match": BaseEvaluator.expr_filter_regex_match, - "regex_replace": BaseEvaluator.expr_filter_regex_replace, - "pb_is_group_member": BaseEvaluator.expr_func_is_group_member, - "pb_user_by": BaseEvaluator.expr_func_user_by, - "pb_logger": get_logger(), - "requests": Session(), - } - self._context = {} - self._filename = "BaseEvalautor" - - @staticmethod - def expr_filter_regex_match(value: Any, regex: str) -> bool: - """Expression Filter to run re.search""" - return re.search(regex, value) is None - - @staticmethod - def expr_filter_regex_replace(value: Any, regex: str, repl: str) -> str: - """Expression Filter to run re.sub""" - return re.sub(regex, repl, value) - - @staticmethod - def expr_func_user_by(**filters) -> Optional[User]: - """Get user by filters""" - users = User.objects.filter(**filters) - if users: - return users.first() - return None - - @staticmethod - def expr_func_is_group_member(user: User, **group_filters) -> bool: - """Check if `user` is member of group with name `group_name`""" - return user.groups.filter(**group_filters).exists() - - def wrap_expression(self, expression: str, params: Iterable[str]) -> str: - """Wrap expression in a function, call it, and save the result as `result`""" - handler_signature = ",".join(params) - full_expression = "" - full_expression += "from ipaddress import ip_address, ip_network\n" - full_expression += f"def handler({handler_signature}):\n" - full_expression += indent(expression, " ") - full_expression += f"\nresult = handler({handler_signature})" - return full_expression - - def evaluate(self, expression_source: str) -> Any: - """Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised. - If any exception is raised during execution, it is raised. - The result is returned without any type-checking.""" - with Hub.current.start_span(op="lib.evaluator.evaluate") as span: - span: Span - span.set_data("expression", expression_source) - param_keys = self._context.keys() - ast_obj = compile( - self.wrap_expression(expression_source, param_keys), - self._filename, - "exec", - ) - try: - _locals = self._context - # Yes this is an exec, yes it is potentially bad. Since we limit what variables are - # available here, and these policies can only be edited by admins, this is a risk - # we're willing to take. - # pylint: disable=exec-used - exec(ast_obj, self._globals, _locals) # nosec # noqa - result = _locals["result"] - except Exception as exc: - LOGGER.warning("Expression error", exc=exc) - raise - return result - - def validate(self, expression: str) -> bool: - """Validate expression's syntax, raise ValidationError if Syntax is invalid""" - param_keys = self._context.keys() - try: - compile( - self.wrap_expression(expression, param_keys), - self._filename, - "exec", - ) - return True - except (ValueError, SyntaxError) as exc: - raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc diff --git a/passbook/lib/logging.py b/passbook/lib/logging.py deleted file mode 100644 index 9024c658..00000000 --- a/passbook/lib/logging.py +++ /dev/null @@ -1,23 +0,0 @@ -"""logging helpers""" -from logging import Logger -from os import getpid -from typing import Callable - - -# pylint: disable=unused-argument -def add_process_id(logger: Logger, method_name: str, event_dict): - """Add the current process ID""" - event_dict["pid"] = getpid() - return event_dict - - -def add_common_fields(environment: str) -> Callable: - """Add a common field to easily search for passbook logs""" - - def add_common_field(logger: Logger, method_name: str, event_dict): - """Add a common field to easily search for passbook logs""" - event_dict["app"] = "passbook" - event_dict["app_environment"] = environment - return event_dict - - return add_common_field diff --git a/passbook/lib/sentry.py b/passbook/lib/sentry.py deleted file mode 100644 index c919d6a1..00000000 --- a/passbook/lib/sentry.py +++ /dev/null @@ -1,64 +0,0 @@ -"""passbook sentry integration""" -from aioredis.errors import ConnectionClosedError, ReplyError -from billiard.exceptions import WorkerLostError -from botocore.client import ClientError -from celery.exceptions import CeleryError -from channels_redis.core import ChannelFull -from django.core.exceptions import DisallowedHost, ValidationError -from django.db import InternalError, OperationalError, ProgrammingError -from django_redis.exceptions import ConnectionInterrupted -from ldap3.core.exceptions import LDAPException -from redis.exceptions import ConnectionError as RedisConnectionError -from redis.exceptions import RedisError, ResponseError -from rest_framework.exceptions import APIException -from structlog import get_logger -from websockets.exceptions import WebSocketException - -LOGGER = get_logger() - - -class SentryIgnoredException(Exception): - """Base Class for all errors that are suppressed, and not sent to sentry.""" - - -def before_send(event, hint): - """Check if error is database error, and ignore if so""" - ignored_classes = ( - # Inbuilt types - KeyboardInterrupt, - ConnectionResetError, - OSError, - # Django DB Errors - OperationalError, - InternalError, - ProgrammingError, - DisallowedHost, - ValidationError, - # Redis errors - RedisConnectionError, - ConnectionInterrupted, - RedisError, - ResponseError, - ReplyError, - ConnectionClosedError, - # websocket errors - ChannelFull, - WebSocketException, - # rest_framework error - APIException, - # celery errors - WorkerLostError, - CeleryError, - # S3 errors - ClientError, - # custom baseclass - SentryIgnoredException, - # ldap errors - LDAPException, - ) - if "exc_info" in hint: - _, exc_value, _ = hint["exc_info"] - if isinstance(exc_value, ignored_classes): - LOGGER.info("Supressing error %r", exc_value) - return None - return event diff --git a/passbook/lib/templates/lib/arrayfield.html b/passbook/lib/templates/lib/arrayfield.html deleted file mode 100644 index 3966470d..00000000 --- a/passbook/lib/templates/lib/arrayfield.html +++ /dev/null @@ -1,17 +0,0 @@ -{% load passbook_utils %} - -{% spaceless %} -
- {% for widget in widget.subwidgets %} -
- {% include widget.template_name %} -
- -
-
- {% endfor %} -
-
-{% endspaceless %} diff --git a/passbook/lib/templatetags/passbook_is_active.py b/passbook/lib/templatetags/passbook_is_active.py deleted file mode 100644 index f595d435..00000000 --- a/passbook/lib/templatetags/passbook_is_active.py +++ /dev/null @@ -1,55 +0,0 @@ -"""passbook lib navbar Templatetag""" -from django import template -from django.http import HttpRequest -from structlog import get_logger - -register = template.Library() - -LOGGER = get_logger() -ACTIVE_STRING = "pf-m-current" - - -@register.simple_tag(takes_context=True) -def is_active(context, *args: str, **_) -> str: - """Return whether a navbar link is active or not.""" - request: HttpRequest = context.get("request") - if not request.resolver_match: - return "" - match = request.resolver_match - for url in args: - if ":" in url: - app_name, url = url.split(":") - if match.app_name == app_name and match.url_name == url: - return ACTIVE_STRING - else: - if match.url_name == url: - return ACTIVE_STRING - return "" - - -@register.simple_tag(takes_context=True) -def is_active_url(context, view: str) -> str: - """Return whether a navbar link is active or not.""" - request: HttpRequest = context.get("request") - if not request.resolver_match: - return "" - - match = request.resolver_match - current_full_url = f"{match.app_name}:{match.url_name}" - - if current_full_url == view: - return ACTIVE_STRING - return "" - - -@register.simple_tag(takes_context=True) -def is_active_app(context, *args: str) -> str: - """Return True if current link is from app""" - - request: HttpRequest = context.get("request") - if not request.resolver_match: - return "" - for app_name in args: - if request.resolver_match.app_name == app_name: - return ACTIVE_STRING - return "" diff --git a/passbook/lib/templatetags/passbook_utils.py b/passbook/lib/templatetags/passbook_utils.py deleted file mode 100644 index 910b4a1e..00000000 --- a/passbook/lib/templatetags/passbook_utils.py +++ /dev/null @@ -1,113 +0,0 @@ -"""passbook lib Templatetags""" -from hashlib import md5 -from urllib.parse import urlencode - -from django import template -from django.db.models import Model -from django.http.request import HttpRequest -from django.template import Context -from django.templatetags.static import static -from django.utils.html import escape, mark_safe -from structlog import get_logger - -from passbook.core.models import User -from passbook.lib.config import CONFIG -from passbook.lib.utils.urls import is_url_absolute - -register = template.Library() -LOGGER = get_logger() - -GRAVATAR_URL = "https://secure.gravatar.com" - - -@register.simple_tag(takes_context=True) -def back(context: Context) -> str: - """Return a link back (either from GET parameter or referer.""" - if "request" not in context: - return "" - request = context.get("request") - url = "" - if "HTTP_REFERER" in request.META: - url = request.META.get("HTTP_REFERER") - if "back" in request.GET: - url = request.GET.get("back") - - if not is_url_absolute(url): - return url - return "" - - -@register.filter("fieldtype") -def fieldtype(field): - """Return classname""" - if isinstance(field.__class__, Model) or issubclass(field.__class__, Model): - return verbose_name(field) - return field.__class__.__name__ - - -@register.simple_tag -def config(path, default=""): - """Get a setting from the database. Returns default is setting doesn't exist.""" - return CONFIG.y(path, default) - - -@register.filter(name="css_class") -def css_class(field, css): - """Add css class to form field""" - return field.as_widget(attrs={"class": css}) - - -@register.simple_tag -def avatar(user: User) -> str: - """Get avatar, depending on passbook.avatar setting""" - mode = CONFIG.raw.get("passbook").get("avatars") - if mode == "none": - return static("passbook/user-default.png") - if mode == "gravatar": - parameters = [ - ("s", "158"), - ("r", "g"), - ] - # gravatar uses md5 for their URLs, so md5 can't be avoided - mail_hash = md5(user.email.encode("utf-8")).hexdigest() # nosec - gravatar_url = ( - f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" - ) - return escape(gravatar_url) - raise ValueError(f"Invalid avatar mode {mode}") - - -@register.filter -def verbose_name(obj) -> str: - """Return Object's Verbose Name""" - if not obj: - return "" - if hasattr(obj, "verbose_name"): - return obj.verbose_name - return obj._meta.verbose_name - - -@register.filter -def form_verbose_name(obj) -> str: - """Return ModelForm's Object's Verbose Name""" - if not obj: - return "" - return verbose_name(obj._meta.model) - - -@register.filter -def doc(obj) -> str: - """Return docstring of object""" - return mark_safe(obj.__doc__.replace("\n", "
")) - - -@register.simple_tag(takes_context=True) -def query_transform(context: Context, **kwargs) -> str: - """Append objects to the current querystring""" - if "request" not in context: - return "" - request: HttpRequest = context["request"] - updated = request.GET.copy() - for key, value in kwargs.items(): - updated[key] = value - return updated.urlencode() diff --git a/passbook/lib/tests.py b/passbook/lib/tests.py deleted file mode 100644 index e8767de2..00000000 --- a/passbook/lib/tests.py +++ /dev/null @@ -1,30 +0,0 @@ -"""base model tests""" -from typing import Callable, Type - -from django.test import TestCase -from rest_framework.serializers import BaseSerializer - -from passbook.flows.models import Stage -from passbook.lib.models import SerializerModel -from passbook.lib.utils.reflection import all_subclasses - - -class TestModels(TestCase): - """Generic model properties tests""" - - -def model_tester_factory(test_model: Type[Stage]) -> Callable: - """Test a form""" - - def tester(self: TestModels): - model_inst = test_model() - try: - self.assertTrue(issubclass(model_inst.serializer, BaseSerializer)) - except NotImplementedError: - pass - - return tester - - -for model in all_subclasses(SerializerModel): - setattr(TestModels, f"test_model_{model.__name__}", model_tester_factory(model)) diff --git a/passbook/lib/utils/reflection.py b/passbook/lib/utils/reflection.py deleted file mode 100644 index 0a218f6f..00000000 --- a/passbook/lib/utils/reflection.py +++ /dev/null @@ -1,43 +0,0 @@ -"""passbook lib reflection utilities""" -from importlib import import_module - -from django.conf import settings - - -def all_subclasses(cls, sort=True): - """Recursively return all subclassess of cls""" - classes = set(cls.__subclasses__()).union( - [s for c in cls.__subclasses__() for s in all_subclasses(c, sort=sort)] - ) - # Check if we're in debug mode, if not exclude classes which have `__debug_only__` - if not settings.DEBUG: - # Filter class out when __debug_only__ is not False - classes = [x for x in classes if not getattr(x, "__debug_only__", False)] - # classes = filter(lambda x: not getattr(x, "__debug_only__", False), classes) - if sort: - return sorted(classes, key=lambda x: x.__name__) - return classes - - -def class_to_path(cls): - """Turn Class (Class or instance) into module path""" - return f"{cls.__module__}.{cls.__name__}" - - -def path_to_class(path): - """Import module and return class""" - if not path: - return None - parts = path.split(".") - package = ".".join(parts[:-1]) - _class = getattr(import_module(package), parts[-1]) - return _class - - -def get_apps(): - """Get list of all passbook apps""" - from django.apps.registry import apps - - for _app in apps.get_app_configs(): - if _app.name.startswith("passbook"): - yield _app diff --git a/passbook/lib/utils/template.py b/passbook/lib/utils/template.py deleted file mode 100644 index 664c2c3d..00000000 --- a/passbook/lib/utils/template.py +++ /dev/null @@ -1,8 +0,0 @@ -"""passbook lib template utilities""" -from django.template import Context, loader - - -def render_to_string(template_path: str, ctx: Context) -> str: - """Render a template to string""" - template = loader.get_template(template_path) - return template.render(ctx) diff --git a/passbook/lib/utils/ui.py b/passbook/lib/utils/ui.py deleted file mode 100644 index f898e086..00000000 --- a/passbook/lib/utils/ui.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook UI utils""" -from typing import Any, List - - -def human_list(_list: List[Any]) -> str: - """Convert a list of items into 'a, b or c'""" - last_item = _list.pop() - if len(_list) < 1: - return last_item - result = ", ".join(_list) - return "%s or %s" % (result, last_item) diff --git a/passbook/lib/views.py b/passbook/lib/views.py deleted file mode 100644 index 2d97dbc0..00000000 --- a/passbook/lib/views.py +++ /dev/null @@ -1,41 +0,0 @@ -"""passbook helper views""" -from django.http import HttpRequest -from django.template.response import TemplateResponse -from django.utils.translation import gettext_lazy as _ -from django.views.generic import CreateView -from guardian.shortcuts import assign_perm - - -class CreateAssignPermView(CreateView): - """Assign permissions to object after creation""" - - permissions = [ - "%s.view_%s", - "%s.change_%s", - "%s.delete_%s", - ] - - def form_valid(self, form): - response = super().form_valid(form) - for permission in self.permissions: - full_permission = permission % ( - self.object._meta.app_label, - self.object._meta.model_name, - ) - assign_perm(full_permission, self.request.user, self.object) - return response - - -def bad_request_message( - request: HttpRequest, - message: str, - title="Bad Request", - template="error/generic.html", -) -> TemplateResponse: - """Return generic error page with message, with status code set to 400""" - return TemplateResponse( - request, - template, - {"message": message, "title": _(title)}, - status=400, - ) diff --git a/passbook/outposts/api.py b/passbook/outposts/api.py deleted file mode 100644 index 18d6a0ba..00000000 --- a/passbook/outposts/api.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Outpost API Views""" -from rest_framework.serializers import JSONField, ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.outposts.models import ( - DockerServiceConnection, - KubernetesServiceConnection, - Outpost, -) - - -class OutpostSerializer(ModelSerializer): - """Outpost Serializer""" - - _config = JSONField() - - class Meta: - - model = Outpost - fields = ["pk", "name", "providers", "service_connection", "_config"] - - -class OutpostViewSet(ModelViewSet): - """Outpost Viewset""" - - queryset = Outpost.objects.all() - serializer_class = OutpostSerializer - - -class DockerServiceConnectionSerializer(ModelSerializer): - """DockerServiceConnection Serializer""" - - class Meta: - - model = DockerServiceConnection - fields = [ - "pk", - "name", - "local", - "url", - "tls_verification", - "tls_authentication", - ] - - -class DockerServiceConnectionViewSet(ModelViewSet): - """DockerServiceConnection Viewset""" - - queryset = DockerServiceConnection.objects.all() - serializer_class = DockerServiceConnectionSerializer - - -class KubernetesServiceConnectionSerializer(ModelSerializer): - """KubernetesServiceConnection Serializer""" - - class Meta: - - model = KubernetesServiceConnection - fields = ["pk", "name", "local", "kubeconfig"] - - -class KubernetesServiceConnectionViewSet(ModelViewSet): - """KubernetesServiceConnection Viewset""" - - queryset = KubernetesServiceConnection.objects.all() - serializer_class = KubernetesServiceConnectionSerializer diff --git a/passbook/outposts/apps.py b/passbook/outposts/apps.py deleted file mode 100644 index 27f26430..00000000 --- a/passbook/outposts/apps.py +++ /dev/null @@ -1,74 +0,0 @@ -"""passbook outposts app config""" -from importlib import import_module -from os import R_OK, access -from os.path import expanduser -from pathlib import Path -from socket import gethostname -from urllib.parse import urlparse - -import yaml -from django.apps import AppConfig -from django.db import ProgrammingError -from docker.constants import DEFAULT_UNIX_SOCKET -from kubernetes.config.incluster_config import SERVICE_TOKEN_FILENAME -from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION -from structlog import get_logger - -LOGGER = get_logger() - - -class PassbookOutpostConfig(AppConfig): - """passbook outposts app config""" - - name = "passbook.outposts" - label = "passbook_outposts" - mountpoint = "outposts/" - verbose_name = "passbook Outpost" - - def ready(self): - import_module("passbook.outposts.signals") - try: - PassbookOutpostConfig.init_local_connection() - except ProgrammingError: - pass - - @staticmethod - def init_local_connection(): - """Check if local kubernetes or docker connections should be created""" - from passbook.outposts.models import ( - KubernetesServiceConnection, - DockerServiceConnection, - ) - - if Path(SERVICE_TOKEN_FILENAME).exists(): - LOGGER.debug("Detected in-cluster Kubernetes Config") - if not KubernetesServiceConnection.objects.filter(local=True).exists(): - LOGGER.debug("Created Service Connection for in-cluster") - KubernetesServiceConnection.objects.create( - name="Local Kubernetes Cluster", local=True, kubeconfig={} - ) - # For development, check for the existence of a kubeconfig file - kubeconfig_path = expanduser(KUBE_CONFIG_DEFAULT_LOCATION) - if Path(kubeconfig_path).exists(): - LOGGER.debug("Detected kubeconfig") - kubeconfig_local_name = f"k8s-{gethostname()}" - if not KubernetesServiceConnection.objects.filter( - name=kubeconfig_local_name - ).exists(): - LOGGER.debug("Creating kubeconfig Service Connection") - with open(kubeconfig_path, "r") as _kubeconfig: - KubernetesServiceConnection.objects.create( - name=kubeconfig_local_name, - kubeconfig=yaml.safe_load(_kubeconfig), - ) - unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path - socket = Path(unix_socket_path) - if socket.exists() and access(socket, R_OK): - LOGGER.debug("Detected local docker socket") - if not DockerServiceConnection.objects.filter(local=True).exists(): - LOGGER.debug("Created Service Connection for docker") - DockerServiceConnection.objects.create( - name="Local Docker connection", - local=True, - url=unix_socket_path, - ) diff --git a/passbook/outposts/channels.py b/passbook/outposts/channels.py deleted file mode 100644 index 7a6978ec..00000000 --- a/passbook/outposts/channels.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Outpost websocket handler""" -from dataclasses import asdict, dataclass, field -from datetime import datetime -from enum import IntEnum -from typing import Any, Dict - -from dacite import from_dict -from dacite.data import Data -from guardian.shortcuts import get_objects_for_user -from structlog import get_logger - -from passbook.core.channels import AuthJsonConsumer -from passbook.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState - -LOGGER = get_logger() - - -class WebsocketMessageInstruction(IntEnum): - """Commands which can be triggered over Websocket""" - - # Simple message used by either side when a message is acknowledged - ACK = 0 - - # Message used by outposts to report their alive status - HELLO = 1 - - # Message sent by us to trigger an Update - TRIGGER_UPDATE = 2 - - -@dataclass -class WebsocketMessage: - """Complete Websocket Message that is being sent""" - - instruction: int - args: Dict[str, Any] = field(default_factory=dict) - - -class OutpostConsumer(AuthJsonConsumer): - """Handler for Outposts that connect over websockets for health checks and live updates""" - - outpost: Outpost - - def connect(self): - if not super().connect(): - return - uuid = self.scope["url_route"]["kwargs"]["pk"] - outpost = get_objects_for_user( - self.user, "passbook_outposts.view_outpost" - ).filter(pk=uuid) - if not outpost.exists(): - self.close() - return - self.accept() - self.outpost = outpost.first() - OutpostState( - uid=self.channel_name, last_seen=datetime.now(), _outpost=self.outpost - ).save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) - LOGGER.debug("added channel to cache", channel_name=self.channel_name) - - # pylint: disable=unused-argument - def disconnect(self, close_code): - OutpostState.for_channel(self.outpost, self.channel_name).delete() - LOGGER.debug("removed channel from cache", channel_name=self.channel_name) - - def receive_json(self, content: Data): - msg = from_dict(WebsocketMessage, content) - state = OutpostState( - uid=self.channel_name, - last_seen=datetime.now(), - _outpost=self.outpost, - ) - if msg.instruction == WebsocketMessageInstruction.HELLO: - state.version = msg.args.get("version", None) - elif msg.instruction == WebsocketMessageInstruction.ACK: - return - state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5) - - response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK) - self.send_json(asdict(response)) - - # pylint: disable=unused-argument - def event_update(self, event): - """Event handler which is called by post_save signals, Send update instruction""" - self.send_json( - asdict( - WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE) - ) - ) diff --git a/passbook/outposts/controllers/base.py b/passbook/outposts/controllers/base.py deleted file mode 100644 index 97ba0d92..00000000 --- a/passbook/outposts/controllers/base.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Base Controller""" -from typing import Dict, List - -from structlog import get_logger -from structlog.testing import capture_logs - -from passbook.lib.sentry import SentryIgnoredException -from passbook.outposts.models import Outpost, OutpostServiceConnection - - -class ControllerException(SentryIgnoredException): - """Exception raised when anything fails during controller run""" - - -class BaseController: - """Base Outpost deployment controller""" - - deployment_ports: Dict[str, int] - - outpost: Outpost - connection: OutpostServiceConnection - - def __init__(self, outpost: Outpost, connection: OutpostServiceConnection): - self.outpost = outpost - self.connection = connection - self.logger = get_logger() - self.deployment_ports = {} - - # pylint: disable=invalid-name - def up(self): - """Called by scheduled task to reconcile deployment/service/etc""" - raise NotImplementedError - - def up_with_logs(self) -> List[str]: - """Call .up() but capture all log output and return it.""" - with capture_logs() as logs: - self.up() - return [x["event"] for x in logs] - - def down(self): - """Handler to delete everything we've created""" - raise NotImplementedError - - def get_static_deployment(self) -> str: - """Return a static deployment configuration""" - raise NotImplementedError diff --git a/passbook/outposts/controllers/docker.py b/passbook/outposts/controllers/docker.py deleted file mode 100644 index 3c3b2183..00000000 --- a/passbook/outposts/controllers/docker.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Docker controller""" -from time import sleep -from typing import Dict, Tuple - -from django.conf import settings -from docker import DockerClient -from docker.errors import DockerException, NotFound -from docker.models.containers import Container -from yaml import safe_dump - -from passbook import __version__ -from passbook.lib.config import CONFIG -from passbook.outposts.controllers.base import BaseController, ControllerException -from passbook.outposts.models import ( - DockerServiceConnection, - Outpost, - ServiceConnectionInvalid, -) - - -class DockerController(BaseController): - """Docker controller""" - - client: DockerClient - - container: Container - connection: DockerServiceConnection - - def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None: - super().__init__(outpost, connection) - try: - self.client = connection.client() - except ServiceConnectionInvalid as exc: - raise ControllerException from exc - - def _get_labels(self) -> Dict[str, str]: - return {} - - def _get_env(self) -> Dict[str, str]: - return { - "PASSBOOK_HOST": self.outpost.config.passbook_host, - "PASSBOOK_INSECURE": str(self.outpost.config.passbook_host_insecure), - "PASSBOOK_TOKEN": self.outpost.token.key, - } - - def _comp_env(self, container: Container) -> bool: - """Check if container's env is equal to what we would set. Return true if container needs - to be rebuilt.""" - should_be = self._get_env() - container_env = container.attrs.get("Config", {}).get("Env", {}) - for key, expected_value in should_be.items(): - if key not in container_env: - continue - if container_env[key] != expected_value: - return True - return False - - def _get_container(self) -> Tuple[Container, bool]: - container_name = f"passbook-proxy-{self.outpost.uuid.hex}" - try: - return self.client.containers.get(container_name), False - except NotFound: - self.logger.info("Container does not exist, creating") - image_prefix = CONFIG.y("outposts.docker_image_base") - image_name = f"{image_prefix}-{self.outpost.type}:{__version__}" - self.client.images.pull(image_name) - container_args = { - "image": image_name, - "name": f"passbook-proxy-{self.outpost.uuid.hex}", - "detach": True, - "ports": {x: x for _, x in self.deployment_ports.items()}, - "environment": self._get_env(), - "labels": self._get_labels(), - } - if settings.TEST: - del container_args["ports"] - container_args["network_mode"] = "host" - return ( - self.client.containers.create(**container_args), - True, - ) - - def up(self): - try: - container, has_been_created = self._get_container() - # Check if the container is out of date, delete it and retry - if len(container.image.tags) > 0: - tag: str = container.image.tags[0] - _, _, version = tag.partition(":") - if version != __version__: - self.logger.info( - "Container has mismatched version, re-creating...", - has=version, - should=__version__, - ) - container.kill() - container.remove(force=True) - return self.up() - # Check that container values match our values - if self._comp_env(container): - self.logger.info("Container has outdated config, re-creating...") - container.kill() - container.remove(force=True) - return self.up() - # Check that container is healthy - if ( - container.status == "running" - and container.attrs.get("State", {}).get("Health", {}).get("Status", "") - != "healthy" - ): - # At this point we know the config is correct, but the container isn't healthy, - # so we just restart it with the same config - if has_been_created: - # Since we've just created the container, give it some time to start. - # If its still not up by then, restart it - self.logger.info( - "Container is unhealthy and new, giving it time to boot." - ) - sleep(60) - self.logger.info("Container is unhealthy, restarting...") - container.restart() - return None - # Check that container is running - if container.status != "running": - self.logger.info("Container is not running, restarting...") - container.start() - return None - return None - except DockerException as exc: - raise ControllerException from exc - - def down(self): - try: - container, _ = self._get_container() - container.kill() - container.remove() - except DockerException as exc: - raise ControllerException from exc - - def get_static_deployment(self) -> str: - """Generate docker-compose yaml for proxy, version 3.5""" - ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()] - image_prefix = CONFIG.y("outposts.docker_image_base") - compose = { - "version": "3.5", - "services": { - f"passbook_{self.outpost.type}": { - "image": f"{image_prefix}-{self.outpost.type}:{__version__}", - "ports": ports, - "environment": { - "PASSBOOK_HOST": self.outpost.config.passbook_host, - "PASSBOOK_INSECURE": str( - self.outpost.config.passbook_host_insecure - ), - "PASSBOOK_TOKEN": self.outpost.token.key, - }, - } - }, - } - return safe_dump(compose, default_flow_style=False) diff --git a/passbook/outposts/controllers/k8s/base.py b/passbook/outposts/controllers/k8s/base.py deleted file mode 100644 index d4b6265b..00000000 --- a/passbook/outposts/controllers/k8s/base.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Base Kubernetes Reconciler""" -from typing import TYPE_CHECKING, Generic, TypeVar - -from kubernetes.client import V1ObjectMeta -from kubernetes.client.rest import ApiException -from structlog import get_logger - -from passbook import __version__ -from passbook.lib.sentry import SentryIgnoredException - -if TYPE_CHECKING: - from passbook.outposts.controllers.kubernetes import KubernetesController - -# pylint: disable=invalid-name -T = TypeVar("T") - - -class ReconcileTrigger(SentryIgnoredException): - """Base trigger raised by child classes to notify us""" - - -class NeedsRecreate(ReconcileTrigger): - """Exception to trigger a complete recreate of the Kubernetes Object""" - - -class NeedsUpdate(ReconcileTrigger): - """Exception to trigger an update to the Kubernetes Object""" - - -class KubernetesObjectReconciler(Generic[T]): - """Base Kubernetes Reconciler, handles the basic logic.""" - - controller: "KubernetesController" - - def __init__(self, controller: "KubernetesController"): - self.controller = controller - self.namespace = controller.outpost.config.kubernetes_namespace - self.logger = get_logger() - - @property - def name(self) -> str: - """Get the name of the object this reconciler manages""" - raise NotImplementedError - - def up(self): - """Create object if it doesn't exist, update if needed or recreate if needed.""" - current = None - reference = self.get_reference_object() - try: - try: - current = self.retrieve() - except ApiException as exc: - if exc.status == 404: - self.logger.debug("Failed to get current, triggering recreate") - raise NeedsRecreate from exc - self.logger.debug("Other unhandled error", exc=exc) - raise exc - else: - self.logger.debug("Got current, running reconcile") - self.reconcile(current, reference) - except NeedsRecreate: - self.logger.debug("Recreate requested") - if current: - self.logger.debug("Deleted old") - self.delete(current) - else: - self.logger.debug("No old found, creating") - self.logger.debug("Created") - self.create(reference) - except NeedsUpdate: - self.logger.debug("Updating") - self.update(current, reference) - else: - self.logger.debug("Nothing to do...") - - def down(self): - """Delete object if found""" - try: - current = self.retrieve() - self.delete(current) - self.logger.debug("Removing") - except ApiException as exc: - if exc.status == 404: - self.logger.debug("Failed to get current, assuming non-existant") - return - self.logger.debug("Other unhandled error", exc=exc) - raise exc - - def get_reference_object(self) -> T: - """Return object as it should be""" - raise NotImplementedError - - def reconcile(self, current: T, reference: T): - """Check what operations should be done, should be raised as - ReconcileTrigger""" - raise NotImplementedError - - def create(self, reference: T): - """API Wrapper to create object""" - raise NotImplementedError - - def retrieve(self) -> T: - """API Wrapper to retrive object""" - raise NotImplementedError - - def delete(self, reference: T): - """API Wrapper to delete object""" - raise NotImplementedError - - def update(self, current: T, reference: T): - """API Wrapper to update object""" - raise NotImplementedError - - def get_object_meta(self, **kwargs) -> V1ObjectMeta: - """Get common object metadata""" - return V1ObjectMeta( - namespace=self.namespace, - labels={ - "app.kubernetes.io/name": f"passbook-{self.controller.outpost.type.lower()}", - "app.kubernetes.io/instance": self.controller.outpost.name, - "app.kubernetes.io/version": __version__, - "app.kubernetes.io/managed-by": "passbook.beryju.org", - "passbook.beryju.org/outpost-uuid": self.controller.outpost.uuid.hex, - }, - **kwargs, - ) diff --git a/passbook/outposts/controllers/k8s/deployment.py b/passbook/outposts/controllers/k8s/deployment.py deleted file mode 100644 index a3c66a20..00000000 --- a/passbook/outposts/controllers/k8s/deployment.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Kubernetes Deployment Reconciler""" -from typing import TYPE_CHECKING, Dict - -from kubernetes.client import ( - AppsV1Api, - V1Container, - V1ContainerPort, - V1Deployment, - V1DeploymentSpec, - V1EnvVar, - V1EnvVarSource, - V1LabelSelector, - V1ObjectMeta, - V1PodSpec, - V1PodTemplateSpec, - V1SecretKeySelector, -) - -from passbook import __version__ -from passbook.lib.config import CONFIG -from passbook.outposts.controllers.k8s.base import ( - KubernetesObjectReconciler, - NeedsUpdate, -) -from passbook.outposts.models import Outpost - -if TYPE_CHECKING: - from passbook.outposts.controllers.kubernetes import KubernetesController - - -class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): - """Kubernetes Deployment Reconciler""" - - outpost: Outpost - - def __init__(self, controller: "KubernetesController") -> None: - super().__init__(controller) - self.api = AppsV1Api(controller.client) - self.outpost = self.controller.outpost - - @property - def name(self) -> str: - return f"passbook-outpost-{self.controller.outpost.uuid.hex}" - - def reconcile(self, current: V1Deployment, reference: V1Deployment): - if current.spec.replicas != reference.spec.replicas: - raise NeedsUpdate() - if ( - current.spec.template.spec.containers[0].image - != reference.spec.template.spec.containers[0].image - ): - raise NeedsUpdate() - - def get_pod_meta(self) -> Dict[str, str]: - """Get common object metadata""" - return { - "app.kubernetes.io/name": "passbook-outpost", - "app.kubernetes.io/managed-by": "passbook.beryju.org", - "passbook.beryju.org/outpost-uuid": self.controller.outpost.uuid.hex, - } - - def get_reference_object(self) -> V1Deployment: - """Get deployment object for outpost""" - # Generate V1ContainerPort objects - container_ports = [] - for port_name, port in self.controller.deployment_ports.items(): - container_ports.append(V1ContainerPort(container_port=port, name=port_name)) - meta = self.get_object_meta(name=self.name) - secret_name = f"passbook-outpost-{self.controller.outpost.uuid.hex}-api" - image_prefix = CONFIG.y("outposts.docker_image_base") - return V1Deployment( - metadata=meta, - spec=V1DeploymentSpec( - replicas=self.outpost.config.kubernetes_replicas, - selector=V1LabelSelector(match_labels=self.get_pod_meta()), - template=V1PodTemplateSpec( - metadata=V1ObjectMeta(labels=self.get_pod_meta()), - spec=V1PodSpec( - containers=[ - V1Container( - name=str(self.outpost.type), - image=f"{image_prefix}-{self.outpost.type}:{__version__}", - ports=container_ports, - env=[ - V1EnvVar( - name="PASSBOOK_HOST", - value_from=V1EnvVarSource( - secret_key_ref=V1SecretKeySelector( - name=secret_name, - key="passbook_host", - ) - ), - ), - V1EnvVar( - name="PASSBOOK_TOKEN", - value_from=V1EnvVarSource( - secret_key_ref=V1SecretKeySelector( - name=secret_name, - key="token", - ) - ), - ), - V1EnvVar( - name="PASSBOOK_INSECURE", - value_from=V1EnvVarSource( - secret_key_ref=V1SecretKeySelector( - name=secret_name, - key="passbook_host_insecure", - ) - ), - ), - ], - ) - ] - ), - ), - ), - ) - - def create(self, reference: V1Deployment): - return self.api.create_namespaced_deployment(self.namespace, reference) - - def delete(self, reference: V1Deployment): - return self.api.delete_namespaced_deployment( - reference.metadata.name, self.namespace - ) - - def retrieve(self) -> V1Deployment: - return self.api.read_namespaced_deployment(self.name, self.namespace) - - def update(self, current: V1Deployment, reference: V1Deployment): - return self.api.patch_namespaced_deployment( - current.metadata.name, self.namespace, reference - ) diff --git a/passbook/outposts/controllers/k8s/secret.py b/passbook/outposts/controllers/k8s/secret.py deleted file mode 100644 index 42a9cf7d..00000000 --- a/passbook/outposts/controllers/k8s/secret.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Kubernetes Secret Reconciler""" -from base64 import b64encode -from typing import TYPE_CHECKING - -from kubernetes.client import CoreV1Api, V1Secret - -from passbook.outposts.controllers.k8s.base import ( - KubernetesObjectReconciler, - NeedsUpdate, -) - -if TYPE_CHECKING: - from passbook.outposts.controllers.kubernetes import KubernetesController - - -def b64string(source: str) -> str: - """Base64 Encode string""" - return b64encode(source.encode()).decode("utf-8") - - -class SecretReconciler(KubernetesObjectReconciler[V1Secret]): - """Kubernetes Secret Reconciler""" - - def __init__(self, controller: "KubernetesController") -> None: - super().__init__(controller) - self.api = CoreV1Api(controller.client) - - @property - def name(self) -> str: - return f"passbook-outpost-{self.controller.outpost.uuid.hex}-api" - - def reconcile(self, current: V1Secret, reference: V1Secret): - for key in reference.data.keys(): - if current.data[key] != reference.data[key]: - raise NeedsUpdate() - - def get_reference_object(self) -> V1Secret: - """Get deployment object for outpost""" - meta = self.get_object_meta(name=self.name) - return V1Secret( - metadata=meta, - data={ - "passbook_host": b64string( - self.controller.outpost.config.passbook_host - ), - "passbook_host_insecure": b64string( - str(self.controller.outpost.config.passbook_host_insecure) - ), - "token": b64string(self.controller.outpost.token.token_uuid.hex), - }, - ) - - def create(self, reference: V1Secret): - return self.api.create_namespaced_secret(self.namespace, reference) - - def delete(self, reference: V1Secret): - return self.api.delete_namespaced_secret( - reference.metadata.name, self.namespace - ) - - def retrieve(self) -> V1Secret: - return self.api.read_namespaced_secret(self.name, self.namespace) - - def update(self, current: V1Secret, reference: V1Secret): - return self.api.patch_namespaced_secret( - current.metadata.name, self.namespace, reference - ) diff --git a/passbook/outposts/controllers/k8s/service.py b/passbook/outposts/controllers/k8s/service.py deleted file mode 100644 index a00580ea..00000000 --- a/passbook/outposts/controllers/k8s/service.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Kubernetes Service Reconciler""" -from typing import TYPE_CHECKING - -from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec - -from passbook.outposts.controllers.k8s.base import ( - KubernetesObjectReconciler, - NeedsUpdate, -) -from passbook.outposts.controllers.k8s.deployment import DeploymentReconciler - -if TYPE_CHECKING: - from passbook.outposts.controllers.kubernetes import KubernetesController - - -class ServiceReconciler(KubernetesObjectReconciler[V1Service]): - """Kubernetes Service Reconciler""" - - def __init__(self, controller: "KubernetesController") -> None: - super().__init__(controller) - self.api = CoreV1Api(controller.client) - - @property - def name(self) -> str: - return f"passbook-outpost-{self.controller.outpost.uuid.hex}" - - def reconcile(self, current: V1Service, reference: V1Service): - if len(current.spec.ports) != len(reference.spec.ports): - raise NeedsUpdate() - for port in reference.spec.ports: - if port not in current.spec.ports: - raise NeedsUpdate() - - def get_reference_object(self) -> V1Service: - """Get deployment object for outpost""" - meta = self.get_object_meta(name=self.name) - ports = [] - for port_name, port in self.controller.deployment_ports.items(): - ports.append(V1ServicePort(name=port_name, port=port)) - selector_labels = DeploymentReconciler(self.controller).get_pod_meta() - return V1Service( - metadata=meta, - spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"), - ) - - def create(self, reference: V1Service): - return self.api.create_namespaced_service(self.namespace, reference) - - def delete(self, reference: V1Service): - return self.api.delete_namespaced_service( - reference.metadata.name, self.namespace - ) - - def retrieve(self) -> V1Service: - return self.api.read_namespaced_service(self.name, self.namespace) - - def update(self, current: V1Service, reference: V1Service): - return self.api.patch_namespaced_service( - current.metadata.name, self.namespace, reference - ) diff --git a/passbook/outposts/controllers/kubernetes.py b/passbook/outposts/controllers/kubernetes.py deleted file mode 100644 index 6f750e69..00000000 --- a/passbook/outposts/controllers/kubernetes.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Kubernetes deployment controller""" -from io import StringIO -from typing import Dict, List, Type - -from kubernetes.client import OpenApiException -from kubernetes.client.api_client import ApiClient -from structlog.testing import capture_logs -from yaml import dump_all - -from passbook.outposts.controllers.base import BaseController, ControllerException -from passbook.outposts.controllers.k8s.base import KubernetesObjectReconciler -from passbook.outposts.controllers.k8s.deployment import DeploymentReconciler -from passbook.outposts.controllers.k8s.secret import SecretReconciler -from passbook.outposts.controllers.k8s.service import ServiceReconciler -from passbook.outposts.models import KubernetesServiceConnection, Outpost - - -class KubernetesController(BaseController): - """Manage deployment of outpost in kubernetes""" - - reconcilers: Dict[str, Type[KubernetesObjectReconciler]] - reconcile_order: List[str] - - client: ApiClient - connection: KubernetesServiceConnection - - def __init__( - self, outpost: Outpost, connection: KubernetesServiceConnection - ) -> None: - super().__init__(outpost, connection) - self.client = connection.client() - self.reconcilers = { - "secret": SecretReconciler, - "deployment": DeploymentReconciler, - "service": ServiceReconciler, - } - self.reconcile_order = ["secret", "deployment", "service"] - - def up(self): - try: - for reconcile_key in self.reconcile_order: - reconciler = self.reconcilers[reconcile_key](self) - reconciler.up() - - except OpenApiException as exc: - raise ControllerException from exc - - def up_with_logs(self) -> List[str]: - try: - all_logs = [] - for reconcile_key in self.reconcile_order: - with capture_logs() as logs: - reconciler = self.reconcilers[reconcile_key](self) - reconciler.up() - all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs] - return all_logs - except OpenApiException as exc: - raise ControllerException from exc - - def down(self): - try: - for reconcile_key in self.reconcile_order: - reconciler = self.reconcilers[reconcile_key](self) - reconciler.down() - - except OpenApiException as exc: - raise ControllerException from exc - - def get_static_deployment(self) -> str: - documents = [] - for reconcile_key in self.reconcile_order: - reconciler = self.reconcilers[reconcile_key](self) - documents.append(reconciler.get_reference_object().to_dict()) - - with StringIO() as _str: - dump_all( - documents, - stream=_str, - default_flow_style=False, - ) - return _str.getvalue() diff --git a/passbook/outposts/docker_tls.py b/passbook/outposts/docker_tls.py deleted file mode 100644 index 3090c797..00000000 --- a/passbook/outposts/docker_tls.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Create Docker TLSConfig from CertificateKeyPair""" -from pathlib import Path -from tempfile import gettempdir -from typing import Optional - -from docker.tls import TLSConfig - -from passbook.crypto.models import CertificateKeyPair - - -class DockerInlineTLS: - """Create Docker TLSConfig from CertificateKeyPair""" - - verification_kp: Optional[CertificateKeyPair] - authentication_kp: Optional[CertificateKeyPair] - - def __init__( - self, - verification_kp: Optional[CertificateKeyPair], - authentication_kp: Optional[CertificateKeyPair], - ) -> None: - self.verification_kp = verification_kp - self.authentication_kp = authentication_kp - - def write_file(self, name: str, contents: str) -> str: - """Wrapper for mkstemp that uses fdopen""" - path = Path(gettempdir(), name) - with open(path, "w") as _file: - _file.write(contents) - return str(path) - - def write(self) -> TLSConfig: - """Create TLSConfig with Certificate Keypairs""" - # So yes, this is quite ugly. But sadly, there is no clean way to pass - # docker-py (which is using requests (which is using urllib3)) a certificate - # for verification or authentication as string. - # Because we run in docker, and our tmpfs is isolated to us, we can just - # write out the certificates and keys to files and use their paths - config_args = {} - if self.verification_kp: - ca_cert_path = self.write_file( - f"{self.verification_kp.pk.hex}-cert.pem", - self.verification_kp.certificate_data, - ) - config_args["ca_cert"] = ca_cert_path - if self.authentication_kp: - auth_cert_path = self.write_file( - f"{self.authentication_kp.pk.hex}-cert.pem", - self.authentication_kp.certificate_data, - ) - auth_key_path = self.write_file( - f"{self.authentication_kp.pk.hex}-key.pem", - self.authentication_kp.key_data, - ) - config_args["client_cert"] = (auth_cert_path, auth_key_path) - return TLSConfig(**config_args) diff --git a/passbook/outposts/forms.py b/passbook/outposts/forms.py deleted file mode 100644 index 7b925935..00000000 --- a/passbook/outposts/forms.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Outpost forms""" - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.admin.fields import CodeMirrorWidget, YAMLField -from passbook.crypto.models import CertificateKeyPair -from passbook.outposts.models import ( - DockerServiceConnection, - KubernetesServiceConnection, - Outpost, - OutpostServiceConnection, -) -from passbook.providers.proxy.models import ProxyProvider - - -class OutpostForm(forms.ModelForm): - """Outpost Form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["providers"].queryset = ProxyProvider.objects.all() - self.fields[ - "service_connection" - ].queryset = OutpostServiceConnection.objects.select_subclasses() - - class Meta: - - model = Outpost - fields = [ - "name", - "type", - "service_connection", - "providers", - "_config", - ] - widgets = { - "name": forms.TextInput(), - "_config": CodeMirrorWidget, - } - field_classes = { - "_config": YAMLField, - } - labels = {"_config": _("Configuration")} - - -class DockerServiceConnectionForm(forms.ModelForm): - """Docker service-connection form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["tls_authentication"].queryset = CertificateKeyPair.objects.filter( - key_data__isnull=False - ) - - class Meta: - - model = DockerServiceConnection - fields = ["name", "local", "url", "tls_verification", "tls_authentication"] - widgets = { - "name": forms.TextInput, - "url": forms.TextInput, - } - labels = { - "url": _("URL"), - "tls_verification": _("TLS Verification Certificate"), - "tls_authentication": _("TLS Authentication Certificate"), - } - - -class KubernetesServiceConnectionForm(forms.ModelForm): - """Kubernetes service-connection form""" - - class Meta: - - model = KubernetesServiceConnection - fields = [ - "name", - "local", - "kubeconfig", - ] - widgets = { - "name": forms.TextInput, - "kubeconfig": CodeMirrorWidget, - } - field_classes = { - "kubeconfig": YAMLField, - } diff --git a/passbook/outposts/migrations/0001_initial.py b/passbook/outposts/migrations/0001_initial.py deleted file mode 100644 index 9f07d019..00000000 --- a/passbook/outposts/migrations/0001_initial.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.1 on 2020-08-25 20:45 - -import uuid - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_core", "0008_auto_20200824_1532"), - ] - - operations = [ - migrations.CreateModel( - name="Outpost", - fields=[ - ( - "uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.TextField()), - ( - "channels", - django.contrib.postgres.fields.ArrayField( - base_field=models.TextField(), size=None - ), - ), - ("providers", models.ManyToManyField(to="passbook_core.Provider")), - ], - ), - ] diff --git a/passbook/outposts/migrations/0002_auto_20200826_1306.py b/passbook/outposts/migrations/0002_auto_20200826_1306.py deleted file mode 100644 index 9687bf52..00000000 --- a/passbook/outposts/migrations/0002_auto_20200826_1306.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.1 on 2020-08-26 13:06 - -from django.db import migrations, models - -import passbook.outposts.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="outpost", - name="_config", - field=models.JSONField( - default=passbook.outposts.models.default_outpost_config - ), - ), - migrations.AddField( - model_name="outpost", - name="type", - field=models.TextField(choices=[("proxy", "Proxy")], default="proxy"), - ), - ] diff --git a/passbook/outposts/migrations/0003_auto_20200827_2108.py b/passbook/outposts/migrations/0003_auto_20200827_2108.py deleted file mode 100644 index 34f4caa8..00000000 --- a/passbook/outposts/migrations/0003_auto_20200827_2108.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 3.1 on 2020-08-27 21:08 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0002_auto_20200826_1306"), - ] - - operations = [ - migrations.AddField( - model_name="outpost", - name="deployment_type", - field=models.TextField( - choices=[ - ("docker_compose", "Docker Compose"), - ("kubernetes", "Kubernetes"), - ("custom", "Custom"), - ], - default="custom", - help_text="Select between passbook-managed deployment types or a custom deployment.", - ), - ), - migrations.AlterField( - model_name="outpost", - name="channels", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.TextField(), default=list, size=None - ), - ), - ] diff --git a/passbook/outposts/migrations/0004_auto_20200830_1056.py b/passbook/outposts/migrations/0004_auto_20200830_1056.py deleted file mode 100644 index e0185a02..00000000 --- a/passbook/outposts/migrations/0004_auto_20200830_1056.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1 on 2020-08-30 10:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0003_auto_20200827_2108"), - ] - - operations = [ - migrations.AlterField( - model_name="outpost", - name="deployment_type", - field=models.TextField( - choices=[("kubernetes", "Kubernetes"), ("custom", "Custom")], - default="custom", - help_text="Select between passbook-managed deployment types or a custom deployment.", - ), - ), - ] diff --git a/passbook/outposts/migrations/0005_auto_20200909_1733.py b/passbook/outposts/migrations/0005_auto_20200909_1733.py deleted file mode 100644 index 5a5f4184..00000000 --- a/passbook/outposts/migrations/0005_auto_20200909_1733.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-09 17:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0004_auto_20200830_1056"), - ] - - operations = [ - migrations.AlterField( - model_name="outpost", - name="deployment_type", - field=models.TextField( - choices=[("custom", "Custom")], - default="custom", - help_text="Select between passbook-managed deployment types or a custom deployment.", - ), - ), - ] diff --git a/passbook/outposts/migrations/0006_auto_20201003_2239.py b/passbook/outposts/migrations/0006_auto_20201003_2239.py deleted file mode 100644 index 3ca23ba5..00000000 --- a/passbook/outposts/migrations/0006_auto_20201003_2239.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-03 22:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0005_auto_20200909_1733"), - ] - - operations = [ - migrations.AlterField( - model_name="outpost", - name="deployment_type", - field=models.TextField( - choices=[ - ("docker", "Docker"), - ("custom", "Custom"), - ], - default="custom", - help_text="Select between passbook-managed deployment types or a custom deployment.", - ), - ), - ] diff --git a/passbook/outposts/migrations/0007_remove_outpost_channels.py b/passbook/outposts/migrations/0007_remove_outpost_channels.py deleted file mode 100644 index 793a59be..00000000 --- a/passbook/outposts/migrations/0007_remove_outpost_channels.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-14 08:32 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0006_auto_20201003_2239"), - ] - - operations = [ - migrations.RemoveField( - model_name="outpost", - name="channels", - ), - ] diff --git a/passbook/outposts/migrations/0008_auto_20201014_1547.py b/passbook/outposts/migrations/0008_auto_20201014_1547.py deleted file mode 100644 index e5e181aa..00000000 --- a/passbook/outposts/migrations/0008_auto_20201014_1547.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-14 15:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0007_remove_outpost_channels"), - ] - - operations = [ - migrations.AlterField( - model_name="outpost", - name="deployment_type", - field=models.TextField( - choices=[ - ("kubernetes", "Kubernetes"), - ("docker", "Docker"), - ("custom", "Custom"), - ], - default="custom", - help_text="Select between passbook-managed deployment types or a custom deployment.", - ), - ), - ] diff --git a/passbook/outposts/migrations/0009_fix_missing_token_identifier.py b/passbook/outposts/migrations/0009_fix_missing_token_identifier.py deleted file mode 100644 index d614f1e5..00000000 --- a/passbook/outposts/migrations/0009_fix_missing_token_identifier.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-17 14:26 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - User = apps.get_model("passbook_core", "User") - Token = apps.get_model("passbook_core", "Token") - from passbook.outposts.models import Outpost - - for outpost in ( - Outpost.objects.using(schema_editor.connection.alias).all().only("pk") - ): - user_identifier = outpost.user_identifier - user = User.objects.get(username=user_identifier) - tokens = Token.objects.filter(user=user) - for token in tokens: - if token.identifier != outpost.token_identifier: - token.identifier = outpost.token_identifier - token.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0014_auto_20201018_1158"), - ("passbook_outposts", "0008_auto_20201014_1547"), - ] - - operations = [ - migrations.RunPython(fix_missing_token_identifier), - ] diff --git a/passbook/outposts/migrations/0010_service_connection.py b/passbook/outposts/migrations/0010_service_connection.py deleted file mode 100644 index f6ac59f8..00000000 --- a/passbook/outposts/migrations/0010_service_connection.py +++ /dev/null @@ -1,168 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-04 09:11 - -import uuid - -import django.db.models.deletion -from django.apps.registry import Apps -from django.core.exceptions import FieldError -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -import passbook.lib.models - - -def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - db_alias = schema_editor.connection.alias - Outpost = apps.get_model("passbook_outposts", "Outpost") - DockerServiceConnection = apps.get_model( - "passbook_outposts", "DockerServiceConnection" - ) - KubernetesServiceConnection = apps.get_model( - "passbook_outposts", "KubernetesServiceConnection" - ) - - docker = DockerServiceConnection.objects.filter(local=True).first() - k8s = KubernetesServiceConnection.objects.filter(local=True).first() - - try: - for outpost in ( - Outpost.objects.using(db_alias).all().exclude(deployment_type="custom") - ): - if outpost.deployment_type == "kubernetes": - outpost.service_connection = k8s - elif outpost.deployment_type == "docker": - outpost.service_connection = docker - outpost.save() - except FieldError: - # This is triggered during e2e tests when this function is called on an already-upgraded - # schema - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0009_fix_missing_token_identifier"), - ] - - operations = [ - migrations.CreateModel( - name="OutpostServiceConnection", - fields=[ - ( - "uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.TextField()), - ( - "local", - models.BooleanField( - default=False, - help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration", - unique=True, - ), - ), - ], - ), - migrations.CreateModel( - name="DockerServiceConnection", - fields=[ - ( - "outpostserviceconnection_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_outposts.outpostserviceconnection", - ), - ), - ("url", models.TextField()), - ("tls", models.BooleanField()), - ], - bases=("passbook_outposts.outpostserviceconnection",), - ), - migrations.CreateModel( - name="KubernetesServiceConnection", - fields=[ - ( - "outpostserviceconnection_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_outposts.outpostserviceconnection", - ), - ), - ("kubeconfig", models.JSONField()), - ], - bases=("passbook_outposts.outpostserviceconnection",), - ), - migrations.AddField( - model_name="outpost", - name="service_connection", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Select Service-Connection passbook should use to manage this outpost. Leave empty if passbook should not handle the deployment.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - to="passbook_outposts.outpostserviceconnection", - ), - ), - migrations.RunPython(migrate_to_service_connection), - migrations.RemoveField( - model_name="outpost", - name="deployment_type", - ), - migrations.AlterModelOptions( - name="dockerserviceconnection", - options={ - "verbose_name": "Docker Service-Connection", - "verbose_name_plural": "Docker Service-Connections", - }, - ), - migrations.AlterModelOptions( - name="kubernetesserviceconnection", - options={ - "verbose_name": "Kubernetes Service-Connection", - "verbose_name_plural": "Kubernetes Service-Connections", - }, - ), - migrations.AlterField( - model_name="outpost", - name="service_connection", - field=passbook.lib.models.InheritanceForeignKey( - blank=True, - default=None, - help_text="Select Service-Connection passbook should use to manage this outpost. Leave empty if passbook should not handle the deployment.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - to="passbook_outposts.outpostserviceconnection", - ), - ), - migrations.AlterModelOptions( - name="outpostserviceconnection", - options={ - "verbose_name": "Outpost Service-Connection", - "verbose_name_plural": "Outpost Service-Connections", - }, - ), - migrations.AlterField( - model_name="kubernetesserviceconnection", - name="kubeconfig", - field=models.JSONField( - default=None, - help_text="Paste your kubeconfig here. passbook will automatically use the currently selected context.", - ), - preserve_default=False, - ), - ] diff --git a/passbook/outposts/migrations/0011_docker_tls_auth.py b/passbook/outposts/migrations/0011_docker_tls_auth.py deleted file mode 100644 index 584d7f5c..00000000 --- a/passbook/outposts/migrations/0011_docker_tls_auth.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-18 21:51 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0002_create_self_signed_kp"), - ("passbook_outposts", "0010_service_connection"), - ] - - operations = [ - migrations.RemoveField( - model_name="dockerserviceconnection", - name="tls", - ), - migrations.AddField( - model_name="dockerserviceconnection", - name="tls_authentication", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Certificate/Key used for authentication. Can be left empty for no authentication.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - related_name="+", - to="passbook_crypto.certificatekeypair", - ), - ), - migrations.AddField( - model_name="dockerserviceconnection", - name="tls_verification", - field=models.ForeignKey( - blank=True, - default=None, - help_text="CA which the endpoint's Certificate is verified against. Can be left empty for no validation.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - related_name="+", - to="passbook_crypto.certificatekeypair", - ), - ), - ] diff --git a/passbook/outposts/migrations/0012_service_connection_non_unique.py b/passbook/outposts/migrations/0012_service_connection_non_unique.py deleted file mode 100644 index 87305fcb..00000000 --- a/passbook/outposts/migrations/0012_service_connection_non_unique.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-18 21:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_outposts", "0011_docker_tls_auth"), - ] - - operations = [ - migrations.AlterField( - model_name="outpostserviceconnection", - name="local", - field=models.BooleanField( - default=False, - help_text="If enabled, use the local connection. Required Docker socket/Kubernetes Integration", - ), - ), - ] diff --git a/passbook/outposts/models.py b/passbook/outposts/models.py deleted file mode 100644 index 461acab6..00000000 --- a/passbook/outposts/models.py +++ /dev/null @@ -1,426 +0,0 @@ -"""Outpost models""" -from dataclasses import asdict, dataclass, field -from datetime import datetime -from typing import Dict, Iterable, List, Optional, Type, Union -from uuid import uuid4 - -from dacite import from_dict -from django.core.cache import cache -from django.db import models, transaction -from django.db.models.base import Model -from django.forms.models import ModelForm -from django.http import HttpRequest -from django.utils.translation import gettext_lazy as _ -from docker.client import DockerClient -from docker.errors import DockerException -from guardian.models import UserObjectPermission -from guardian.shortcuts import assign_perm -from kubernetes.client import VersionApi, VersionInfo -from kubernetes.client.api_client import ApiClient -from kubernetes.client.configuration import Configuration -from kubernetes.client.exceptions import OpenApiException -from kubernetes.config.config_exception import ConfigException -from kubernetes.config.incluster_config import load_incluster_config -from kubernetes.config.kube_config import load_kube_config_from_dict -from model_utils.managers import InheritanceManager -from packaging.version import LegacyVersion, Version, parse -from structlog import get_logger -from urllib3.exceptions import HTTPError - -from passbook import __version__ -from passbook.core.models import Provider, Token, TokenIntents, User -from passbook.crypto.models import CertificateKeyPair -from passbook.lib.config import CONFIG -from passbook.lib.models import InheritanceForeignKey -from passbook.lib.sentry import SentryIgnoredException -from passbook.lib.utils.template import render_to_string -from passbook.outposts.docker_tls import DockerInlineTLS - -OUR_VERSION = parse(__version__) -OUTPOST_HELLO_INTERVAL = 10 -LOGGER = get_logger() - - -class ServiceConnectionInvalid(SentryIgnoredException): - """"Exception raised when a Service Connection has invalid parameters""" - - -@dataclass -class OutpostConfig: - """Configuration an outpost uses to configure it self""" - - passbook_host: str - passbook_host_insecure: bool = False - - log_level: str = CONFIG.y("log_level") - error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled") - error_reporting_environment: str = CONFIG.y( - "error_reporting.environment", "customer" - ) - - kubernetes_replicas: int = field(default=1) - kubernetes_namespace: str = field(default="default") - kubernetes_ingress_annotations: Dict[str, str] = field(default_factory=dict) - kubernetes_ingress_secret_name: str = field(default="passbook-outpost") - - -class OutpostModel(Model): - """Base model for providers that need more objects than just themselves""" - - def get_required_objects(self) -> Iterable[models.Model]: - """Return a list of all required objects""" - return [self] - - class Meta: - - abstract = True - - -class OutpostType(models.TextChoices): - """Outpost types, currently only the reverse proxy is available""" - - PROXY = "proxy" - - -def default_outpost_config(): - """Get default outpost config""" - return asdict(OutpostConfig(passbook_host="")) - - -@dataclass -class OutpostServiceConnectionState: - """State of an Outpost Service Connection""" - - version: str - healthy: bool - - -class OutpostServiceConnection(models.Model): - """Connection details for an Outpost Controller, like Docker or Kubernetes""" - - uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) - name = models.TextField() - - local = models.BooleanField( - default=False, - help_text=_( - ( - "If enabled, use the local connection. Required Docker " - "socket/Kubernetes Integration" - ) - ), - ) - - objects = InheritanceManager() - - @property - def state(self) -> OutpostServiceConnectionState: - """Get state of service connection""" - state_key = f"outpost_service_connection_{self.pk.hex}" - state = cache.get(state_key, None) - if not state: - state = self._get_state() - cache.set(state_key, state, timeout=0) - return state - - def _get_state(self) -> OutpostServiceConnectionState: - raise NotImplementedError - - @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" - raise NotImplementedError - - class Meta: - - verbose_name = _("Outpost Service-Connection") - verbose_name_plural = _("Outpost Service-Connections") - - -class DockerServiceConnection(OutpostServiceConnection): - """Service Connection to a Docker endpoint""" - - url = models.TextField() - tls_verification = models.ForeignKey( - CertificateKeyPair, - null=True, - blank=True, - default=None, - related_name="+", - on_delete=models.SET_DEFAULT, - help_text=_( - ( - "CA which the endpoint's Certificate is verified against. " - "Can be left empty for no validation." - ) - ), - ) - tls_authentication = models.ForeignKey( - CertificateKeyPair, - null=True, - blank=True, - default=None, - related_name="+", - on_delete=models.SET_DEFAULT, - help_text=_( - "Certificate/Key used for authentication. Can be left empty for no authentication." - ), - ) - - @property - def form(self) -> Type[ModelForm]: - from passbook.outposts.forms import DockerServiceConnectionForm - - return DockerServiceConnectionForm - - def __str__(self) -> str: - return f"Docker Service-Connection {self.name}" - - def client(self) -> DockerClient: - """Get DockerClient""" - try: - client = None - if self.local: - client = DockerClient.from_env() - else: - client = DockerClient( - base_url=self.url, - tls=DockerInlineTLS( - verification_kp=self.tls_verification, - authentication_kp=self.tls_authentication, - ).write(), - ) - client.containers.list() - except DockerException as exc: - LOGGER.error(exc) - raise ServiceConnectionInvalid from exc - return client - - def _get_state(self) -> OutpostServiceConnectionState: - try: - client = self.client() - return OutpostServiceConnectionState( - version=client.info()["ServerVersion"], healthy=True - ) - except ServiceConnectionInvalid: - return OutpostServiceConnectionState(version="", healthy=False) - - class Meta: - - verbose_name = _("Docker Service-Connection") - verbose_name_plural = _("Docker Service-Connections") - - -class KubernetesServiceConnection(OutpostServiceConnection): - """Service Connection to a Kubernetes cluster""" - - kubeconfig = models.JSONField( - help_text=_( - ( - "Paste your kubeconfig here. passbook will automatically use " - "the currently selected context." - ) - ) - ) - - @property - def form(self) -> Type[ModelForm]: - from passbook.outposts.forms import KubernetesServiceConnectionForm - - return KubernetesServiceConnectionForm - - def __str__(self) -> str: - return f"Kubernetes Service-Connection {self.name}" - - def _get_state(self) -> OutpostServiceConnectionState: - try: - client = self.client() - api_instance = VersionApi(client) - version: VersionInfo = api_instance.get_code() - return OutpostServiceConnectionState( - version=version.git_version, healthy=True - ) - except (OpenApiException, HTTPError): - return OutpostServiceConnectionState(version="", healthy=False) - - def client(self) -> ApiClient: - """Get Kubernetes client configured from kubeconfig""" - config = Configuration() - try: - if self.local: - load_incluster_config(client_configuration=config) - else: - load_kube_config_from_dict(self.kubeconfig, client_configuration=config) - return ApiClient(config) - except ConfigException as exc: - raise ServiceConnectionInvalid from exc - - class Meta: - - verbose_name = _("Kubernetes Service-Connection") - verbose_name_plural = _("Kubernetes Service-Connections") - - -class Outpost(models.Model): - """Outpost instance which manages a service user and token""" - - uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) - name = models.TextField() - - type = models.TextField(choices=OutpostType.choices, default=OutpostType.PROXY) - service_connection = InheritanceForeignKey( - OutpostServiceConnection, - default=None, - null=True, - blank=True, - help_text=_( - ( - "Select Service-Connection passbook should use to manage this outpost. " - "Leave empty if passbook should not handle the deployment." - ) - ), - on_delete=models.SET_DEFAULT, - ) - - _config = models.JSONField(default=default_outpost_config) - - providers = models.ManyToManyField(Provider) - - @property - def config(self) -> OutpostConfig: - """Load config as OutpostConfig object""" - return from_dict(OutpostConfig, self._config) - - @config.setter - def config(self, value): - """Dump config into json""" - self._config = asdict(value) - - @property - def state_cache_prefix(self) -> str: - """Key by which the outposts status is saved""" - return f"outpost_{self.uuid.hex}_state" - - @property - def state(self) -> List["OutpostState"]: - """Get outpost's health status""" - return OutpostState.for_outpost(self) - - @property - def user_identifier(self): - """Username for service user""" - return f"pb-outpost-{self.uuid.hex}" - - @property - def user(self) -> User: - """Get/create user with access to all required objects""" - users = User.objects.filter(username=self.user_identifier) - if not users.exists(): - user: User = User.objects.create(username=self.user_identifier) - user.set_unusable_password() - user.save() - else: - user = users.first() - # To ensure the user only has the correct permissions, we delete all of them and re-add - # the ones the user needs - with transaction.atomic(): - UserObjectPermission.objects.filter(user=user).delete() - for model in self.get_required_objects(): - code_name = f"{model._meta.app_label}.view_{model._meta.model_name}" - assign_perm(code_name, user, model) - return user - - @property - def token_identifier(self) -> str: - """Get Token identifier""" - return f"pb-outpost-{self.pk}-api" - - @property - def token(self) -> Token: - """Get/create token for auto-generated user""" - token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API) - if token.exists(): - return token.first() - return Token.objects.create( - user=self.user, - identifier=self.token_identifier, - intent=TokenIntents.INTENT_API, - description=f"Autogenerated by passbook for Outpost {self.name}", - expiring=False, - ) - - def get_required_objects(self) -> Iterable[models.Model]: - """Get an iterator of all objects the user needs read access to""" - objects = [self] - for provider in ( - Provider.objects.filter(outpost=self).select_related().select_subclasses() - ): - if isinstance(provider, OutpostModel): - objects.extend(provider.get_required_objects()) - else: - objects.append(provider) - return objects - - def html_deployment_view(self, request: HttpRequest) -> Optional[str]: - """return template and context modal to view token and other config info""" - return render_to_string( - "outposts/deployment_modal.html", - {"outpost": self, "full_url": request.build_absolute_uri("/")}, - ) - - def __str__(self) -> str: - return f"Outpost {self.name}" - - -@dataclass -class OutpostState: - """Outpost instance state, last_seen and version""" - - uid: str - last_seen: Optional[datetime] = field(default=None) - version: Optional[str] = field(default=None) - version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION) - - _outpost: Optional[Outpost] = field(default=None) - - @property - def version_outdated(self) -> bool: - """Check if outpost version matches our version""" - if not self.version: - return False - return parse(self.version) < OUR_VERSION - - @staticmethod - def for_outpost(outpost: Outpost) -> List["OutpostState"]: - """Get all states for an outpost""" - keys = cache.keys(f"{outpost.state_cache_prefix}_*") - states = [] - for key in keys: - channel = key.replace(f"{outpost.state_cache_prefix}_", "") - states.append(OutpostState.for_channel(outpost, channel)) - return states - - @staticmethod - def for_channel(outpost: Outpost, channel: str) -> "OutpostState": - """Get state for a single channel""" - key = f"{outpost.state_cache_prefix}_{channel}" - default_data = {"uid": channel} - data = cache.get(key, default_data) - if isinstance(data, str): - cache.delete(key) - data = default_data - state = from_dict(OutpostState, data) - state.uid = channel - # pylint: disable=protected-access - state._outpost = outpost - return state - - def save(self, timeout=OUTPOST_HELLO_INTERVAL): - """Save current state to cache""" - full_key = f"{self._outpost.state_cache_prefix}_{self.uid}" - return cache.set(full_key, asdict(self), timeout=timeout) - - def delete(self): - """Manually delete from cache, used on channel disconnect""" - full_key = f"{self._outpost.state_cache_prefix}_{self.uid}" - cache.delete(full_key) diff --git a/passbook/outposts/settings.py b/passbook/outposts/settings.py deleted file mode 100644 index 9f4a08d5..00000000 --- a/passbook/outposts/settings.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Outposts Settings""" -from celery.schedules import crontab - -CELERY_BEAT_SCHEDULE = { - "outposts_controller": { - "task": "passbook.outposts.tasks.outpost_controller_all", - "schedule": crontab(minute="*/5"), - "options": {"queue": "passbook_scheduled"}, - }, - "outposts_service_connection_check": { - "task": "passbook.outposts.tasks.outpost_service_connection_monitor", - "schedule": crontab(minute=0, hour="*"), - "options": {"queue": "passbook_scheduled"}, - }, -} diff --git a/passbook/outposts/signals.py b/passbook/outposts/signals.py deleted file mode 100644 index 7c4ba4c3..00000000 --- a/passbook/outposts/signals.py +++ /dev/null @@ -1,36 +0,0 @@ -"""passbook outpost signals""" -from django.db.models import Model -from django.db.models.signals import post_save, pre_delete -from django.dispatch import receiver -from structlog import get_logger - -from passbook.lib.utils.reflection import class_to_path -from passbook.outposts.models import Outpost -from passbook.outposts.tasks import outpost_post_save, outpost_pre_delete - -LOGGER = get_logger() - - -@receiver(post_save) -# pylint: disable=unused-argument -def post_save_update(sender, instance: Model, **_): - """If an Outpost is saved, Ensure that token is created/updated - - If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, - we send a message down the relevant OutpostModels WS connection to trigger an update""" - if instance.__module__ == "django.db.migrations.recorder": - return - if instance.__module__ == "__fake__": - return - outpost_post_save.delay(class_to_path(instance.__class__), instance.pk) - - -@receiver(pre_delete, sender=Outpost) -# pylint: disable=unused-argument -def pre_delete_cleanup(sender, instance: Outpost, **_): - """Ensure that Outpost's user is deleted (which will delete the token through cascade)""" - instance.user.delete() - # To ensure that deployment is cleaned up *consistently* we call the controller, and wait - # for it to finish. We don't want to call it in this thread, as we don't have the K8s - # credentials here - outpost_pre_delete.delay(instance.pk.hex).get() diff --git a/passbook/outposts/tasks.py b/passbook/outposts/tasks.py deleted file mode 100644 index 24fb5cab..00000000 --- a/passbook/outposts/tasks.py +++ /dev/null @@ -1,165 +0,0 @@ -"""outpost tasks""" -from typing import Any - -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer -from django.core.cache import cache -from django.db.models.base import Model -from django.utils.text import slugify -from structlog import get_logger - -from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus -from passbook.lib.utils.reflection import path_to_class -from passbook.outposts.controllers.base import ControllerException -from passbook.outposts.models import ( - DockerServiceConnection, - KubernetesServiceConnection, - Outpost, - OutpostModel, - OutpostServiceConnection, - OutpostState, - OutpostType, -) -from passbook.providers.proxy.controllers.docker import ProxyDockerController -from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController -from passbook.root.celery import CELERY_APP - -LOGGER = get_logger() - - -@CELERY_APP.task() -def outpost_controller_all(): - """Launch Controller for all Outposts which support it""" - for outpost in Outpost.objects.exclude(service_connection=None): - outpost_controller.delay(outpost.pk.hex) - - -@CELERY_APP.task() -def outpost_service_connection_state(state_pk: Any): - """Update cached state of a service connection""" - connection: OutpostServiceConnection = ( - OutpostServiceConnection.objects.filter(pk=state_pk).select_subclasses().first() - ) - cache.delete(f"outpost_service_connection_{connection.pk.hex}") - _ = connection.state - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def outpost_service_connection_monitor(self: MonitoredTask): - """Regularly check the state of Outpost Service Connections""" - for connection in OutpostServiceConnection.objects.select_subclasses(): - cache.delete(f"outpost_service_connection_{connection.pk.hex}") - _ = connection.state - self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def outpost_controller(self: MonitoredTask, outpost_pk: str): - """Create/update/monitor the deployment of an Outpost""" - logs = [] - outpost: Outpost = Outpost.objects.get(pk=outpost_pk) - self.set_uid(slugify(outpost.name)) - try: - if outpost.type == OutpostType.PROXY: - service_connection = outpost.service_connection - if isinstance(service_connection, DockerServiceConnection): - logs = ProxyDockerController(outpost, service_connection).up_with_logs() - if isinstance(service_connection, KubernetesServiceConnection): - logs = ProxyKubernetesController( - outpost, service_connection - ).up_with_logs() - LOGGER.debug("---------------Outpost Controller logs starting----------------") - for log in logs: - LOGGER.debug(log) - LOGGER.debug("-----------------Outpost Controller logs end-------------------") - except ControllerException as exc: - self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) - else: - self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, logs)) - - -@CELERY_APP.task() -def outpost_pre_delete(outpost_pk: str): - """Delete outpost objects before deleting the DB Object""" - outpost = Outpost.objects.get(pk=outpost_pk) - if outpost.type == OutpostType.PROXY: - service_connection = outpost.service_connection - if isinstance(service_connection, DockerServiceConnection): - ProxyDockerController(outpost, service_connection).down() - if isinstance(service_connection, KubernetesServiceConnection): - ProxyKubernetesController(outpost, service_connection).down() - - -@CELERY_APP.task() -def outpost_post_save(model_class: str, model_pk: Any): - """If an Outpost is saved, Ensure that token is created/updated - - If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved, - we send a message down the relevant OutpostModels WS connection to trigger an update""" - model: Model = path_to_class(model_class) - try: - instance = model.objects.get(pk=model_pk) - except model.DoesNotExist: - LOGGER.warning("Model does not exist", model=model, pk=model_pk) - return - - if isinstance(instance, Outpost): - LOGGER.debug("Ensuring token for outpost", instance=instance) - _ = instance.token - LOGGER.debug("Trigger reconcile for outpost") - outpost_controller.delay(instance.pk) - return - - if isinstance(instance, (OutpostModel, Outpost)): - LOGGER.debug( - "triggering outpost update from outpostmodel/outpost", instance=instance - ) - outpost_send_update(instance) - return - - if isinstance(instance, OutpostServiceConnection): - LOGGER.debug("triggering ServiceConnection state update", instance=instance) - outpost_service_connection_state.delay(instance.pk) - - for field in instance._meta.get_fields(): - # Each field is checked if it has a `related_model` attribute (when ForeginKeys or M2Ms) - # are used, and if it has a value - if not hasattr(field, "related_model"): - continue - if not field.related_model: - continue - if not issubclass(field.related_model, OutpostModel): - continue - - field_name = f"{field.name}_set" - if not hasattr(instance, field_name): - continue - - LOGGER.debug("triggering outpost update from from field", field=field.name) - # Because the Outpost Model has an M2M to Provider, - # we have to iterate over the entire QS - for reverse in getattr(instance, field_name).all(): - outpost_send_update(reverse) - - -def outpost_send_update(model_instace: Model): - """Send outpost update to all registered outposts, irregardless to which passbook - instance they are connected""" - channel_layer = get_channel_layer() - if isinstance(model_instace, OutpostModel): - for outpost in model_instace.outpost_set.all(): - _outpost_single_update(outpost, channel_layer) - elif isinstance(model_instace, Outpost): - _outpost_single_update(model_instace, channel_layer) - - -def _outpost_single_update(outpost: Outpost, layer=None): - """Update outpost instances connected to a single outpost""" - # Ensure token again, because this function is called when anything related to an - # OutpostModel is saved, so we can be sure permissions are right - _ = outpost.token - if not layer: # pragma: no cover - layer = get_channel_layer() - for state in OutpostState.for_outpost(outpost): - LOGGER.debug("sending update", channel=state.uid, outpost=outpost) - async_to_sync(layer.send)(state.uid, {"type": "event.update"}) diff --git a/passbook/outposts/templates/outposts/deployment_modal.html b/passbook/outposts/templates/outposts/deployment_modal.html deleted file mode 100644 index 1d480580..00000000 --- a/passbook/outposts/templates/outposts/deployment_modal.html +++ /dev/null @@ -1,43 +0,0 @@ -{% load i18n %} - - - -
-
-

{% trans 'Outpost Deployment Info' %}

-
- - -
-
diff --git a/passbook/outposts/tests.py b/passbook/outposts/tests.py deleted file mode 100644 index 31caede5..00000000 --- a/passbook/outposts/tests.py +++ /dev/null @@ -1,59 +0,0 @@ -"""outpost tests""" -from django.test import TestCase -from guardian.models import UserObjectPermission - -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow -from passbook.outposts.models import Outpost, OutpostType -from passbook.providers.proxy.models import ProxyProvider - - -class OutpostTests(TestCase): - """Outpost Tests""" - - def test_service_account_permissions(self): - """Test that the service account has correct permissions""" - provider: ProxyProvider = ProxyProvider.objects.create( - name="test", - internal_host="http://localhost", - external_host="http://localhost", - authorization_flow=Flow.objects.first(), - ) - outpost: Outpost = Outpost.objects.create( - name="test", - type=OutpostType.PROXY, - ) - - # Before we add a provider, the user should only have access to the outpost - permissions = UserObjectPermission.objects.filter(user=outpost.user) - self.assertEqual(len(permissions), 1) - self.assertEqual(permissions[0].object_pk, str(outpost.pk)) - - # We add a provider, user should only have access to outpost and provider - outpost.providers.add(provider) - outpost.save() - permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( - "content_type__model" - ) - self.assertEqual(len(permissions), 2) - self.assertEqual(permissions[0].object_pk, str(outpost.pk)) - self.assertEqual(permissions[1].object_pk, str(provider.pk)) - - # Provider requires a certificate-key-pair, user should have permissions for it - keypair = CertificateKeyPair.objects.first() - provider.certificate = keypair - provider.save() - permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by( - "content_type__model" - ) - self.assertEqual(len(permissions), 3) - self.assertEqual(permissions[0].object_pk, str(keypair.pk)) - self.assertEqual(permissions[1].object_pk, str(outpost.pk)) - self.assertEqual(permissions[2].object_pk, str(provider.pk)) - - # Remove provider from outpost, user should only have access to outpost - outpost.providers.remove(provider) - outpost.save() - permissions = UserObjectPermission.objects.filter(user=outpost.user) - self.assertEqual(len(permissions), 1) - self.assertEqual(permissions[0].object_pk, str(outpost.pk)) diff --git a/passbook/outposts/urls.py b/passbook/outposts/urls.py deleted file mode 100644 index 1ad51398..00000000 --- a/passbook/outposts/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook outposts urls""" -from django.urls import path - -from passbook.outposts.views import KubernetesManifestView, SetupView - -urlpatterns = [ - path( - "/k8s/", KubernetesManifestView.as_view(), name="k8s-manifest" - ), - path("/", SetupView.as_view(), name="setup"), -] diff --git a/passbook/outposts/views.py b/passbook/outposts/views.py deleted file mode 100644 index 6b71a1e9..00000000 --- a/passbook/outposts/views.py +++ /dev/null @@ -1,89 +0,0 @@ -"""passbook outpost views""" -from typing import Any, Dict, List - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import Model -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404 -from django.views import View -from django.views.generic import TemplateView -from guardian.shortcuts import get_objects_for_user -from structlog import get_logger - -from passbook.core.models import User -from passbook.outposts.controllers.docker import DockerController -from passbook.outposts.models import ( - DockerServiceConnection, - KubernetesServiceConnection, - Outpost, - OutpostType, -) -from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController - -LOGGER = get_logger() - - -def get_object_for_user_or_404(user: User, perm: str, **filters) -> Model: - """Wrapper that combines get_objects_for_user and get_object_or_404""" - return get_object_or_404(get_objects_for_user(user, perm), **filters) - - -class DockerComposeView(LoginRequiredMixin, View): - """Generate docker-compose yaml""" - - def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse: - """Render docker-compose file""" - outpost: Outpost = get_object_for_user_or_404( - request.user, - "passbook_outposts.view_outpost", - pk=outpost_pk, - ) - manifest = "" - if outpost.type == OutpostType.PROXY: - controller = DockerController(outpost, DockerServiceConnection()) - manifest = controller.get_static_deployment() - - return HttpResponse(manifest, content_type="text/vnd.yaml") - - -class KubernetesManifestView(LoginRequiredMixin, View): - """Generate Kubernetes Deployment and SVC for proxy""" - - def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse: - """Render deployment template""" - outpost: Outpost = get_object_for_user_or_404( - request.user, - "passbook_outposts.view_outpost", - pk=outpost_pk, - ) - manifest = "" - if outpost.type == OutpostType.PROXY: - controller = ProxyKubernetesController( - outpost, KubernetesServiceConnection() - ) - manifest = controller.get_static_deployment() - - return HttpResponse(manifest, content_type="text/vnd.yaml") - - -class SetupView(LoginRequiredMixin, TemplateView): - """Setup view""" - - def get_template_names(self) -> List[str]: - allowed = ["dc", "custom", "k8s_manual", "k8s_integration"] - setup_type = self.request.GET.get("type", "dc") - if setup_type not in allowed: - setup_type = allowed[0] - return [f"outposts/setup_{setup_type}.html"] - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - outpost: Outpost = get_object_for_user_or_404( - self.request.user, - "passbook_outposts.view_outpost", - pk=self.kwargs["outpost_pk"], - ) - kwargs.update( - {"host": self.request.build_absolute_uri("/"), "outpost": outpost} - ) - return kwargs diff --git a/passbook/policies/api.py b/passbook/policies/api.py deleted file mode 100644 index 636ff953..00000000 --- a/passbook/policies/api.py +++ /dev/null @@ -1,100 +0,0 @@ -"""policy API Views""" -from django.core.exceptions import ObjectDoesNotExist -from rest_framework.serializers import ( - ModelSerializer, - PrimaryKeyRelatedField, - SerializerMethodField, -) -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet - -from passbook.policies.forms import GENERAL_FIELDS -from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel - - -class PolicyBindingModelForeignKey(PrimaryKeyRelatedField): - """rest_framework PrimaryKeyRelatedField which resolves - model_manager's InheritanceQuerySet""" - - def use_pk_only_optimization(self): - return False - - def to_internal_value(self, data): - if self.pk_field is not None: - data = self.pk_field.to_internal_value(data) - try: - # Due to inheritance, a direct DB lookup for the primary key - # won't return anything. This is because the direct lookup - # checks the PK of PolicyBindingModel (for example), - # but we get given the Primary Key of the inheriting class - for model in self.get_queryset().select_subclasses().all().select_related(): - if model.pk == data: - return model - # as a fallback we still try a direct lookup - return self.get_queryset().get_subclass(pk=data) - except ObjectDoesNotExist: - self.fail("does_not_exist", pk_value=data) - except (TypeError, ValueError): - self.fail("incorrect_type", data_type=type(data).__name__) - - def to_representation(self, value): - correct_model = PolicyBindingModel.objects.get_subclass(pbm_uuid=value.pbm_uuid) - return correct_model.pk - - -class PolicySerializer(ModelSerializer): - """Policy Serializer""" - - __type__ = SerializerMethodField(method_name="get_type") - - def get_type(self, obj): - """Get object type so that we know which API Endpoint to use to get the full object""" - return obj._meta.object_name.lower().replace("policy", "") - - def to_representation(self, instance: Policy): - # pyright: reportGeneralTypeIssues=false - if instance.__class__ == Policy: - return super().to_representation(instance) - return instance.serializer(instance=instance).data - - class Meta: - - model = Policy - fields = ["pk"] + GENERAL_FIELDS + ["__type__"] - depth = 3 - - -class PolicyViewSet(ReadOnlyModelViewSet): - """Policy Viewset""" - - queryset = Policy.objects.all() - serializer_class = PolicySerializer - - def get_queryset(self): - return Policy.objects.select_subclasses() - - -class PolicyBindingSerializer(ModelSerializer): - """PolicyBinding Serializer""" - - # Because we're not interested in the PolicyBindingModel's PK but rather the subclasses PK, - # we have to manually declare this field - target = PolicyBindingModelForeignKey( - queryset=PolicyBindingModel.objects.select_subclasses(), - required=True, - ) - - policy_obj = PolicySerializer(read_only=True, source="policy") - - class Meta: - - model = PolicyBinding - fields = ["pk", "policy", "policy_obj", "target", "enabled", "order", "timeout"] - - -class PolicyBindingViewSet(ModelViewSet): - """PolicyBinding Viewset""" - - queryset = PolicyBinding.objects.all() - serializer_class = PolicyBindingSerializer - filterset_fields = ["policy", "target", "enabled", "order", "timeout"] - search_fields = ["policy__name"] diff --git a/passbook/policies/apps.py b/passbook/policies/apps.py deleted file mode 100644 index 738d718c..00000000 --- a/passbook/policies/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -"""passbook policies app config""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookPoliciesConfig(AppConfig): - """passbook policies app config""" - - name = "passbook.policies" - label = "passbook_policies" - verbose_name = "passbook Policies" - - def ready(self): - import_module("passbook.policies.signals") diff --git a/passbook/policies/dummy/api.py b/passbook/policies/dummy/api.py deleted file mode 100644 index dffe5b66..00000000 --- a/passbook/policies/dummy/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Dummy Policy API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.policies.dummy.models import DummyPolicy -from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS - - -class DummyPolicySerializer(ModelSerializer): - """Dummy Policy Serializer""" - - class Meta: - model = DummyPolicy - fields = GENERAL_SERIALIZER_FIELDS + ["result", "wait_min", "wait_max"] - - -class DummyPolicyViewSet(ModelViewSet): - """Dummy Viewset""" - - queryset = DummyPolicy.objects.all() - serializer_class = DummyPolicySerializer diff --git a/passbook/policies/dummy/apps.py b/passbook/policies/dummy/apps.py deleted file mode 100644 index 5f60a03f..00000000 --- a/passbook/policies/dummy/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Passbook policy dummy app config""" - -from django.apps import AppConfig - - -class PassbookPolicyDummyConfig(AppConfig): - """Passbook policy_dummy app config""" - - name = "passbook.policies.dummy" - label = "passbook_policies_dummy" - verbose_name = "passbook Policies.Dummy" diff --git a/passbook/policies/dummy/forms.py b/passbook/policies/dummy/forms.py deleted file mode 100644 index 05e6a6bd..00000000 --- a/passbook/policies/dummy/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -"""passbook Policy forms""" - -from django import forms -from django.utils.translation import gettext as _ - -from passbook.policies.dummy.models import DummyPolicy -from passbook.policies.forms import GENERAL_FIELDS - - -class DummyPolicyForm(forms.ModelForm): - """DummyPolicyForm Form""" - - class Meta: - - model = DummyPolicy - fields = GENERAL_FIELDS + ["result", "wait_min", "wait_max"] - widgets = { - "name": forms.TextInput(), - } - labels = {"result": _("Allow user")} diff --git a/passbook/policies/dummy/migrations/0001_initial.py b/passbook/policies/dummy/migrations/0001_initial.py deleted file mode 100644 index 319bdf21..00000000 --- a/passbook/policies/dummy/migrations/0001_initial.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="DummyPolicy", - fields=[ - ( - "policy_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.Policy", - ), - ), - ("result", models.BooleanField(default=False)), - ("wait_min", models.IntegerField(default=5)), - ("wait_max", models.IntegerField(default=30)), - ], - options={ - "verbose_name": "Dummy Policy", - "verbose_name_plural": "Dummy Policies", - }, - bases=("passbook_policies.policy",), - ), - ] diff --git a/passbook/policies/dummy/models.py b/passbook/policies/dummy/models.py deleted file mode 100644 index 8ff36306..00000000 --- a/passbook/policies/dummy/models.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Dummy policy""" -from random import SystemRandom -from time import sleep -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext_lazy as _ -from rest_framework.serializers import BaseSerializer -from structlog import get_logger - -from passbook.policies.models import Policy -from passbook.policies.types import PolicyRequest, PolicyResult - -LOGGER = get_logger() - - -class DummyPolicy(Policy): - """Policy used for debugging the PolicyEngine. Returns a fixed result, - but takes a random time to process.""" - - __debug_only__ = True - - result = models.BooleanField(default=False) - wait_min = models.IntegerField(default=5) - wait_max = models.IntegerField(default=30) - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.dummy.api import DummyPolicySerializer - - return DummyPolicySerializer - - @property - def form(self) -> Type[ModelForm]: - from passbook.policies.dummy.forms import DummyPolicyForm - - return DummyPolicyForm - - def passes(self, request: PolicyRequest) -> PolicyResult: - """Wait random time then return result""" - wait = SystemRandom().randrange(self.wait_min, self.wait_max) - LOGGER.debug("Policy waiting", policy=self, delay=wait) - sleep(wait) - return PolicyResult(self.result, "dummy") - - class Meta: - - verbose_name = _("Dummy Policy") - verbose_name_plural = _("Dummy Policies") diff --git a/passbook/policies/dummy/tests.py b/passbook/policies/dummy/tests.py deleted file mode 100644 index 40959625..00000000 --- a/passbook/policies/dummy/tests.py +++ /dev/null @@ -1,39 +0,0 @@ -"""dummy policy tests""" -from django.test import TestCase -from guardian.shortcuts import get_anonymous_user - -from passbook.policies.dummy.forms import DummyPolicyForm -from passbook.policies.dummy.models import DummyPolicy -from passbook.policies.engine import PolicyRequest - - -class TestDummyPolicy(TestCase): - """Test dummy policy""" - - def setUp(self): - super().setUp() - self.request = PolicyRequest(user=get_anonymous_user()) - - def test_policy(self): - """test policy .passes""" - policy: DummyPolicy = DummyPolicy.objects.create( - name="dummy", wait_min=1, wait_max=2 - ) - result = policy.passes(self.request) - self.assertFalse(result.passing) - self.assertEqual(result.messages, ("dummy",)) - - def test_form(self): - """test form""" - form = DummyPolicyForm( - data={ - "name": "dummy", - "negate": False, - "order": 0, - "timeout": 1, - "result": True, - "wait_min": 1, - "wait_max": 2, - } - ) - self.assertTrue(form.is_valid()) diff --git a/passbook/policies/engine.py b/passbook/policies/engine.py deleted file mode 100644 index c02e7408..00000000 --- a/passbook/policies/engine.py +++ /dev/null @@ -1,135 +0,0 @@ -"""passbook policy engine""" -from multiprocessing import Pipe, set_start_method -from multiprocessing.connection import Connection -from typing import Iterator, List, Optional - -from django.core.cache import cache -from django.http import HttpRequest -from sentry_sdk.hub import Hub -from sentry_sdk.tracing import Span -from structlog import get_logger - -from passbook.core.models import User -from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel -from passbook.policies.process import PolicyProcess, cache_key -from passbook.policies.types import PolicyRequest, PolicyResult - -LOGGER = get_logger() -# This is only really needed for macOS, because Python 3.8 changed the default to spawn -# spawn causes issues with objects that aren't picklable, and also the django setup -set_start_method("fork") - - -class PolicyProcessInfo: - """Dataclass to hold all information and communication channels to a process""" - - process: PolicyProcess - connection: Connection - result: Optional[PolicyResult] - binding: PolicyBinding - - def __init__( - self, process: PolicyProcess, connection: Connection, binding: PolicyBinding - ): - self.process = process - self.connection = connection - self.binding = binding - self.result = None - - -class PolicyEngine: - """Orchestrate policy checking, launch tasks and return result""" - - use_cache: bool - request: PolicyRequest - - __pbm: PolicyBindingModel - __cached_policies: List[PolicyResult] - __processes: List[PolicyProcessInfo] - - def __init__( - self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None - ): - if not isinstance(pbm, PolicyBindingModel): # pragma: no cover - raise ValueError(f"{pbm} is not instance of PolicyBindingModel") - self.__pbm = pbm - self.request = PolicyRequest(user) - if request: - self.request.http_request = request - self.__cached_policies = [] - self.__processes = [] - self.use_cache = True - - def _iter_bindings(self) -> Iterator[PolicyBinding]: - """Make sure all Policies are their respective classes""" - return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by( - "order" - ) - - def _check_policy_type(self, policy: Policy): - """Check policy type, make sure it's not the root class as that has no logic implemented""" - # pyright: reportGeneralTypeIssues=false - if policy.__class__ == Policy: - raise TypeError(f"Policy '{policy}' is root type") - - def build(self) -> "PolicyEngine": - """Build wrapper which monitors performance""" - with Hub.current.start_span(op="policy.engine.build") as span: - span: Span - span.set_data("pbm", self.__pbm) - span.set_data("request", self.request) - for binding in self._iter_bindings(): - self._check_policy_type(binding.policy) - key = cache_key(binding, self.request) - cached_policy = cache.get(key, None) - if cached_policy and self.use_cache: - LOGGER.debug( - "P_ENG: Taking result from cache", - policy=binding.policy, - cache_key=key, - ) - self.__cached_policies.append(cached_policy) - continue - LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy) - our_end, task_end = Pipe(False) - task = PolicyProcess(binding, self.request, task_end) - LOGGER.debug("P_ENG: Starting Process", policy=binding.policy) - task.start() - self.__processes.append( - PolicyProcessInfo(process=task, connection=our_end, binding=binding) - ) - # If all policies are cached, we have an empty list here. - for proc_info in self.__processes: - proc_info.process.join(proc_info.binding.timeout) - # Only call .recv() if no result is saved, otherwise we just deadlock here - if not proc_info.result: - proc_info.result = proc_info.connection.recv() - return self - - @property - def result(self) -> PolicyResult: - """Get policy-checking result""" - process_results: List[PolicyResult] = [ - x.result for x in self.__processes if x.result - ] - final_result = PolicyResult(False) - final_result.messages = [] - final_result.source_results = list(process_results + self.__cached_policies) - for result in process_results + self.__cached_policies: - LOGGER.debug( - "P_ENG: result", passing=result.passing, messages=result.messages - ) - if result.messages: - final_result.messages.extend(result.messages) - if not result.passing: - final_result.messages = tuple(final_result.messages) - final_result.passing = False - return final_result - final_result.messages = tuple(final_result.messages) - final_result.passing = True - return final_result - - @property - def passing(self) -> bool: - """Only get true/false if user passes""" - return self.result.passing diff --git a/passbook/policies/exceptions.py b/passbook/policies/exceptions.py deleted file mode 100644 index ec27684d..00000000 --- a/passbook/policies/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -"""policy exceptions""" -from passbook.lib.sentry import SentryIgnoredException - - -class PolicyException(SentryIgnoredException): - """Exception that should be raised during Policy Evaluation, and can be recovered from.""" diff --git a/passbook/policies/expiry/api.py b/passbook/policies/expiry/api.py deleted file mode 100644 index fd206371..00000000 --- a/passbook/policies/expiry/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Password Expiry Policy API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.policies.expiry.models import PasswordExpiryPolicy -from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS - - -class PasswordExpiryPolicySerializer(ModelSerializer): - """Password Expiry Policy Serializer""" - - class Meta: - model = PasswordExpiryPolicy - fields = GENERAL_SERIALIZER_FIELDS + ["days", "deny_only"] - - -class PasswordExpiryPolicyViewSet(ModelViewSet): - """Password Expiry Viewset""" - - queryset = PasswordExpiryPolicy.objects.all() - serializer_class = PasswordExpiryPolicySerializer diff --git a/passbook/policies/expiry/apps.py b/passbook/policies/expiry/apps.py deleted file mode 100644 index cfcff589..00000000 --- a/passbook/policies/expiry/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Passbook policy_expiry app config""" - -from django.apps import AppConfig - - -class PassbookPolicyExpiryConfig(AppConfig): - """Passbook policy_expiry app config""" - - name = "passbook.policies.expiry" - label = "passbook_policies_expiry" - verbose_name = "passbook Policies.Expiry" diff --git a/passbook/policies/expiry/forms.py b/passbook/policies/expiry/forms.py deleted file mode 100644 index 2eb85220..00000000 --- a/passbook/policies/expiry/forms.py +++ /dev/null @@ -1,22 +0,0 @@ -"""passbook PasswordExpiry Policy forms""" - -from django import forms -from django.utils.translation import gettext as _ - -from passbook.policies.expiry.models import PasswordExpiryPolicy -from passbook.policies.forms import GENERAL_FIELDS - - -class PasswordExpiryPolicyForm(forms.ModelForm): - """Edit PasswordExpiryPolicy instances""" - - class Meta: - - model = PasswordExpiryPolicy - fields = GENERAL_FIELDS + ["days", "deny_only"] - widgets = { - "name": forms.TextInput(), - "order": forms.NumberInput(), - "days": forms.NumberInput(), - } - labels = {"deny_only": _("Only fail the policy, don't set user's password.")} diff --git a/passbook/policies/expiry/migrations/0001_initial.py b/passbook/policies/expiry/migrations/0001_initial.py deleted file mode 100644 index 31dc8fa3..00000000 --- a/passbook/policies/expiry/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PasswordExpiryPolicy", - fields=[ - ( - "policy_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.Policy", - ), - ), - ("deny_only", models.BooleanField(default=False)), - ("days", models.IntegerField()), - ], - options={ - "verbose_name": "Password Expiry Policy", - "verbose_name_plural": "Password Expiry Policies", - }, - bases=("passbook_policies.policy",), - ), - ] diff --git a/passbook/policies/expiry/models.py b/passbook/policies/expiry/models.py deleted file mode 100644 index 96b59269..00000000 --- a/passbook/policies/expiry/models.py +++ /dev/null @@ -1,62 +0,0 @@ -"""passbook password_expiry_policy Models""" -from datetime import timedelta -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.utils.timezone import now -from django.utils.translation import gettext as _ -from rest_framework.serializers import BaseSerializer -from structlog import get_logger - -from passbook.policies.models import Policy -from passbook.policies.types import PolicyRequest, PolicyResult - -LOGGER = get_logger() - - -class PasswordExpiryPolicy(Policy): - """If password change date is more than x days in the past, invalidate the user's password - and show a notice""" - - deny_only = models.BooleanField(default=False) - days = models.IntegerField() - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.expiry.api import PasswordExpiryPolicySerializer - - return PasswordExpiryPolicySerializer - - @property - def form(self) -> Type[ModelForm]: - from passbook.policies.expiry.forms import PasswordExpiryPolicyForm - - return PasswordExpiryPolicyForm - - def passes(self, request: PolicyRequest) -> PolicyResult: - """If password change date is more than x days in the past, call set_unusable_password - and show a notice""" - actual_days = (now() - request.user.password_change_date).days - days_since_expiry = ( - now() - (request.user.password_change_date + timedelta(days=self.days)) - ).days - if actual_days >= self.days: - if not self.deny_only: - request.user.set_unusable_password() - request.user.save() - message = _( - ( - "Password expired %(days)d days ago. " - "Please update your password." - ) - % {"days": days_since_expiry} - ) - return PolicyResult(False, message) - return PolicyResult(False, _("Password has expired.")) - return PolicyResult(True) - - class Meta: - - verbose_name = _("Password Expiry Policy") - verbose_name_plural = _("Password Expiry Policies") diff --git a/passbook/policies/expression/api.py b/passbook/policies/expression/api.py deleted file mode 100644 index f54443fa..00000000 --- a/passbook/policies/expression/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Expression Policy API""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS - - -class ExpressionPolicySerializer(ModelSerializer): - """Group Membership Policy Serializer""" - - class Meta: - model = ExpressionPolicy - fields = GENERAL_SERIALIZER_FIELDS + ["expression"] - - -class ExpressionPolicyViewSet(ModelViewSet): - """Source Viewset""" - - queryset = ExpressionPolicy.objects.all() - serializer_class = ExpressionPolicySerializer diff --git a/passbook/policies/expression/apps.py b/passbook/policies/expression/apps.py deleted file mode 100644 index d4c73519..00000000 --- a/passbook/policies/expression/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Passbook policy_expression app config""" - -from django.apps import AppConfig - - -class PassbookPolicyExpressionConfig(AppConfig): - """Passbook policy_expression app config""" - - name = "passbook.policies.expression" - label = "passbook_policies_expression" - verbose_name = "passbook Policies.Expression" diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py deleted file mode 100644 index 5d861d9b..00000000 --- a/passbook/policies/expression/evaluator.py +++ /dev/null @@ -1,72 +0,0 @@ -"""passbook expression policy evaluator""" -from ipaddress import ip_address, ip_network -from typing import List - -from django.http import HttpRequest -from structlog import get_logger - -from passbook.flows.planner import PLAN_CONTEXT_SSO -from passbook.lib.expression.evaluator import BaseEvaluator -from passbook.lib.utils.http import get_client_ip -from passbook.policies.types import PolicyRequest, PolicyResult - -LOGGER = get_logger() - - -class PolicyEvaluator(BaseEvaluator): - """Validate and evaluate python-based expressions""" - - _messages: List[str] - - def __init__(self, policy_name: str): - super().__init__() - self._messages = [] - self._context["pb_message"] = self.expr_func_message - self._context["ip_address"] = ip_address - self._context["ip_network"] = ip_network - self._filename = policy_name or "PolicyEvaluator" - - def expr_func_message(self, message: str): - """Wrapper to append to messages list, which is returned with PolicyResult""" - self._messages.append(message) - - def set_policy_request(self, request: PolicyRequest): - """Update context based on policy request (if http request is given, update that too)""" - # update website/docs/policies/expression.md - self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False) - if request.http_request: - self.set_http_request(request.http_request) - self._context["request"] = request - self._context["context"] = request.context - - def set_http_request(self, request: HttpRequest): - """Update context based on http request""" - # update website/docs/policies/expression.md - self._context["pb_client_ip"] = ip_address( - get_client_ip(request) or "255.255.255.255" - ) - self._context["request"] = request - - def evaluate(self, expression_source: str) -> PolicyResult: - """Parse and evaluate expression. Policy is expected to return a truthy object. - Messages can be added using 'do pb_message()'.""" - try: - result = super().evaluate(expression_source) - except (ValueError, SyntaxError) as exc: - return PolicyResult(False, str(exc)) - except Exception as exc: # pylint: disable=broad-except - LOGGER.warning("Expression error", exc=exc) - return PolicyResult(False, str(exc)) - else: - policy_result = PolicyResult(False) - policy_result.messages = tuple(self._messages) - if result is None: - LOGGER.warning( - "Expression policy returned None", - src=expression_source, - req=self._context, - ) - policy_result.passing = False - if result: - policy_result.passing = bool(result) - return policy_result diff --git a/passbook/policies/expression/forms.py b/passbook/policies/expression/forms.py deleted file mode 100644 index 23d2bc47..00000000 --- a/passbook/policies/expression/forms.py +++ /dev/null @@ -1,31 +0,0 @@ -"""passbook Expression Policy forms""" - -from django import forms - -from passbook.admin.fields import CodeMirrorWidget -from passbook.policies.expression.evaluator import PolicyEvaluator -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.forms import GENERAL_FIELDS - - -class ExpressionPolicyForm(forms.ModelForm): - """ExpressionPolicy Form""" - - template_name = "policy/expression/form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - PolicyEvaluator(self.instance.name).validate(expression) - return expression - - class Meta: - - model = ExpressionPolicy - fields = GENERAL_FIELDS + [ - "expression", - ] - widgets = { - "name": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } diff --git a/passbook/policies/expression/migrations/0001_initial.py b/passbook/policies/expression/migrations/0001_initial.py deleted file mode 100644 index c0f00ae7..00000000 --- a/passbook/policies/expression/migrations/0001_initial.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="ExpressionPolicy", - fields=[ - ( - "policy_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.Policy", - ), - ), - ("expression", models.TextField()), - ], - options={ - "verbose_name": "Expression Policy", - "verbose_name_plural": "Expression Policies", - }, - bases=("passbook_policies.policy",), - ), - ] diff --git a/passbook/policies/expression/migrations/0002_auto_20200926_1156.py b/passbook/policies/expression/migrations/0002_auto_20200926_1156.py deleted file mode 100644 index 8f2e6798..00000000 --- a/passbook/policies/expression/migrations/0002_auto_20200926_1156.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-26 11:56 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def remove_pb_flow_plan(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - ExpressionPolicy = apps.get_model( - "passbook_policies_expression", "ExpressionPolicy" - ) - - db_alias = schema_editor.connection.alias - - for policy in ExpressionPolicy.objects.using(db_alias).all(): - policy.expression.replace("pb_flow_plan.", "context.") - policy.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_policies_expression", "0001_initial"), - ] - - operations = [ - migrations.RunPython(remove_pb_flow_plan), - ] diff --git a/passbook/policies/expression/models.py b/passbook/policies/expression/models.py deleted file mode 100644 index 6bdce9fe..00000000 --- a/passbook/policies/expression/models.py +++ /dev/null @@ -1,44 +0,0 @@ -"""passbook expression Policy Models""" -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext as _ -from rest_framework.serializers import BaseSerializer - -from passbook.policies.expression.evaluator import PolicyEvaluator -from passbook.policies.models import Policy -from passbook.policies.types import PolicyRequest, PolicyResult - - -class ExpressionPolicy(Policy): - """Execute arbitrary Python code to implement custom checks and validation.""" - - expression = models.TextField() - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.expression.api import ExpressionPolicySerializer - - return ExpressionPolicySerializer - - @property - def form(self) -> Type[ModelForm]: - from passbook.policies.expression.forms import ExpressionPolicyForm - - return ExpressionPolicyForm - - def passes(self, request: PolicyRequest) -> PolicyResult: - """Evaluate and render expression. Returns PolicyResult(false) on error.""" - evaluator = PolicyEvaluator(self.name) - evaluator.set_policy_request(request) - return evaluator.evaluate(self.expression) - - def save(self, *args, **kwargs): - PolicyEvaluator(self.name).validate(self.expression) - return super().save(*args, **kwargs) - - class Meta: - - verbose_name = _("Expression Policy") - verbose_name_plural = _("Expression Policies") diff --git a/passbook/policies/expression/templates/policy/expression/form.html b/passbook/policies/expression/templates/policy/expression/form.html deleted file mode 100644 index 5a1f354d..00000000 --- a/passbook/policies/expression/templates/policy/expression/form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/passbook/policies/expression/tests.py b/passbook/policies/expression/tests.py deleted file mode 100644 index 54b04981..00000000 --- a/passbook/policies/expression/tests.py +++ /dev/null @@ -1,62 +0,0 @@ -"""evaluator tests""" -from django.core.exceptions import ValidationError -from django.test import TestCase -from guardian.shortcuts import get_anonymous_user - -from passbook.policies.expression.evaluator import PolicyEvaluator -from passbook.policies.types import PolicyRequest - - -class TestEvaluator(TestCase): - """Evaluator tests""" - - def setUp(self): - self.request = PolicyRequest(user=get_anonymous_user()) - - def test_valid(self): - """test simple value expression""" - template = "return True" - evaluator = PolicyEvaluator("test") - evaluator.set_policy_request(self.request) - self.assertEqual(evaluator.evaluate(template).passing, True) - - def test_messages(self): - """test expression with message return""" - template = 'pb_message("some message");return False' - evaluator = PolicyEvaluator("test") - evaluator.set_policy_request(self.request) - result = evaluator.evaluate(template) - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("some message",)) - - def test_invalid_syntax(self): - """test invalid syntax""" - template = ";" - evaluator = PolicyEvaluator("test") - evaluator.set_policy_request(self.request) - result = evaluator.evaluate(template) - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("invalid syntax (test, line 3)",)) - - def test_undefined(self): - """test undefined result""" - template = "{{ foo.bar }}" - evaluator = PolicyEvaluator("test") - evaluator.set_policy_request(self.request) - result = evaluator.evaluate(template) - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("name 'foo' is not defined",)) - - def test_validate(self): - """test validate""" - template = "True" - evaluator = PolicyEvaluator("test") - result = evaluator.validate(template) - self.assertEqual(result, True) - - def test_validate_invalid(self): - """test validate""" - template = ";" - evaluator = PolicyEvaluator("test") - with self.assertRaises(ValidationError): - evaluator.validate(template) diff --git a/passbook/policies/forms.py b/passbook/policies/forms.py deleted file mode 100644 index e38955ea..00000000 --- a/passbook/policies/forms.py +++ /dev/null @@ -1,26 +0,0 @@ -"""General fields""" - -from django import forms - -from passbook.lib.widgets import GroupedModelChoiceField -from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel - -GENERAL_FIELDS = ["name"] -GENERAL_SERIALIZER_FIELDS = ["pk", "name"] - - -class PolicyBindingForm(forms.ModelForm): - """Form to edit Policy to PolicyBindingModel Binding""" - - target = GroupedModelChoiceField( - queryset=PolicyBindingModel.objects.all().select_subclasses(), - to_field_name="pbm_uuid", - ) - policy = GroupedModelChoiceField( - queryset=Policy.objects.all().select_subclasses(), - ) - - class Meta: - - model = PolicyBinding - fields = ["enabled", "policy", "target", "order", "timeout"] diff --git a/passbook/policies/group_membership/api.py b/passbook/policies/group_membership/api.py deleted file mode 100644 index e712dc19..00000000 --- a/passbook/policies/group_membership/api.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Group Membership Policy API""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS -from passbook.policies.group_membership.models import GroupMembershipPolicy - - -class GroupMembershipPolicySerializer(ModelSerializer): - """Group Membership Policy Serializer""" - - class Meta: - model = GroupMembershipPolicy - fields = GENERAL_SERIALIZER_FIELDS + [ - "group", - ] - - -class GroupMembershipPolicyViewSet(ModelViewSet): - """Group Membership Policy Viewset""" - - queryset = GroupMembershipPolicy.objects.all() - serializer_class = GroupMembershipPolicySerializer diff --git a/passbook/policies/group_membership/apps.py b/passbook/policies/group_membership/apps.py deleted file mode 100644 index 95ea0edb..00000000 --- a/passbook/policies/group_membership/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook Group Membership policy app config""" - -from django.apps import AppConfig - - -class PassbookPoliciesGroupMembershipConfig(AppConfig): - """passbook Group Membership policy app config""" - - name = "passbook.policies.group_membership" - label = "passbook_policies_group_membership" - verbose_name = "passbook Policies.Group Membership" diff --git a/passbook/policies/group_membership/forms.py b/passbook/policies/group_membership/forms.py deleted file mode 100644 index af236869..00000000 --- a/passbook/policies/group_membership/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -"""passbook Group Membership Policy forms""" - -from django import forms - -from passbook.policies.forms import GENERAL_FIELDS -from passbook.policies.group_membership.models import GroupMembershipPolicy - - -class GroupMembershipPolicyForm(forms.ModelForm): - """GroupMembershipPolicy Form""" - - class Meta: - - model = GroupMembershipPolicy - fields = GENERAL_FIELDS + [ - "group", - ] - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/policies/group_membership/migrations/0001_initial.py b/passbook/policies/group_membership/migrations/0001_initial.py deleted file mode 100644 index 5c8325ae..00000000 --- a/passbook/policies/group_membership/migrations/0001_initial.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 3.0.7 on 2020-07-01 19:01 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0002_auto_20200528_1647"), - ("passbook_core", "0003_default_user"), - ] - - operations = [ - migrations.CreateModel( - name="GroupMembershipPolicy", - fields=[ - ( - "policy_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.Policy", - ), - ), - ( - "group", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="passbook_core.Group", - ), - ), - ], - options={ - "verbose_name": "Group Membership Policy", - "verbose_name_plural": "Group Membership Policies", - }, - bases=("passbook_policies.policy",), - ), - ] diff --git a/passbook/policies/group_membership/models.py b/passbook/policies/group_membership/models.py deleted file mode 100644 index c163aa68..00000000 --- a/passbook/policies/group_membership/models.py +++ /dev/null @@ -1,39 +0,0 @@ -"""user field matcher models""" -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext as _ -from rest_framework.serializers import BaseSerializer - -from passbook.core.models import Group -from passbook.policies.models import Policy -from passbook.policies.types import PolicyRequest, PolicyResult - - -class GroupMembershipPolicy(Policy): - """Check that the user is member of the selected group.""" - - group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.SET_NULL) - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.group_membership.api import ( - GroupMembershipPolicySerializer, - ) - - return GroupMembershipPolicySerializer - - @property - def form(self) -> Type[ModelForm]: - from passbook.policies.group_membership.forms import GroupMembershipPolicyForm - - return GroupMembershipPolicyForm - - def passes(self, request: PolicyRequest) -> PolicyResult: - return PolicyResult(self.group.users.filter(pk=request.user.pk).exists()) - - class Meta: - - verbose_name = _("Group Membership Policy") - verbose_name_plural = _("Group Membership Policies") diff --git a/passbook/policies/group_membership/tests.py b/passbook/policies/group_membership/tests.py deleted file mode 100644 index 1948942b..00000000 --- a/passbook/policies/group_membership/tests.py +++ /dev/null @@ -1,32 +0,0 @@ -"""evaluator tests""" -from django.test import TestCase -from guardian.shortcuts import get_anonymous_user - -from passbook.core.models import Group -from passbook.policies.group_membership.models import GroupMembershipPolicy -from passbook.policies.types import PolicyRequest - - -class TestGroupMembershipPolicy(TestCase): - """GroupMembershipPolicy tests""" - - def setUp(self): - self.request = PolicyRequest(user=get_anonymous_user()) - - def test_invalid(self): - """user not in group""" - group = Group.objects.create(name="test") - policy: GroupMembershipPolicy = GroupMembershipPolicy.objects.create( - group=group - ) - self.assertFalse(policy.passes(self.request).passing) - - def test_valid(self): - """user in group""" - group = Group.objects.create(name="test") - group.users.add(get_anonymous_user()) - group.save() - policy: GroupMembershipPolicy = GroupMembershipPolicy.objects.create( - group=group - ) - self.assertTrue(policy.passes(self.request).passing) diff --git a/passbook/policies/hibp/api.py b/passbook/policies/hibp/api.py deleted file mode 100644 index 03c5a736..00000000 --- a/passbook/policies/hibp/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Source API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS -from passbook.policies.hibp.models import HaveIBeenPwendPolicy - - -class HaveIBeenPwendPolicySerializer(ModelSerializer): - """Have I Been Pwned Policy Serializer""" - - class Meta: - model = HaveIBeenPwendPolicy - fields = GENERAL_SERIALIZER_FIELDS + ["password_field", "allowed_count"] - - -class HaveIBeenPwendPolicyViewSet(ModelViewSet): - """Source Viewset""" - - queryset = HaveIBeenPwendPolicy.objects.all() - serializer_class = HaveIBeenPwendPolicySerializer diff --git a/passbook/policies/hibp/apps.py b/passbook/policies/hibp/apps.py deleted file mode 100644 index 24a6169c..00000000 --- a/passbook/policies/hibp/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Passbook hibp app config""" - -from django.apps import AppConfig - - -class PassbookPolicyHIBPConfig(AppConfig): - """Passbook hibp app config""" - - name = "passbook.policies.hibp" - label = "passbook_policies_hibp" - verbose_name = "passbook Policies.HaveIBeenPwned" diff --git a/passbook/policies/hibp/forms.py b/passbook/policies/hibp/forms.py deleted file mode 100644 index 2091526a..00000000 --- a/passbook/policies/hibp/forms.py +++ /dev/null @@ -1,19 +0,0 @@ -"""passbook HaveIBeenPwned Policy forms""" - -from django import forms - -from passbook.policies.forms import GENERAL_FIELDS -from passbook.policies.hibp.models import HaveIBeenPwendPolicy - - -class HaveIBeenPwnedPolicyForm(forms.ModelForm): - """Edit HaveIBeenPwendPolicy instances""" - - class Meta: - - model = HaveIBeenPwendPolicy - fields = GENERAL_FIELDS + ["password_field", "allowed_count"] - widgets = { - "name": forms.TextInput(), - "password_field": forms.TextInput(), - } diff --git a/passbook/policies/hibp/migrations/0001_initial.py b/passbook/policies/hibp/migrations/0001_initial.py deleted file mode 100644 index 9df7de88..00000000 --- a/passbook/policies/hibp/migrations/0001_initial.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="HaveIBeenPwendPolicy", - fields=[ - ( - "policy_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.Policy", - ), - ), - ("allowed_count", models.IntegerField(default=0)), - ], - options={ - "verbose_name": "Have I Been Pwned Policy", - "verbose_name_plural": "Have I Been Pwned Policies", - }, - bases=("passbook_policies.policy",), - ), - ] diff --git a/passbook/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py b/passbook/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py deleted file mode 100644 index 8cf6d60f..00000000 --- a/passbook/policies/hibp/migrations/0002_haveibeenpwendpolicy_password_field.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-10 18:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_policies_hibp", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="haveibeenpwendpolicy", - name="password_field", - field=models.TextField( - default="password", - help_text="Field key to check, field keys defined in Prompt stages are available.", - ), - ), - ] diff --git a/passbook/policies/hibp/models.py b/passbook/policies/hibp/models.py deleted file mode 100644 index 56367a1d..00000000 --- a/passbook/policies/hibp/models.py +++ /dev/null @@ -1,74 +0,0 @@ -"""passbook HIBP Models""" -from hashlib import sha1 -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext as _ -from requests import get -from rest_framework.serializers import BaseSerializer -from structlog import get_logger - -from passbook.policies.models import Policy, PolicyResult -from passbook.policies.types import PolicyRequest - -LOGGER = get_logger() - - -class HaveIBeenPwendPolicy(Policy): - """Check if password is on HaveIBeenPwned's list by uploading the first - 5 characters of the SHA1 Hash.""" - - password_field = models.TextField( - default="password", - help_text=_( - "Field key to check, field keys defined in Prompt stages are available." - ), - ) - - allowed_count = models.IntegerField(default=0) - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.hibp.api import HaveIBeenPwendPolicySerializer - - return HaveIBeenPwendPolicySerializer - - @property - def form(self) -> Type[ModelForm]: - from passbook.policies.hibp.forms import HaveIBeenPwnedPolicyForm - - return HaveIBeenPwnedPolicyForm - - def passes(self, request: PolicyRequest) -> PolicyResult: - """Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5 - characters of Password in request and checks if full hash is in response. Returns 0 - if Password is not in result otherwise the count of how many times it was used.""" - if self.password_field not in request.context: - LOGGER.warning( - "Password field not set in Policy Request", - field=self.password_field, - fields=request.context.keys(), - ) - password = request.context[self.password_field] - - pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec - url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}" - result = get(url).text - final_count = 0 - for line in result.split("\r\n"): - full_hash, count = line.split(":") - if pw_hash[5:] == full_hash.lower(): - final_count = int(count) - LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) - if final_count > self.allowed_count: - message = _( - "Password exists on %(count)d online lists." % {"count": final_count} - ) - return PolicyResult(False, message) - return PolicyResult(True) - - class Meta: - - verbose_name = _("Have I Been Pwned Policy") - verbose_name_plural = _("Have I Been Pwned Policies") diff --git a/passbook/policies/hibp/tests.py b/passbook/policies/hibp/tests.py deleted file mode 100644 index 4254b7bb..00000000 --- a/passbook/policies/hibp/tests.py +++ /dev/null @@ -1,33 +0,0 @@ -"""HIBP Policy tests""" -from django.test import TestCase -from guardian.shortcuts import get_anonymous_user - -from passbook.policies.hibp.models import HaveIBeenPwendPolicy -from passbook.policies.types import PolicyRequest, PolicyResult -from passbook.providers.oauth2.generators import generate_client_secret - - -class TestHIBPPolicy(TestCase): - """Test HIBP Policy""" - - def test_false(self): - """Failing password case""" - policy = HaveIBeenPwendPolicy.objects.create( - name="test_false", - ) - request = PolicyRequest(get_anonymous_user()) - request.context["password"] = "password" - result: PolicyResult = policy.passes(request) - self.assertFalse(result.passing) - self.assertTrue(result.messages[0].startswith("Password exists on ")) - - def test_true(self): - """Positive password case""" - policy = HaveIBeenPwendPolicy.objects.create( - name="test_true", - ) - request = PolicyRequest(get_anonymous_user()) - request.context["password"] = generate_client_secret() - result: PolicyResult = policy.passes(request) - self.assertTrue(result.passing) - self.assertEqual(result.messages, tuple()) diff --git a/passbook/policies/http.py b/passbook/policies/http.py deleted file mode 100644 index d2f1b9c7..00000000 --- a/passbook/policies/http.py +++ /dev/null @@ -1,43 +0,0 @@ -"""policy http response""" -from typing import Any, Dict, Optional - -from django.http.request import HttpRequest -from django.template.response import TemplateResponse -from django.utils.translation import gettext as _ - -from passbook.core.models import PASSBOOK_USER_DEBUG -from passbook.policies.types import PolicyResult - - -class AccessDeniedResponse(TemplateResponse): - """Response used for access denied messages. Can optionally show an error message, - and if the user is a superuser or has user_debug enabled, shows a policy result.""" - - title: str - - error_message: Optional[str] = None - policy_result: Optional[PolicyResult] = None - - # pyright: reportGeneralTypeIssues=false - def __init__(self, request: HttpRequest, template="policies/denied.html") -> None: - super().__init__(request, template) - self.title = _("Access denied") - - def resolve_context( - self, context: Optional[Dict[str, Any]] - ) -> Optional[Dict[str, Any]]: - if not context: - context = {} - context["title"] = self.title - if self.error_message: - context["error"] = self.error_message - # Only show policy result if user is authenticated and - # either superuser or has PASSBOOK_USER_DEBUG set - if self.policy_result: - if self._request.user and self._request.user.is_authenticated: - if ( - self._request.user.is_superuser - or self._request.user.attributes.get(PASSBOOK_USER_DEBUG, False) - ): - context["policy_result"] = self.policy_result - return context diff --git a/passbook/policies/migrations/0001_initial.py b/passbook/policies/migrations/0001_initial.py deleted file mode 100644 index 05057705..00000000 --- a/passbook/policies/migrations/0001_initial.py +++ /dev/null @@ -1,103 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:07 - -import uuid - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="Policy", - fields=[ - ("created", models.DateTimeField(auto_now_add=True)), - ("last_updated", models.DateTimeField(auto_now=True)), - ( - "policy_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("name", models.TextField(blank=True, null=True)), - ("negate", models.BooleanField(default=False)), - ("order", models.IntegerField(default=0)), - ("timeout", models.IntegerField(default=30)), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="PolicyBinding", - fields=[ - ( - "policy_binding_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("enabled", models.BooleanField(default=True)), - ("order", models.IntegerField(default=0)), - ( - "policy", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="passbook_policies.Policy", - ), - ), - ], - options={ - "verbose_name": "Policy Binding", - "verbose_name_plural": "Policy Bindings", - }, - ), - migrations.CreateModel( - name="PolicyBindingModel", - fields=[ - ( - "pbm_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "policies", - models.ManyToManyField( - blank=True, - related_name="bindings", - through="passbook_policies.PolicyBinding", - to="passbook_policies.Policy", - ), - ), - ], - options={ - "verbose_name": "Policy Binding Model", - "verbose_name_plural": "Policy Binding Models", - }, - ), - migrations.AddField( - model_name="policybinding", - name="target", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="passbook_policies.PolicyBindingModel", - ), - ), - ] diff --git a/passbook/policies/migrations/0002_auto_20200528_1647.py b/passbook/policies/migrations/0002_auto_20200528_1647.py deleted file mode 100644 index 2d0f5479..00000000 --- a/passbook/policies/migrations/0002_auto_20200528_1647.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-28 16:47 - -import django.db.models.deletion -from django.db import migrations, models - -import passbook.lib.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.AlterModelOptions( - name="policy", - options={ - "base_manager_name": "objects", - "verbose_name": "Policy", - "verbose_name_plural": "Policies", - }, - ), - migrations.RemoveField( - model_name="policy", - name="negate", - ), - migrations.RemoveField( - model_name="policy", - name="order", - ), - migrations.RemoveField( - model_name="policy", - name="timeout", - ), - migrations.AddField( - model_name="policybinding", - name="negate", - field=models.BooleanField( - default=False, - help_text="Negates the outcome of the policy. Messages are unaffected.", - ), - ), - migrations.AddField( - model_name="policybinding", - name="timeout", - field=models.IntegerField( - default=30, - help_text="Timeout after which Policy execution is terminated.", - ), - ), - migrations.AlterField( - model_name="policybinding", - name="order", - field=models.IntegerField(), - ), - migrations.AlterField( - model_name="policybinding", - name="policy", - field=passbook.lib.models.InheritanceForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="passbook_policies.Policy", - ), - ), - migrations.AlterUniqueTogether( - name="policybinding", - unique_together={("policy", "target", "order")}, - ), - ] diff --git a/passbook/policies/migrations/0003_auto_20200908_1542.py b/passbook/policies/migrations/0003_auto_20200908_1542.py deleted file mode 100644 index ce5cd2ad..00000000 --- a/passbook/policies/migrations/0003_auto_20200908_1542.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-08 15:42 - -import django.db.models.deletion -from django.db import migrations - -import passbook.lib.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_policies", "0002_auto_20200528_1647"), - ] - - operations = [ - migrations.AlterField( - model_name="policybinding", - name="target", - field=passbook.lib.models.InheritanceForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="+", - to="passbook_policies.policybindingmodel", - ), - ), - ] diff --git a/passbook/policies/models.py b/passbook/policies/models.py deleted file mode 100644 index b9292474..00000000 --- a/passbook/policies/models.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Policy base models""" -from typing import Type -from uuid import uuid4 - -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext_lazy as _ -from model_utils.managers import InheritanceManager -from rest_framework.serializers import BaseSerializer - -from passbook.lib.models import ( - CreatedUpdatedModel, - InheritanceAutoManager, - InheritanceForeignKey, - SerializerModel, -) -from passbook.policies.exceptions import PolicyException -from passbook.policies.types import PolicyRequest, PolicyResult - - -class PolicyBindingModel(models.Model): - """Base Model for objects that have policies applied to them.""" - - pbm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - policies = models.ManyToManyField( - "Policy", through="PolicyBinding", related_name="bindings", blank=True - ) - - objects = InheritanceManager() - - class Meta: - verbose_name = _("Policy Binding Model") - verbose_name_plural = _("Policy Binding Models") - - -class PolicyBinding(SerializerModel): - """Relationship between a Policy and a PolicyBindingModel.""" - - policy_binding_uuid = models.UUIDField( - primary_key=True, editable=False, default=uuid4 - ) - - enabled = models.BooleanField(default=True) - - policy = InheritanceForeignKey("Policy", on_delete=models.CASCADE, related_name="+") - target = InheritanceForeignKey( - PolicyBindingModel, on_delete=models.CASCADE, related_name="+" - ) - negate = models.BooleanField( - default=False, - help_text=_("Negates the outcome of the policy. Messages are unaffected."), - ) - timeout = models.IntegerField( - default=30, help_text=_("Timeout after which Policy execution is terminated.") - ) - - order = models.IntegerField() - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.api import PolicyBindingSerializer - - return PolicyBindingSerializer - - def __str__(self) -> str: - return f"Policy Binding {self.target} #{self.order} {self.policy}" - - class Meta: - - verbose_name = _("Policy Binding") - verbose_name_plural = _("Policy Bindings") - unique_together = ("policy", "target", "order") - - -class Policy(SerializerModel, CreatedUpdatedModel): - """Policies which specify if a user is authorized to use an Application. Can be overridden by - other types to add other fields, more logic, etc.""" - - policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - name = models.TextField(blank=True, null=True) - - objects = InheritanceAutoManager() - - @property - def form(self) -> Type[ModelForm]: - """Return Form class used to edit this object""" - raise NotImplementedError - - def __str__(self): - return f"{self.__class__.__name__} {self.name}" - - def passes(self, request: PolicyRequest) -> PolicyResult: # pragma: no cover - """Check if user instance passes this policy""" - raise PolicyException() - - class Meta: - base_manager_name = "objects" - - verbose_name = _("Policy") - verbose_name_plural = _("Policies") diff --git a/passbook/policies/password/api.py b/passbook/policies/password/api.py deleted file mode 100644 index c95ef2ff..00000000 --- a/passbook/policies/password/api.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Password Policy API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS -from passbook.policies.password.models import PasswordPolicy - - -class PasswordPolicySerializer(ModelSerializer): - """Password Policy Serializer""" - - class Meta: - model = PasswordPolicy - fields = GENERAL_SERIALIZER_FIELDS + [ - "password_field", - "amount_uppercase", - "amount_lowercase", - "amount_symbols", - "length_min", - "symbol_charset", - "error_message", - ] - - -class PasswordPolicyViewSet(ModelViewSet): - """Password Policy Viewset""" - - queryset = PasswordPolicy.objects.all() - serializer_class = PasswordPolicySerializer diff --git a/passbook/policies/password/apps.py b/passbook/policies/password/apps.py deleted file mode 100644 index 6bd6bcb3..00000000 --- a/passbook/policies/password/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook Password policy app config""" - -from django.apps import AppConfig - - -class PassbookPoliciesPasswordConfig(AppConfig): - """passbook Password policy app config""" - - name = "passbook.policies.password" - label = "passbook_policies_password" - verbose_name = "passbook Policies.Password" diff --git a/passbook/policies/password/forms.py b/passbook/policies/password/forms.py deleted file mode 100644 index 3b9e7695..00000000 --- a/passbook/policies/password/forms.py +++ /dev/null @@ -1,36 +0,0 @@ -"""passbook Policy forms""" - -from django import forms -from django.utils.translation import gettext as _ - -from passbook.policies.forms import GENERAL_FIELDS -from passbook.policies.password.models import PasswordPolicy - - -class PasswordPolicyForm(forms.ModelForm): - """PasswordPolicy Form""" - - class Meta: - - model = PasswordPolicy - fields = GENERAL_FIELDS + [ - "password_field", - "amount_uppercase", - "amount_lowercase", - "amount_symbols", - "length_min", - "symbol_charset", - "error_message", - ] - widgets = { - "name": forms.TextInput(), - "password_field": forms.TextInput(), - "symbol_charset": forms.TextInput(), - "error_message": forms.TextInput(), - } - labels = { - "amount_uppercase": _("Minimum amount of Uppercase Characters"), - "amount_lowercase": _("Minimum amount of Lowercase Characters"), - "amount_symbols": _("Minimum amount of Symbols Characters"), - "length_min": _("Minimum Length"), - } diff --git a/passbook/policies/password/migrations/0001_initial.py b/passbook/policies/password/migrations/0001_initial.py deleted file mode 100644 index ab9756aa..00000000 --- a/passbook/policies/password/migrations/0001_initial.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PasswordPolicy", - fields=[ - ( - "policy_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.Policy", - ), - ), - ("amount_uppercase", models.IntegerField(default=0)), - ("amount_lowercase", models.IntegerField(default=0)), - ("amount_symbols", models.IntegerField(default=0)), - ("length_min", models.IntegerField(default=0)), - ( - "symbol_charset", - models.TextField(default="!\\\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ "), - ), - ("error_message", models.TextField()), - ], - options={ - "verbose_name": "Password Policy", - "verbose_name_plural": "Password Policies", - }, - bases=("passbook_policies.policy",), - ), - ] diff --git a/passbook/policies/password/migrations/0002_passwordpolicy_password_field.py b/passbook/policies/password/migrations/0002_passwordpolicy_password_field.py deleted file mode 100644 index 84d5fe06..00000000 --- a/passbook/policies/password/migrations/0002_passwordpolicy_password_field.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-10 18:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_policies_password", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="passwordpolicy", - name="password_field", - field=models.TextField( - default="password", - help_text="Field key to check, field keys defined in Prompt stages are available.", - ), - ), - ] diff --git a/passbook/policies/password/models.py b/passbook/policies/password/models.py deleted file mode 100644 index f2bc29b9..00000000 --- a/passbook/policies/password/models.py +++ /dev/null @@ -1,77 +0,0 @@ -"""user field matcher models""" -import re -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext as _ -from rest_framework.serializers import BaseSerializer -from structlog import get_logger - -from passbook.policies.models import Policy -from passbook.policies.types import PolicyRequest, PolicyResult - -LOGGER = get_logger() - - -class PasswordPolicy(Policy): - """Policy to make sure passwords have certain properties""" - - password_field = models.TextField( - default="password", - help_text=_( - "Field key to check, field keys defined in Prompt stages are available." - ), - ) - - amount_uppercase = models.IntegerField(default=0) - amount_lowercase = models.IntegerField(default=0) - amount_symbols = models.IntegerField(default=0) - length_min = models.IntegerField(default=0) - symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ") - error_message = models.TextField() - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.password.api import PasswordPolicySerializer - - return PasswordPolicySerializer - - @property - def form(self) -> Type[ModelForm]: - from passbook.policies.password.forms import PasswordPolicyForm - - return PasswordPolicyForm - - def passes(self, request: PolicyRequest) -> PolicyResult: - if self.password_field not in request.context: - LOGGER.warning( - "Password field not set in Policy Request", - field=self.password_field, - fields=request.context.keys(), - ) - password = request.context[self.password_field] - - filter_regex = [] - if self.amount_lowercase > 0: - filter_regex.append(r"[a-z]{%d,}" % self.amount_lowercase) - if self.amount_uppercase > 0: - filter_regex.append(r"[A-Z]{%d,}" % self.amount_uppercase) - if self.amount_symbols > 0: - filter_regex.append( - r"[%s]{%d,}" % (self.symbol_charset, self.amount_symbols) - ) - full_regex = "|".join(filter_regex) - LOGGER.debug("Built regex", regexp=full_regex) - result = bool(re.compile(full_regex).match(password)) - - result = result and len(password) >= self.length_min - - if not result: - return PolicyResult(result, self.error_message) - return PolicyResult(result) - - class Meta: - - verbose_name = _("Password Policy") - verbose_name_plural = _("Password Policies") diff --git a/passbook/policies/password/tests.py b/passbook/policies/password/tests.py deleted file mode 100644 index 51875ff6..00000000 --- a/passbook/policies/password/tests.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Password Policy tests""" -from django.test import TestCase -from guardian.shortcuts import get_anonymous_user - -from passbook.policies.password.models import PasswordPolicy -from passbook.policies.types import PolicyRequest, PolicyResult - - -class TestPasswordPolicy(TestCase): - """Test Password Policy""" - - def test_false(self): - """Failing password case""" - policy = PasswordPolicy.objects.create( - name="test_false", - amount_uppercase=1, - amount_lowercase=2, - amount_symbols=3, - length_min=24, - error_message="test message", - ) - request = PolicyRequest(get_anonymous_user()) - request.context["password"] = "test" - result: PolicyResult = policy.passes(request) - self.assertFalse(result.passing) - self.assertEqual(result.messages, ("test message",)) - - def test_true(self): - """Positive password case""" - policy = PasswordPolicy.objects.create( - name="test_true", - amount_uppercase=1, - amount_lowercase=2, - amount_symbols=3, - length_min=3, - error_message="test message", - ) - request = PolicyRequest(get_anonymous_user()) - request.context["password"] = "Test()!" - result: PolicyResult = policy.passes(request) - self.assertTrue(result.passing) - self.assertEqual(result.messages, tuple()) diff --git a/passbook/policies/process.py b/passbook/policies/process.py deleted file mode 100644 index f252f3f4..00000000 --- a/passbook/policies/process.py +++ /dev/null @@ -1,87 +0,0 @@ -"""passbook policy task""" -from multiprocessing import Process -from multiprocessing.connection import Connection -from typing import Optional - -from django.core.cache import cache -from sentry_sdk.hub import Hub -from sentry_sdk.tracing import Span -from structlog import get_logger - -from passbook.policies.exceptions import PolicyException -from passbook.policies.models import PolicyBinding -from passbook.policies.types import PolicyRequest, PolicyResult - -LOGGER = get_logger() - - -def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: - """Generate Cache key for policy""" - prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}" - if request.http_request: - prefix += f"_{request.http_request.session.session_key}" - if request.user: - prefix += f"#{request.user.pk}" - return prefix - - -class PolicyProcess(Process): - """Evaluate a single policy within a seprate process""" - - connection: Connection - binding: PolicyBinding - request: PolicyRequest - - def __init__( - self, - binding: PolicyBinding, - request: PolicyRequest, - connection: Optional[Connection], - ): - super().__init__() - self.binding = binding - self.request = request - if not isinstance(self.request, PolicyRequest): - raise ValueError(f"{self.request} is not a Policy Request.") - if connection: - self.connection = connection - - def execute(self) -> PolicyResult: - """Run actual policy, returns result""" - with Hub.current.start_span( - op="policy.process.execute", - ) as span: - span: Span - span.set_data("policy", self.binding.policy) - span.set_data("request", self.request) - LOGGER.debug( - "P_ENG(proc): Running policy", - policy=self.binding.policy, - user=self.request.user, - process="PolicyProcess", - ) - try: - policy_result = self.binding.policy.passes(self.request) - except PolicyException as exc: - LOGGER.debug("P_ENG(proc): error", exc=exc) - policy_result = PolicyResult(False, str(exc)) - policy_result.source_policy = self.binding.policy - # Invert result if policy.negate is set - if self.binding.negate: - policy_result.passing = not policy_result.passing - LOGGER.debug( - "P_ENG(proc): Finished", - policy=self.binding.policy, - result=policy_result, - process="PolicyProcess", - passing=policy_result.passing, - user=self.request.user, - ) - key = cache_key(self.binding, self.request) - cache.set(key, policy_result) - LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key) - return policy_result - - def run(self): - """Task wrapper to run policy checking""" - self.connection.send(self.execute()) diff --git a/passbook/policies/reputation/api.py b/passbook/policies/reputation/api.py deleted file mode 100644 index a0a06885..00000000 --- a/passbook/policies/reputation/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Source API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS -from passbook.policies.reputation.models import ReputationPolicy - - -class ReputationPolicySerializer(ModelSerializer): - """Reputation Policy Serializer""" - - class Meta: - model = ReputationPolicy - fields = GENERAL_SERIALIZER_FIELDS + ["check_ip", "check_username", "threshold"] - - -class ReputationPolicyViewSet(ModelViewSet): - """Source Viewset""" - - queryset = ReputationPolicy.objects.all() - serializer_class = ReputationPolicySerializer diff --git a/passbook/policies/reputation/apps.py b/passbook/policies/reputation/apps.py deleted file mode 100644 index 712a0bbd..00000000 --- a/passbook/policies/reputation/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Passbook reputation_policy app config""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookPolicyReputationConfig(AppConfig): - """Passbook reputation app config""" - - name = "passbook.policies.reputation" - label = "passbook_policies_reputation" - verbose_name = "passbook Policies.Reputation" - - def ready(self): - import_module("passbook.policies.reputation.signals") diff --git a/passbook/policies/reputation/forms.py b/passbook/policies/reputation/forms.py deleted file mode 100644 index 0c4af1b3..00000000 --- a/passbook/policies/reputation/forms.py +++ /dev/null @@ -1,22 +0,0 @@ -"""passbook reputation request forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.policies.forms import GENERAL_FIELDS -from passbook.policies.reputation.models import ReputationPolicy - - -class ReputationPolicyForm(forms.ModelForm): - """Form to edit ReputationPolicy""" - - class Meta: - - model = ReputationPolicy - fields = GENERAL_FIELDS + ["check_ip", "check_username", "threshold"] - widgets = { - "name": forms.TextInput(), - "value": forms.TextInput(), - } - labels = { - "check_ip": _("Check IP"), - } diff --git a/passbook/policies/reputation/migrations/0001_initial.py b/passbook/policies/reputation/migrations/0001_initial.py deleted file mode 100644 index 52c2e158..00000000 --- a/passbook/policies/reputation/migrations/0001_initial.py +++ /dev/null @@ -1,82 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -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), - ("passbook_policies", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="IPReputation", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("ip", models.GenericIPAddressField(unique=True)), - ("score", models.IntegerField(default=0)), - ("updated", models.DateTimeField(auto_now=True)), - ], - ), - migrations.CreateModel( - name="ReputationPolicy", - fields=[ - ( - "policy_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_policies.Policy", - ), - ), - ("check_ip", models.BooleanField(default=True)), - ("check_username", models.BooleanField(default=True)), - ("threshold", models.IntegerField(default=-5)), - ], - options={ - "verbose_name": "Reputation Policy", - "verbose_name_plural": "Reputation Policies", - }, - bases=("passbook_policies.policy",), - ), - migrations.CreateModel( - name="UserReputation", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("score", models.IntegerField(default=0)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/passbook/policies/reputation/models.py b/passbook/policies/reputation/models.py deleted file mode 100644 index 37e7ecba..00000000 --- a/passbook/policies/reputation/models.py +++ /dev/null @@ -1,74 +0,0 @@ -"""passbook reputation request policy""" -from typing import Type - -from django.core.cache import cache -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext as _ -from rest_framework.serializers import BaseSerializer - -from passbook.core.models import User -from passbook.lib.utils.http import get_client_ip -from passbook.policies.models import Policy -from passbook.policies.types import PolicyRequest, PolicyResult - -CACHE_KEY_IP_PREFIX = "passbook_reputation_ip_" -CACHE_KEY_USER_PREFIX = "passbook_reputation_user_" - - -class ReputationPolicy(Policy): - """Return true if request IP/target username's score is below a certain threshold""" - - check_ip = models.BooleanField(default=True) - check_username = models.BooleanField(default=True) - threshold = models.IntegerField(default=-5) - - @property - def serializer(self) -> BaseSerializer: - from passbook.policies.reputation.api import ReputationPolicySerializer - - return ReputationPolicySerializer - - @property - def form(self) -> Type[ModelForm]: - from passbook.policies.reputation.forms import ReputationPolicyForm - - return ReputationPolicyForm - - def passes(self, request: PolicyRequest) -> PolicyResult: - remote_ip = get_client_ip(request.http_request) or "255.255.255.255" - passing = True - if self.check_ip: - score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) - passing = passing and score <= self.threshold - if self.check_username: - score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) - passing = passing and score <= self.threshold - return PolicyResult(passing) - - class Meta: - - verbose_name = _("Reputation Policy") - verbose_name_plural = _("Reputation Policies") - - -class IPReputation(models.Model): - """Store score coming from the same IP""" - - ip = models.GenericIPAddressField(unique=True) - score = models.IntegerField(default=0) - updated = models.DateTimeField(auto_now=True) - - def __str__(self): - return f"IPReputation for {self.ip} @ {self.score}" - - -class UserReputation(models.Model): - """Store score attempting to log in as the same username""" - - user = models.OneToOneField(User, on_delete=models.CASCADE) - score = models.IntegerField(default=0) - updated = models.DateTimeField(auto_now=True) - - def __str__(self): - return f"UserReputation for {self.user} @ {self.score}" diff --git a/passbook/policies/reputation/settings.py b/passbook/policies/reputation/settings.py deleted file mode 100644 index aae7eac1..00000000 --- a/passbook/policies/reputation/settings.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Reputation Settings""" -from celery.schedules import crontab - -CELERY_BEAT_SCHEDULE = { - "policies_reputation_ip_save": { - "task": "passbook.policies.reputation.tasks.save_ip_reputation", - "schedule": crontab(minute="*/5"), - "options": {"queue": "passbook_scheduled"}, - }, - "policies_reputation_user_save": { - "task": "passbook.policies.reputation.tasks.save_user_reputation", - "schedule": crontab(minute="*/5"), - "options": {"queue": "passbook_scheduled"}, - }, -} diff --git a/passbook/policies/reputation/signals.py b/passbook/policies/reputation/signals.py deleted file mode 100644 index 08627af8..00000000 --- a/passbook/policies/reputation/signals.py +++ /dev/null @@ -1,43 +0,0 @@ -"""passbook reputation request signals""" -from django.contrib.auth.signals import user_logged_in, user_login_failed -from django.core.cache import cache -from django.dispatch import receiver -from django.http import HttpRequest -from structlog import get_logger - -from passbook.lib.utils.http import get_client_ip -from passbook.policies.reputation.models import ( - CACHE_KEY_IP_PREFIX, - CACHE_KEY_USER_PREFIX, -) - -LOGGER = get_logger() - - -def update_score(request: HttpRequest, username: str, amount: int): - """Update score for IP and User""" - remote_ip = get_client_ip(request) or "255.255.255.255" - - # We only update the cache here, as its faster than writing to the DB - cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) - cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) - - cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0) - cache.incr(CACHE_KEY_USER_PREFIX + username, amount) - - LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) - - -@receiver(user_login_failed) -# pylint: disable=unused-argument -def handle_failed_login(sender, request, credentials, **_): - """Lower Score for failed loging attempts""" - if "username" in credentials: - update_score(request, credentials.get("username"), -1) - - -@receiver(user_logged_in) -# pylint: disable=unused-argument -def handle_successful_login(sender, request, user, **_): - """Raise score for successful attempts""" - update_score(request, user.username, 1) diff --git a/passbook/policies/reputation/tasks.py b/passbook/policies/reputation/tasks.py deleted file mode 100644 index 8b9663ab..00000000 --- a/passbook/policies/reputation/tasks.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Reputation tasks""" -from django.core.cache import cache -from structlog import get_logger - -from passbook.core.models import User -from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus -from passbook.policies.reputation.models import IPReputation, UserReputation -from passbook.policies.reputation.signals import ( - CACHE_KEY_IP_PREFIX, - CACHE_KEY_USER_PREFIX, -) -from passbook.root.celery import CELERY_APP - -LOGGER = get_logger() - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def save_ip_reputation(self: MonitoredTask): - """Save currently cached reputation to database""" - objects_to_update = [] - for key, score in cache.get_many(cache.keys(CACHE_KEY_IP_PREFIX + "*")).items(): - remote_ip = key.replace(CACHE_KEY_IP_PREFIX, "") - rep, _ = IPReputation.objects.get_or_create(ip=remote_ip) - rep.score = score - objects_to_update.append(rep) - IPReputation.objects.bulk_update(objects_to_update, ["score"]) - self.set_status( - TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated IP Reputation"]) - ) - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def save_user_reputation(self: MonitoredTask): - """Save currently cached reputation to database""" - objects_to_update = [] - for key, score in cache.get_many(cache.keys(CACHE_KEY_USER_PREFIX + "*")).items(): - username = key.replace(CACHE_KEY_USER_PREFIX, "") - users = User.objects.filter(username=username) - if not users.exists(): - LOGGER.info("User in cache does not exist, ignoring", username=username) - continue - rep, _ = UserReputation.objects.get_or_create(user=users.first()) - rep.score = score - objects_to_update.append(rep) - UserReputation.objects.bulk_update(objects_to_update, ["score"]) - self.set_status( - TaskResult( - TaskResultStatus.SUCCESSFUL, ["Successfully updated User Reputation"] - ) - ) diff --git a/passbook/policies/reputation/tests.py b/passbook/policies/reputation/tests.py deleted file mode 100644 index a5a679c8..00000000 --- a/passbook/policies/reputation/tests.py +++ /dev/null @@ -1,55 +0,0 @@ -"""test reputation signals and policy""" -from django.contrib.auth import authenticate -from django.core.cache import cache -from django.test import TestCase - -from passbook.core.models import User -from passbook.policies.reputation.models import ( - CACHE_KEY_IP_PREFIX, - CACHE_KEY_USER_PREFIX, - IPReputation, - ReputationPolicy, - UserReputation, -) -from passbook.policies.reputation.tasks import save_ip_reputation, save_user_reputation -from passbook.policies.types import PolicyRequest - - -class TestReputationPolicy(TestCase): - """test reputation signals and policy""" - - def setUp(self): - self.test_ip = "255.255.255.255" - self.test_username = "test" - cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip) - cache.delete(CACHE_KEY_USER_PREFIX + self.test_username) - # We need a user for the one-to-one in userreputation - self.user = User.objects.create(username=self.test_username) - - def test_ip_reputation(self): - """test IP reputation""" - # Trigger negative reputation - authenticate(None, username=self.test_username, password=self.test_username) - # Test value in cache - self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1) - # Save cache and check db values - save_ip_reputation.delay().get() - self.assertEqual(IPReputation.objects.get(ip=self.test_ip).score, -1) - - def test_user_reputation(self): - """test User reputation""" - # Trigger negative reputation - authenticate(None, username=self.test_username, password=self.test_username) - # Test value in cache - self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1) - # Save cache and check db values - save_user_reputation.delay().get() - self.assertEqual(UserReputation.objects.get(user=self.user).score, -1) - - def test_policy(self): - """Test Policy""" - request = PolicyRequest(user=self.user) - policy: ReputationPolicy = ReputationPolicy.objects.create( - name="reputation-test", threshold=0 - ) - self.assertTrue(policy.passes(request).passing) diff --git a/passbook/policies/signals.py b/passbook/policies/signals.py deleted file mode 100644 index 92489187..00000000 --- a/passbook/policies/signals.py +++ /dev/null @@ -1,25 +0,0 @@ -"""passbook policy signals""" -from django.core.cache import cache -from django.db.models.signals import post_save -from django.dispatch import receiver -from structlog import get_logger - -LOGGER = get_logger() - - -@receiver(post_save) -# pylint: disable=unused-argument -def invalidate_policy_cache(sender, instance, **_): - """Invalidate Policy cache when policy is updated""" - from passbook.policies.models import Policy, PolicyBinding - - if isinstance(instance, Policy): - total = 0 - for binding in PolicyBinding.objects.filter(policy=instance): - prefix = ( - f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*" - ) - keys = cache.keys(prefix) - total += len(keys) - cache.delete_many(keys) - LOGGER.debug("Invalidating policy cache", policy=instance, keys=total) diff --git a/passbook/policies/templates/policies/denied.html b/passbook/policies/templates/policies/denied.html deleted file mode 100644 index 9200dc4d..00000000 --- a/passbook/policies/templates/policies/denied.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'login/base_full.html' %} - -{% load static %} -{% load i18n %} -{% load passbook_utils %} - -{% block card_title %} -{% trans 'Permission denied' %} -{% endblock %} - -{% block title %} -{% trans 'Permission denied' %} -{% endblock %} - -{% block card %} -
- {% csrf_token %} - {% include 'partials/form.html' %} -
-

- - {% trans 'Request has been denied.' %} -

- {% if error %} -
-

- {{ error }} -

- {% endif %} - {% if policy_result %} -
- - {% trans 'Explanation:' %} - -
    - {% for source_result in policy_result.source_results %} -
  • - {% blocktrans with name=source_result.source_policy.name result=source_result.passing %} - Policy '{{ name }}' returned result '{{ result }}' - {% endblocktrans %} - {% if source_result.messages %} -
      - {% for message in source_result.messages %} -
    • {{ message }}
    • - {% endfor %} -
    - {% endif %} -
  • - {% endfor %} -
- {% endif %} -
- {% if 'back' in request.GET %} - {% trans 'Back' %} - {% endif %} -
-{% endblock %} diff --git a/passbook/policies/tests/test_engine.py b/passbook/policies/tests/test_engine.py deleted file mode 100644 index d7aee94d..00000000 --- a/passbook/policies/tests/test_engine.py +++ /dev/null @@ -1,84 +0,0 @@ -"""policy engine tests""" -from django.core.cache import cache -from django.test import TestCase - -from passbook.core.models import User -from passbook.policies.dummy.models import DummyPolicy -from passbook.policies.engine import PolicyEngine -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel - - -class TestPolicyEngine(TestCase): - """PolicyEngine tests""" - - def setUp(self): - cache.clear() - self.user = User.objects.create_user(username="policyuser") - self.policy_false = DummyPolicy.objects.create( - result=False, wait_min=0, wait_max=1 - ) - self.policy_true = DummyPolicy.objects.create( - result=True, wait_min=0, wait_max=1 - ) - self.policy_wrong_type = Policy.objects.create(name="wrong_type") - self.policy_raises = ExpressionPolicy.objects.create( - name="raises", expression="{{ 0/0 }}" - ) - - def test_engine_empty(self): - """Ensure empty policy list passes""" - pbm = PolicyBindingModel.objects.create() - engine = PolicyEngine(pbm, self.user) - result = engine.build().result - self.assertEqual(result.passing, True) - self.assertEqual(result.messages, ()) - - def test_engine(self): - """Ensure all policies passes (Mix of false and true -> false)""" - pbm = PolicyBindingModel.objects.create() - PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) - PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=1) - engine = PolicyEngine(pbm, self.user) - result = engine.build().result - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("dummy",)) - - def test_engine_negate(self): - """Test negate flag""" - pbm = PolicyBindingModel.objects.create() - PolicyBinding.objects.create( - target=pbm, policy=self.policy_true, negate=True, order=0 - ) - engine = PolicyEngine(pbm, self.user) - result = engine.build().result - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("dummy",)) - - def test_engine_policy_error(self): - """Test policy raising an error flag""" - pbm = PolicyBindingModel.objects.create() - PolicyBinding.objects.create(target=pbm, policy=self.policy_raises, order=0) - engine = PolicyEngine(pbm, self.user) - result = engine.build().result - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("division by zero",)) - - def test_engine_policy_type(self): - """Test invalid policy type""" - pbm = PolicyBindingModel.objects.create() - PolicyBinding.objects.create(target=pbm, policy=self.policy_wrong_type, order=0) - with self.assertRaises(TypeError): - engine = PolicyEngine(pbm, self.user) - engine.build() - - def test_engine_cache(self): - """Ensure empty policy list passes""" - pbm = PolicyBindingModel.objects.create() - PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) - engine = PolicyEngine(pbm, self.user) - self.assertEqual(len(cache.keys("policy_*")), 0) - self.assertEqual(engine.build().passing, False) - self.assertEqual(len(cache.keys("policy_*")), 1) - self.assertEqual(engine.build().passing, False) - self.assertEqual(len(cache.keys("policy_*")), 1) diff --git a/passbook/policies/tests/test_models.py b/passbook/policies/tests/test_models.py deleted file mode 100644 index 1a1e6742..00000000 --- a/passbook/policies/tests/test_models.py +++ /dev/null @@ -1,30 +0,0 @@ -"""flow model tests""" -from typing import Callable, Type - -from django.forms import ModelForm -from django.test import TestCase - -from passbook.lib.utils.reflection import all_subclasses -from passbook.policies.models import Policy - - -class TestPolicyProperties(TestCase): - """Generic model properties tests""" - - -def policy_tester_factory(model: Type[Policy]) -> Callable: - """Test a form""" - - def tester(self: TestPolicyProperties): - model_inst = model() - self.assertTrue(issubclass(model_inst.form, ModelForm)) - - return tester - - -for policy_type in all_subclasses(Policy): - setattr( - TestPolicyProperties, - f"test_policy_{policy_type.__name__}", - policy_tester_factory(policy_type), - ) diff --git a/passbook/policies/types.py b/passbook/policies/types.py deleted file mode 100644 index 0bbcadbb..00000000 --- a/passbook/policies/types.py +++ /dev/null @@ -1,53 +0,0 @@ -"""policy structures""" -from __future__ import annotations - -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple - -from django.db.models import Model -from django.http import HttpRequest - -if TYPE_CHECKING: - from passbook.core.models import User - from passbook.policies.models import Policy - - -class PolicyRequest: - """Data-class to hold policy request data""" - - user: User - http_request: Optional[HttpRequest] - obj: Optional[Model] - context: Dict[str, str] - - def __init__(self, user: User): - self.user = user - self.http_request = None - self.obj = None - self.context = {} - - def __str__(self): - return f"" - - -class PolicyResult: - """Small data-class to hold policy results""" - - passing: bool - messages: Tuple[str, ...] - - source_policy: Optional[Policy] - source_results: Optional[List["PolicyResult"]] - - def __init__(self, passing: bool, *messages: str): - self.passing = passing - self.messages = messages - self.source_policy = None - self.source_results = [] - - def __repr__(self): - return self.__str__() - - def __str__(self): - if self.messages: - return f"PolicyResult passing={self.passing} messages={self.messages}" - return f"PolicyResult passing={self.passing}" diff --git a/passbook/policies/views.py b/passbook/policies/views.py deleted file mode 100644 index a3f8480b..00000000 --- a/passbook/policies/views.py +++ /dev/null @@ -1,93 +0,0 @@ -"""passbook access helper classes""" -from typing import Any, Optional - -from django.contrib import messages -from django.contrib.auth.mixins import AccessMixin -from django.contrib.auth.views import redirect_to_login -from django.http import HttpRequest, HttpResponse -from django.utils.translation import gettext as _ -from django.views.generic.base import View -from structlog import get_logger - -from passbook.core.models import Application, Provider, User -from passbook.flows.views import SESSION_KEY_APPLICATION_PRE -from passbook.policies.engine import PolicyEngine -from passbook.policies.http import AccessDeniedResponse -from passbook.policies.types import PolicyResult - -LOGGER = get_logger() - - -class BaseMixin: - """Base Mixin class, used to annotate View Member variables""" - - request: HttpRequest - - -class PolicyAccessView(AccessMixin, View): - """Mixin class for usage in Authorization views. - Provider functions to check application access, etc""" - - provider: Provider - application: Application - - def resolve_provider_application(self): - """Resolve self.provider and self.application. *.DoesNotExist Exceptions cause a normal - AccessDenied view to be shown. An Http404 exception - is not caught, and will return directly""" - raise NotImplementedError - - def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - try: - self.resolve_provider_application() - except (Application.DoesNotExist, Provider.DoesNotExist): - return self.handle_no_permission_authenticated() - # Check if user is unauthenticated, so we pass the application - # for the identification stage - if not request.user.is_authenticated: - return self.handle_no_permission() - # Check permissions - result = self.user_has_access() - if not result.passing: - return self.handle_no_permission_authenticated(result) - return super().dispatch(request, *args, **kwargs) - - def handle_no_permission(self) -> HttpResponse: - """User has no access and is not authenticated, so we remember the application - they try to access and redirect to the login URL. The application is saved to show - a hint on the Identification Stage what the user should login for.""" - if self.application: - self.request.session[SESSION_KEY_APPLICATION_PRE] = self.application - return redirect_to_login( - self.request.get_full_path(), - self.get_login_url(), - self.get_redirect_field_name(), - ) - - def handle_no_permission_authenticated( - self, result: Optional[PolicyResult] = None - ) -> HttpResponse: - """Function called when user has no permissions but is authenticated""" - response = AccessDeniedResponse(self.request) - if result: - response.policy_result = result - return response - - def user_has_access(self, user: Optional[User] = None) -> PolicyResult: - """Check if user has access to application.""" - user = user or self.request.user - policy_engine = PolicyEngine( - self.application, user or self.request.user, self.request - ) - policy_engine.build() - result = policy_engine.result - LOGGER.debug( - "AccessMixin user_has_access", - user=user, - app=self.application, - result=result, - ) - if not result.passing: - for message in result.messages: - messages.error(self.request, _(message)) - return result diff --git a/passbook/providers/oauth2/api.py b/passbook/providers/oauth2/api.py deleted file mode 100644 index fc0d7586..00000000 --- a/passbook/providers/oauth2/api.py +++ /dev/null @@ -1,51 +0,0 @@ -"""OAuth2Provider API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.providers.oauth2.models import OAuth2Provider, ScopeMapping - - -class OAuth2ProviderSerializer(ModelSerializer): - """OAuth2Provider Serializer""" - - class Meta: - - model = OAuth2Provider - fields = [ - "pk", - "name", - "authorization_flow", - "client_type", - "client_id", - "client_secret", - "token_validity", - "response_type", - "jwt_alg", - "rsa_key", - "redirect_uris", - "sub_mode", - "property_mappings", - ] - - -class OAuth2ProviderViewSet(ModelViewSet): - """OAuth2Provider Viewset""" - - queryset = OAuth2Provider.objects.all() - serializer_class = OAuth2ProviderSerializer - - -class ScopeMappingSerializer(ModelSerializer): - """ScopeMapping Serializer""" - - class Meta: - - model = ScopeMapping - fields = ["pk", "name", "scope_name", "description", "expression"] - - -class ScopeMappingViewSet(ModelViewSet): - """ScopeMapping Viewset""" - - queryset = ScopeMapping.objects.all() - serializer_class = ScopeMappingSerializer diff --git a/passbook/providers/oauth2/apps.py b/passbook/providers/oauth2/apps.py deleted file mode 100644 index e0cde620..00000000 --- a/passbook/providers/oauth2/apps.py +++ /dev/null @@ -1,14 +0,0 @@ -"""passbook auth oauth provider app config""" -from django.apps import AppConfig - - -class PassbookProviderOAuth2Config(AppConfig): - """passbook auth oauth provider app config""" - - name = "passbook.providers.oauth2" - label = "passbook_providers_oauth2" - verbose_name = "passbook Providers.OAuth2" - mountpoints = { - "passbook.providers.oauth2.urls": "application/o/", - "passbook.providers.oauth2.urls_github": "", - } diff --git a/passbook/providers/oauth2/forms.py b/passbook/providers/oauth2/forms.py deleted file mode 100644 index 659eb4bd..00000000 --- a/passbook/providers/oauth2/forms.py +++ /dev/null @@ -1,96 +0,0 @@ -"""passbook OAuth2 Provider Forms""" - -from django import forms -from django.core.exceptions import ValidationError -from django.utils.translation import gettext as _ - -from passbook.admin.fields import CodeMirrorWidget -from passbook.core.expression import PropertyMappingEvaluator -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow, FlowDesignation -from passbook.providers.oauth2.generators import ( - generate_client_id, - generate_client_secret, -) -from passbook.providers.oauth2.models import JWTAlgorithms, OAuth2Provider, ScopeMapping - - -class OAuth2ProviderForm(forms.ModelForm): - """OAuth2 Provider form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authorization_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHORIZATION - ) - self.fields["client_id"].initial = generate_client_id() - self.fields["client_secret"].initial = generate_client_secret() - self.fields["rsa_key"].queryset = CertificateKeyPair.objects.exclude( - key_data__exact="" - ) - self.fields["property_mappings"].queryset = ScopeMapping.objects.all() - - def clean_jwt_alg(self): - """Ensure that when RS256 is selected, a certificate-key-pair is selected""" - if ( - self.data["rsa_key"] == "" - and self.cleaned_data["jwt_alg"] == JWTAlgorithms.RS256 - ): - raise ValidationError( - _("RS256 requires a Certificate-Key-Pair to be selected.") - ) - return self.cleaned_data["jwt_alg"] - - class Meta: - model = OAuth2Provider - fields = [ - "name", - "authorization_flow", - "client_type", - "client_id", - "client_secret", - "response_type", - "token_validity", - "jwt_alg", - "rsa_key", - "redirect_uris", - "sub_mode", - "property_mappings", - ] - widgets = { - "name": forms.TextInput(), - "token_validity": forms.TextInput(), - } - labels = {"property_mappings": _("Scopes")} - help_texts = { - "property_mappings": _( - ( - "Select which scopes can be used by the client. " - "The client stil has to specify the scope to access the data." - ) - ) - } - - -class ScopeMappingForm(forms.ModelForm): - """Form to edit ScopeMappings""" - - template_name = "providers/oauth2/property_mapping_form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - evaluator = PropertyMappingEvaluator() - evaluator.validate(expression) - return expression - - class Meta: - - model = ScopeMapping - fields = ["name", "scope_name", "description", "expression"] - widgets = { - "name": forms.TextInput(), - "scope_name": forms.TextInput(), - "description": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } diff --git a/passbook/providers/oauth2/migrations/0001_initial.py b/passbook/providers/oauth2/migrations/0001_initial.py deleted file mode 100644 index 3015aa46..00000000 --- a/passbook/providers/oauth2/migrations/0001_initial.py +++ /dev/null @@ -1,360 +0,0 @@ -# Generated by Django 3.1 on 2020-08-18 15:59 - -import django.db.models.deletion -from django.apps.registry import Apps -from django.conf import settings -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -import passbook.core.models -import passbook.lib.utils.time -import passbook.providers.oauth2.generators - -SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself. -return {} -""" -SCOPE_EMAIL_EXPRESSION = """return { - "email": user.email, - "email_verified": True -} -""" -SCOPE_PROFILE_EXPRESSION = """return { - "name": user.name, - "given_name": user.name, - "family_name": "", - "preferred_username": user.username, - "nickname": user.username, -} -""" - - -def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - ScopeMapping = apps.get_model("passbook_providers_oauth2", "ScopeMapping") - ScopeMapping.objects.update_or_create( - scope_name="openid", - defaults={ - "name": "Autogenerated OAuth2 Mapping: OpenID 'openid'", - "scope_name": "openid", - "description": "", - "expression": SCOPE_OPENID_EXPRESSION, - }, - ) - ScopeMapping.objects.update_or_create( - scope_name="email", - defaults={ - "name": "Autogenerated OAuth2 Mapping: OpenID 'email'", - "scope_name": "email", - "description": "Email address", - "expression": SCOPE_EMAIL_EXPRESSION, - }, - ) - ScopeMapping.objects.update_or_create( - scope_name="profile", - defaults={ - "name": "Autogenerated OAuth2 Mapping: OpenID 'profile'", - "scope_name": "profile", - "description": "General Profile Information", - "expression": SCOPE_PROFILE_EXPRESSION, - }, - ) - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("passbook_core", "0007_auto_20200815_1841"), - ("passbook_crypto", "0002_create_self_signed_kp"), - ] - - operations = [ - migrations.RunSQL( - "DROP TABLE IF EXISTS passbook_providers_oauth_oauth2provider CASCADE;" - ), - migrations.RunSQL( - "DROP TABLE IF EXISTS passbook_providers_oidc_openidprovider CASCADE;" - ), - migrations.CreateModel( - name="OAuth2Provider", - fields=[ - ( - "provider_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.provider", - ), - ), - ("name", models.TextField()), - ( - "client_type", - models.CharField( - choices=[ - ("confidential", "Confidential"), - ("public", "Public"), - ], - default="confidential", - help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.", - max_length=30, - verbose_name="Client Type", - ), - ), - ( - "client_id", - models.CharField( - default=passbook.providers.oauth2.generators.generate_client_id, - max_length=255, - unique=True, - verbose_name="Client ID", - ), - ), - ( - "client_secret", - models.CharField( - blank=True, - default=passbook.providers.oauth2.generators.generate_client_secret, - max_length=255, - verbose_name="Client Secret", - ), - ), - ( - "response_type", - models.TextField( - choices=[ - ("code", "code (Authorization Code Flow)"), - ("id_token", "id_token (Implicit Flow)"), - ("id_token token", "id_token token (Implicit Flow)"), - ("code token", "code token (Hybrid Flow)"), - ("code id_token", "code id_token (Hybrid Flow)"), - ( - "code id_token token", - "code id_token token (Hybrid Flow)", - ), - ], - default="code", - help_text="Response Type required by the client.", - ), - ), - ( - "jwt_alg", - models.CharField( - choices=[ - ("HS256", "HS256 (Symmetric Encryption)"), - ("RS256", "RS256 (Asymmetric Encryption)"), - ], - default="RS256", - help_text="Algorithm used to sign the JWT Token", - max_length=10, - verbose_name="JWT Algorithm", - ), - ), - ( - "redirect_uris", - models.TextField( - default="", - help_text="Enter each URI on a new line.", - verbose_name="Redirect URIs", - ), - ), - ( - "post_logout_redirect_uris", - models.TextField( - blank=True, - default="", - help_text="Enter each URI on a new line.", - verbose_name="Post Logout Redirect URIs", - ), - ), - ( - "include_claims_in_id_token", - models.BooleanField( - default=True, - help_text="Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.", - verbose_name="Include claims in id_token", - ), - ), - ( - "token_validity", - models.TextField( - default="minutes=10", - help_text="Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", - validators=[passbook.lib.utils.time.timedelta_string_validator], - ), - ), - ( - "rsa_key", - models.ForeignKey( - help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.", - on_delete=django.db.models.deletion.CASCADE, - to="passbook_crypto.certificatekeypair", - verbose_name="RSA Key", - blank=True, - null=True, - ), - ), - ], - options={ - "verbose_name": "OAuth2/OpenID Provider", - "verbose_name_plural": "OAuth2/OpenID Providers", - }, - bases=("passbook_core.provider",), - ), - migrations.CreateModel( - name="ScopeMapping", - fields=[ - ( - "propertymapping_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.propertymapping", - ), - ), - ("scope_name", models.TextField(help_text="Scope used by the client")), - ( - "description", - models.TextField( - blank=True, - help_text="Description shown to the user when consenting. If left empty, the user won't be informed.", - ), - ), - ], - options={ - "verbose_name": "Scope Mapping", - "verbose_name_plural": "Scope Mappings", - }, - bases=("passbook_core.propertymapping",), - ), - migrations.RunPython(create_default_scopes), - migrations.CreateModel( - name="RefreshToken", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "expires", - models.DateTimeField( - default=passbook.core.models.default_token_duration - ), - ), - ("expiring", models.BooleanField(default=True)), - ("_scope", models.TextField(default="", verbose_name="Scopes")), - ( - "access_token", - models.CharField( - max_length=255, unique=True, verbose_name="Access Token" - ), - ), - ( - "refresh_token", - models.CharField( - max_length=255, unique=True, verbose_name="Refresh Token" - ), - ), - ("_id_token", models.TextField(verbose_name="ID Token")), - ( - "provider", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_providers_oauth2.oauth2provider", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - verbose_name="User", - ), - ), - ], - options={ - "verbose_name": "Token", - "verbose_name_plural": "Tokens", - }, - ), - migrations.CreateModel( - name="AuthorizationCode", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "expires", - models.DateTimeField( - default=passbook.core.models.default_token_duration - ), - ), - ("expiring", models.BooleanField(default=True)), - ("_scope", models.TextField(default="", verbose_name="Scopes")), - ( - "code", - models.CharField(max_length=255, unique=True, verbose_name="Code"), - ), - ( - "nonce", - models.CharField( - blank=True, default="", max_length=255, verbose_name="Nonce" - ), - ), - ( - "is_open_id", - models.BooleanField( - default=False, verbose_name="Is Authentication?" - ), - ), - ( - "code_challenge", - models.CharField( - max_length=255, null=True, verbose_name="Code Challenge" - ), - ), - ( - "code_challenge_method", - models.CharField( - max_length=255, null=True, verbose_name="Code Challenge Method" - ), - ), - ( - "provider", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_providers_oauth2.oauth2provider", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - verbose_name="User", - ), - ), - ], - options={ - "verbose_name": "Authorization Code", - "verbose_name_plural": "Authorization Codes", - }, - ), - ] diff --git a/passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py b/passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py deleted file mode 100644 index b33c7075..00000000 --- a/passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-15 18:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_oauth2", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="oauth2provider", - name="sub_mode", - field=models.TextField( - choices=[ - ("hashed_user_id", "Based on the Hashed User ID"), - ("user_username", "Based on the username"), - ( - "user_email", - "Based on the User's Email. This is recommended over the UPN method.", - ), - ( - "user_upn", - "Based on the User's UPN, only works if user has a 'upn' attribute set. Use this method only if you have different UPN and Mail domains.", - ), - ], - default="hashed_user_id", - help_text="Configure what data should be used as unique User Identifier. For most cases, the default should be fine.", - ), - ), - ] diff --git a/passbook/providers/oauth2/migrations/0003_auto_20200916_2129.py b/passbook/providers/oauth2/migrations/0003_auto_20200916_2129.py deleted file mode 100644 index 1f2f806f..00000000 --- a/passbook/providers/oauth2/migrations/0003_auto_20200916_2129.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-16 21:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_oauth2", "0002_oauth2provider_sub_mode"), - ] - - operations = [ - migrations.AlterField( - model_name="oauth2provider", - name="client_type", - field=models.CharField( - choices=[("confidential", "Confidential"), ("public", "Public")], - default="confidential", - help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.", - max_length=30, - verbose_name="Client Type", - ), - ), - migrations.AlterField( - model_name="oauth2provider", - name="response_type", - field=models.TextField( - choices=[ - ("code", "code (Authorization Code Flow)"), - ( - "code_adfs", - "code (ADFS Compatibility Mode, sends id_token as access_token)", - ), - ("id_token", "id_token (Implicit Flow)"), - ("id_token token", "id_token token (Implicit Flow)"), - ("code token", "code token (Hybrid Flow)"), - ("code id_token", "code id_token (Hybrid Flow)"), - ("code id_token token", "code id_token token (Hybrid Flow)"), - ], - default="code", - help_text="Response Type required by the client.", - ), - ), - ] diff --git a/passbook/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py b/passbook/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py deleted file mode 100644 index 5d99c61c..00000000 --- a/passbook/providers/oauth2/migrations/0004_remove_oauth2provider_post_logout_redirect_uris.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-18 21:16 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_oauth2", "0003_auto_20200916_2129"), - ] - - operations = [ - migrations.RemoveField( - model_name="oauth2provider", - name="post_logout_redirect_uris", - ), - ] diff --git a/passbook/providers/oauth2/migrations/0005_auto_20200920_1240.py b/passbook/providers/oauth2/migrations/0005_auto_20200920_1240.py deleted file mode 100644 index 106585ab..00000000 --- a/passbook/providers/oauth2/migrations/0005_auto_20200920_1240.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-20 12:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "passbook_providers_oauth2", - "0004_remove_oauth2provider_post_logout_redirect_uris", - ), - ] - - operations = [ - migrations.AlterField( - model_name="oauth2provider", - name="response_type", - field=models.TextField( - choices=[ - ("code", "code (Authorization Code Flow)"), - ( - "code#adfs", - "code (ADFS Compatibility Mode, sends id_token as access_token)", - ), - ("id_token", "id_token (Implicit Flow)"), - ("id_token token", "id_token token (Implicit Flow)"), - ("code token", "code token (Hybrid Flow)"), - ("code id_token", "code id_token (Hybrid Flow)"), - ("code id_token token", "code id_token token (Hybrid Flow)"), - ], - default="code", - help_text="Response Type required by the client.", - ), - ), - ] diff --git a/passbook/providers/oauth2/migrations/0006_remove_oauth2provider_name.py b/passbook/providers/oauth2/migrations/0006_remove_oauth2provider_name.py deleted file mode 100644 index 3c67a491..00000000 --- a/passbook/providers/oauth2/migrations/0006_remove_oauth2provider_name.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-03 17:37 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def update_name_temp(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - OAuth2Provider = apps.get_model("passbook_providers_oauth2", "OAuth2Provider") - db_alias = schema_editor.connection.alias - - for provider in OAuth2Provider.objects.using(db_alias).all(): - provider.name_temp = provider.name - provider.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0011_provider_name_temp"), - ("passbook_providers_oauth2", "0005_auto_20200920_1240"), - ] - - operations = [ - migrations.RunPython(update_name_temp), - migrations.RemoveField( - model_name="oauth2provider", - name="name", - ), - ] diff --git a/passbook/providers/oauth2/migrations/0007_auto_20201016_1107.py b/passbook/providers/oauth2/migrations/0007_auto_20201016_1107.py deleted file mode 100644 index d8575aa3..00000000 --- a/passbook/providers/oauth2/migrations/0007_auto_20201016_1107.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-16 11:07 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_oauth2", "0006_remove_oauth2provider_name"), - ] - - operations = [ - migrations.AlterModelOptions( - name="refreshtoken", - options={ - "verbose_name": "OAuth2 Token", - "verbose_name_plural": "OAuth2 Tokens", - }, - ), - ] diff --git a/passbook/providers/oauth2/models.py b/passbook/providers/oauth2/models.py deleted file mode 100644 index 141d4e33..00000000 --- a/passbook/providers/oauth2/models.py +++ /dev/null @@ -1,499 +0,0 @@ -"""OAuth Provider Models""" -import base64 -import binascii -import json -import time -from dataclasses import asdict, dataclass, field -from hashlib import sha256 -from typing import Any, Dict, List, Optional, Type -from urllib.parse import urlparse -from uuid import uuid4 - -from django.conf import settings -from django.db import models -from django.forms import ModelForm -from django.http import HttpRequest -from django.shortcuts import reverse -from django.utils import dateformat, timezone -from django.utils.translation import gettext_lazy as _ -from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key -from jwkest.jws import JWS - -from passbook.core.models import ExpiringModel, PropertyMapping, Provider, User -from passbook.crypto.models import CertificateKeyPair -from passbook.lib.utils.template import render_to_string -from passbook.lib.utils.time import timedelta_from_string, timedelta_string_validator -from passbook.providers.oauth2.apps import PassbookProviderOAuth2Config -from passbook.providers.oauth2.generators import ( - generate_client_id, - generate_client_secret, -) - - -class ClientTypes(models.TextChoices): - """Confidential clients are capable of maintaining the confidentiality - of their credentials. Public clients are incapable.""" - - CONFIDENTIAL = "confidential", _("Confidential") - PUBLIC = "public", _("Public") - - -class GrantTypes(models.TextChoices): - """OAuth2 Grant types we support""" - - AUTHORIZATION_CODE = "authorization_code" - IMPLICIT = "implicit" - HYBRID = "hybrid" - - -class SubModes(models.TextChoices): - """Mode after which 'sub' attribute is generateed, for compatibility reasons""" - - HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID") - USER_USERNAME = "user_username", _("Based on the username") - USER_EMAIL = ( - "user_email", - _("Based on the User's Email. This is recommended over the UPN method."), - ) - USER_UPN = ( - "user_upn", - _( - ( - "Based on the User's UPN, only works if user has a 'upn' attribute set. " - "Use this method only if you have different UPN and Mail domains." - ) - ), - ) - - -class ResponseTypes(models.TextChoices): - """Response Type required by the client.""" - - CODE = "code", _("code (Authorization Code Flow)") - CODE_ADFS = ( - "code#adfs", - _("code (ADFS Compatibility Mode, sends id_token as access_token)"), - ) - ID_TOKEN = "id_token", _("id_token (Implicit Flow)") - ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)") - CODE_TOKEN = "code token", _("code token (Hybrid Flow)") - CODE_ID_TOKEN = "code id_token", _("code id_token (Hybrid Flow)") - CODE_ID_TOKEN_TOKEN = "code id_token token", _("code id_token token (Hybrid Flow)") - - -class JWTAlgorithms(models.TextChoices): - """Algorithm used to sign the JWT Token""" - - HS256 = "HS256", _("HS256 (Symmetric Encryption)") - RS256 = "RS256", _("RS256 (Asymmetric Encryption)") - - -class ScopeMapping(PropertyMapping): - """Map an OAuth Scope to users properties""" - - scope_name = models.TextField(help_text=_("Scope used by the client")) - description = models.TextField( - blank=True, - help_text=_( - ( - "Description shown to the user when consenting. " - "If left empty, the user won't be informed." - ) - ), - ) - - @property - def form(self) -> Type[ModelForm]: - from passbook.providers.oauth2.forms import ScopeMappingForm - - return ScopeMappingForm - - def __str__(self): - return f"Scope Mapping {self.name} ({self.scope_name})" - - class Meta: - - verbose_name = _("Scope Mapping") - verbose_name_plural = _("Scope Mappings") - - -class OAuth2Provider(Provider): - """OAuth2 Provider for generic OAuth and OpenID Connect Applications.""" - - client_type = models.CharField( - max_length=30, - choices=ClientTypes.choices, - default=ClientTypes.CONFIDENTIAL, - verbose_name=_("Client Type"), - help_text=_(ClientTypes.__doc__), - ) - client_id = models.CharField( - max_length=255, - unique=True, - verbose_name=_("Client ID"), - default=generate_client_id, - ) - client_secret = models.CharField( - max_length=255, - blank=True, - verbose_name=_("Client Secret"), - default=generate_client_secret, - ) - response_type = models.TextField( - choices=ResponseTypes.choices, - default=ResponseTypes.CODE, - help_text=_(ResponseTypes.__doc__), - ) - jwt_alg = models.CharField( - max_length=10, - choices=JWTAlgorithms.choices, - default=JWTAlgorithms.RS256, - verbose_name=_("JWT Algorithm"), - help_text=_(JWTAlgorithms.__doc__), - ) - redirect_uris = models.TextField( - default="", - verbose_name=_("Redirect URIs"), - help_text=_("Enter each URI on a new line."), - ) - - include_claims_in_id_token = models.BooleanField( - default=True, - verbose_name=_("Include claims in id_token"), - help_text=_( - ( - "Include User claims from scopes in the id_token, for applications " - "that don't access the userinfo endpoint." - ) - ), - ) - - token_validity = models.TextField( - default="minutes=10", - validators=[timedelta_string_validator], - help_text=_( - ( - "Tokens not valid on or after current time + this value " - "(Format: hours=1;minutes=2;seconds=3)." - ) - ), - ) - - sub_mode = models.TextField( - choices=SubModes.choices, - default=SubModes.HASHED_USER_ID, - help_text=_( - ( - "Configure what data should be used as unique User Identifier. For most cases, " - "the default should be fine." - ) - ), - ) - - rsa_key = models.ForeignKey( - CertificateKeyPair, - verbose_name=_("RSA Key"), - on_delete=models.CASCADE, - blank=True, - null=True, - help_text=_( - "Key used to sign the tokens. Only required when JWT Algorithm is set to RS256." - ), - ) - - def create_refresh_token( - self, user: User, scope: List[str], id_token: Optional["IDToken"] = None - ) -> "RefreshToken": - """Create and populate a RefreshToken object.""" - token = RefreshToken( - user=user, - provider=self, - access_token=uuid4().hex, - refresh_token=uuid4().hex, - expires=timezone.now() + timedelta_from_string(self.token_validity), - scope=scope, - ) - if id_token: - token.id_token = id_token - return token - - def get_jwt_keys(self) -> List[Key]: - """ - Takes a provider and returns the set of keys associated with it. - Returns a list of keys. - """ - if self.jwt_alg == JWTAlgorithms.RS256: - # if the user selected RS256 but didn't select a - # CertificateKeyPair, we fall back to HS256 - if not self.rsa_key: - self.jwt_alg = JWTAlgorithms.HS256 - self.save() - else: - # Because the JWT Library uses python cryptodome, - # we can't directly pass the RSAPublicKey - # object, but have to load it ourselves - key = import_rsa_key(self.rsa_key.key_data) - keys = [RSAKey(key=key, kid=self.rsa_key.kid)] - if not keys: - raise Exception("You must add at least one RSA Key.") - return keys - - if self.jwt_alg == JWTAlgorithms.HS256: - return [SYMKey(key=self.client_secret, alg=self.jwt_alg)] - - raise Exception("Unsupported key algorithm.") - - def get_issuer(self, request: HttpRequest) -> Optional[str]: - """Get issuer, based on request""" - try: - mountpoint = PassbookProviderOAuth2Config.mountpoints[ - "passbook.providers.oauth2.urls" - ] - # pylint: disable=no-member - return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/") - except Provider.application.RelatedObjectDoesNotExist: - return None - - @property - def launch_url(self) -> Optional[str]: - """Guess launch_url based on first redirect_uri""" - if self.redirect_uris == "": - return None - main_url = self.redirect_uris.split("\n")[0] - launch_url = urlparse(main_url) - return main_url.replace(launch_url.path, "") - - @property - def form(self) -> Type[ModelForm]: - from passbook.providers.oauth2.forms import OAuth2ProviderForm - - return OAuth2ProviderForm - - def __str__(self): - return f"OAuth2 Provider {self.name}" - - def encode(self, payload: Dict[str, Any]) -> str: - """Represent the ID Token as a JSON Web Token (JWT).""" - keys = self.get_jwt_keys() - # If the provider does not have an RSA Key assigned, it was switched to Symmetric - self.refresh_from_db() - jws = JWS(payload, alg=self.jwt_alg) - return jws.sign_compact(keys) - - def html_setup_urls(self, request: HttpRequest) -> Optional[str]: - """return template and context modal with URLs for authorize, token, openid-config, etc""" - try: - # pylint: disable=no-member - return render_to_string( - "providers/oauth2/setup_url_modal.html", - { - "provider": self, - "issuer": self.get_issuer(request), - "authorize": request.build_absolute_uri( - reverse( - "passbook_providers_oauth2:authorize", - ) - ), - "token": request.build_absolute_uri( - reverse( - "passbook_providers_oauth2:token", - ) - ), - "userinfo": request.build_absolute_uri( - reverse( - "passbook_providers_oauth2:userinfo", - ) - ), - "provider_info": request.build_absolute_uri( - reverse( - "passbook_providers_oauth2:provider-info", - kwargs={"application_slug": self.application.slug}, - ) - ), - }, - ) - except Provider.application.RelatedObjectDoesNotExist: - return None - - class Meta: - - verbose_name = _("OAuth2/OpenID Provider") - verbose_name_plural = _("OAuth2/OpenID Providers") - - -class BaseGrantModel(models.Model): - """Base Model for all grants""" - - provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE) - user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) - _scope = models.TextField(default="", verbose_name=_("Scopes")) - - @property - def scope(self) -> List[str]: - """Return scopes as list of strings""" - return self._scope.split() - - @scope.setter - def scope(self, value): - self._scope = " ".join(value) - - class Meta: - abstract = True - - -class AuthorizationCode(ExpiringModel, BaseGrantModel): - """OAuth2 Authorization Code""" - - code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) - nonce = models.CharField( - max_length=255, blank=True, default="", verbose_name=_("Nonce") - ) - is_open_id = models.BooleanField( - default=False, verbose_name=_("Is Authentication?") - ) - code_challenge = models.CharField( - max_length=255, null=True, verbose_name=_("Code Challenge") - ) - code_challenge_method = models.CharField( - max_length=255, null=True, verbose_name=_("Code Challenge Method") - ) - - class Meta: - verbose_name = _("Authorization Code") - verbose_name_plural = _("Authorization Codes") - - def __str__(self): - return "{0} - {1}".format(self.provider, self.code) - - -@dataclass -class IDToken: - """The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be - Authenticated is the ID Token data structure. The ID Token is a security token that contains - Claims about the Authentication of an End-User by an Authorization Server when using a Client, - and potentially other requested Claims. The ID Token is represented as a - JSON Web Token (JWT) [JWT]. - - https://openid.net/specs/openid-connect-core-1_0.html#IDToken""" - - # All these fields need to optional so we can save an empty IDToken for non-OpenID flows. - iss: Optional[str] = None - sub: Optional[str] = None - aud: Optional[str] = None - exp: Optional[int] = None - iat: Optional[int] = None - auth_time: Optional[int] = None - - nonce: Optional[str] = None - at_hash: Optional[str] = None - - claims: Dict[str, Any] = field(default_factory=dict) - - @staticmethod - def from_dict(data: Dict[str, Any]) -> "IDToken": - """Reconstruct ID Token from json dictionary""" - token = IDToken() - for key, value in data.items(): - setattr(token, key, value) - return token - - def to_dict(self) -> Dict[str, Any]: - """Convert dataclass to dict, and update with keys from `claims`""" - dic = asdict(self) - dic.pop("claims") - dic.update(self.claims) - return dic - - -class RefreshToken(ExpiringModel, BaseGrantModel): - """OAuth2 Refresh Token""" - - access_token = models.CharField( - max_length=255, unique=True, verbose_name=_("Access Token") - ) - refresh_token = models.CharField( - max_length=255, unique=True, verbose_name=_("Refresh Token") - ) - _id_token = models.TextField(verbose_name=_("ID Token")) - - class Meta: - verbose_name = _("OAuth2 Token") - verbose_name_plural = _("OAuth2 Tokens") - - @property - def id_token(self) -> IDToken: - """Load ID Token from json""" - if self._id_token: - raw_token = json.loads(self._id_token) - return IDToken.from_dict(raw_token) - return IDToken() - - @id_token.setter - def id_token(self, value: IDToken): - self._id_token = json.dumps(asdict(value)) - - def __str__(self): - return f"{self.provider} - {self.access_token}" - - @property - def at_hash(self): - """Get hashed access_token""" - hashed_access_token = ( - sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii") - ) - return ( - base64.urlsafe_b64encode( - binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2]) - ) - .rstrip(b"=") - .decode("ascii") - ) - - def create_id_token(self, user: User, request: HttpRequest) -> IDToken: - """Creates the id_token. - See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken""" - sub = "" - if self.provider.sub_mode == SubModes.HASHED_USER_ID: - sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() - elif self.provider.sub_mode == SubModes.USER_EMAIL: - sub = user.email - elif self.provider.sub_mode == SubModes.USER_USERNAME: - sub = user.username - elif self.provider.sub_mode == SubModes.USER_UPN: - sub = user.attributes["upn"] - else: - raise ValueError( - ( - f"Provider {self.provider} has invalid sub_mode " - f"selected: {self.provider.sub_mode}" - ) - ) - - # Convert datetimes into timestamps. - now = int(time.time()) - iat_time = now - exp_time = int( - now + timedelta_from_string(self.provider.token_validity).seconds - ) - user_auth_time = user.last_login or user.date_joined - auth_time = int(dateformat.format(user_auth_time, "U")) - - token = IDToken( - iss=self.provider.get_issuer(request), - sub=sub, - aud=self.provider.client_id, - exp=exp_time, - iat=iat_time, - auth_time=auth_time, - ) - - # Include (or not) user standard claims in the id_token. - if self.provider.include_claims_in_id_token: - from passbook.providers.oauth2.views.userinfo import UserInfoView - - user_info = UserInfoView() - user_info.request = request - claims = user_info.get_claims(self) - token.claims = claims - - return token diff --git a/passbook/providers/oauth2/templates/providers/oauth2/end_session.html b/passbook/providers/oauth2/templates/providers/oauth2/end_session.html deleted file mode 100644 index 3a05087a..00000000 --- a/passbook/providers/oauth2/templates/providers/oauth2/end_session.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'login/base_full.html' %} - -{% load static %} -{% load i18n %} -{% load passbook_utils %} - -{% block title %} -{% trans 'End session' %} -{% endblock %} - -{% block card_title %} -{% blocktrans with application=application.name %} -You've logged out of {{ application }}. -{% endblocktrans %} -{% endblock %} - -{% block card %} -
-

- {% blocktrans with application=application.name %} - You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your passbook account. - {% endblocktrans %} -

- - {% trans 'Go back to overview' %} - - {% trans 'Log out of passbook' %} - - {% if application.get_launch_url %} - - {% blocktrans with application=application.name %} - Log back into {{ application }} - {% endblocktrans %} - - {% endif %} - -
-{% endblock %} diff --git a/passbook/providers/oauth2/templates/providers/oauth2/property_mapping_form.html b/passbook/providers/oauth2/templates/providers/oauth2/property_mapping_form.html deleted file mode 100644 index 4bd3085a..00000000 --- a/passbook/providers/oauth2/templates/providers/oauth2/property_mapping_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/passbook/providers/oauth2/templates/providers/oauth2/setup_url_modal.html b/passbook/providers/oauth2/templates/providers/oauth2/setup_url_modal.html deleted file mode 100644 index b69f47ae..00000000 --- a/passbook/providers/oauth2/templates/providers/oauth2/setup_url_modal.html +++ /dev/null @@ -1,50 +0,0 @@ -{% load i18n %} - - - -
-
-

{% trans 'Setup URLs' %}

-
- - -
-
diff --git a/passbook/providers/oauth2/urls.py b/passbook/providers/oauth2/urls.py deleted file mode 100644 index 1a0a1a93..00000000 --- a/passbook/providers/oauth2/urls.py +++ /dev/null @@ -1,43 +0,0 @@ -"""OAuth provider URLs""" -from django.urls import path -from django.views.decorators.csrf import csrf_exempt - -from passbook.providers.oauth2.constants import SCOPE_OPENID -from passbook.providers.oauth2.utils import protected_resource_view -from passbook.providers.oauth2.views.authorize import AuthorizationFlowInitView -from passbook.providers.oauth2.views.introspection import TokenIntrospectionView -from passbook.providers.oauth2.views.jwks import JWKSView -from passbook.providers.oauth2.views.provider import ProviderInfoView -from passbook.providers.oauth2.views.session import EndSessionView -from passbook.providers.oauth2.views.token import TokenView -from passbook.providers.oauth2.views.userinfo import UserInfoView - -urlpatterns = [ - path( - "authorize/", - AuthorizationFlowInitView.as_view(), - name="authorize", - ), - path("token/", csrf_exempt(TokenView.as_view()), name="token"), - path( - "userinfo/", - csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())), - name="userinfo", - ), - path( - "introspect/", - csrf_exempt(TokenIntrospectionView.as_view()), - name="token-introspection", - ), - path( - "/end-session/", - EndSessionView.as_view(), - name="end-session", - ), - path("/jwks/", JWKSView.as_view(), name="jwks"), - path( - "/.well-known/openid-configuration", - ProviderInfoView.as_view(), - name="provider-info", - ), -] diff --git a/passbook/providers/oauth2/urls_github.py b/passbook/providers/oauth2/urls_github.py deleted file mode 100644 index 5bb8260d..00000000 --- a/passbook/providers/oauth2/urls_github.py +++ /dev/null @@ -1,45 +0,0 @@ -"""passbook oauth_provider urls""" -from django.urls import include, path -from django.views.decorators.csrf import csrf_exempt - -from passbook.providers.oauth2.constants import ( - SCOPE_GITHUB_ORG_READ, - SCOPE_GITHUB_USER_EMAIL, -) -from passbook.providers.oauth2.utils import protected_resource_view -from passbook.providers.oauth2.views.authorize import AuthorizationFlowInitView -from passbook.providers.oauth2.views.github import GitHubUserTeamsView, GitHubUserView -from passbook.providers.oauth2.views.token import TokenView - -github_urlpatterns = [ - path( - "login/oauth/authorize", - AuthorizationFlowInitView.as_view(), - name="github-authorize", - ), - path( - "login/oauth/access_token", - csrf_exempt(TokenView.as_view()), - name="github-access-token", - ), - path( - "user", - csrf_exempt( - protected_resource_view([SCOPE_GITHUB_USER_EMAIL])(GitHubUserView.as_view()) - ), - name="github-user", - ), - path( - "user/teams", - csrf_exempt( - protected_resource_view([SCOPE_GITHUB_ORG_READ])( - GitHubUserTeamsView.as_view() - ) - ), - name="github-user-teams", - ), -] - -urlpatterns = [ - path("", include(github_urlpatterns)), -] diff --git a/passbook/providers/oauth2/utils.py b/passbook/providers/oauth2/utils.py deleted file mode 100644 index adcae91c..00000000 --- a/passbook/providers/oauth2/utils.py +++ /dev/null @@ -1,156 +0,0 @@ -"""OAuth2/OpenID Utils""" -import re -from base64 import b64decode -from binascii import Error -from typing import List, Optional, Tuple - -from django.http import HttpRequest, HttpResponse, JsonResponse -from django.utils.cache import patch_vary_headers -from jwkest.jwt import JWT -from structlog import get_logger - -from passbook.providers.oauth2.errors import BearerTokenError -from passbook.providers.oauth2.models import RefreshToken - -LOGGER = get_logger() - - -class TokenResponse(JsonResponse): - """JSON Response with headers that it should never be cached - - https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self["Cache-Control"] = "no-store" - self["Pragma"] = "no-cache" - - -def cors_allow_any(request, response): - """ - Add headers to permit CORS requests from any origin, with or without credentials, - with any headers. - """ - origin = request.META.get("HTTP_ORIGIN") - if not origin: - return response - - # From the CORS spec: The string "*" cannot be used for a resource that supports credentials. - response["Access-Control-Allow-Origin"] = origin - patch_vary_headers(response, ["Origin"]) - response["Access-Control-Allow-Credentials"] = "true" - - if request.method == "OPTIONS": - if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META: - response["Access-Control-Allow-Headers"] = request.META[ - "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" - ] - response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" - - return response - - -def extract_access_token(request: HttpRequest) -> Optional[str]: - """ - Get the access token using Authorization Request Header Field method. - Or try getting via GET. - See: http://tools.ietf.org/html/rfc6750#section-2.1 - - Return a string. - """ - auth_header = request.META.get("HTTP_AUTHORIZATION", "") - - if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header): - return auth_header.split()[1] - if "access_token" in request.POST: - return request.POST.get("access_token") - if "access_token" in request.GET: - return request.GET.get("access_token") - return None - - -def extract_client_auth(request: HttpRequest) -> Tuple[str, str]: - """ - Get client credentials using HTTP Basic Authentication method. - Or try getting parameters via POST. - See: http://tools.ietf.org/html/rfc6750#section-2.1 - - Return a tuple `(client_id, client_secret)`. - """ - auth_header = request.META.get("HTTP_AUTHORIZATION", "") - - if re.compile(r"^Basic\s{1}.+$").match(auth_header): - b64_user_pass = auth_header.split()[1] - try: - user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") - client_id, client_secret = user_pass - except (ValueError, Error): - client_id = client_secret = "" - else: - client_id = request.POST.get("client_id", "") - client_secret = request.POST.get("client_secret", "") - - return (client_id, client_secret) - - -def protected_resource_view(scopes: List[str]): - """View decorator. The client accesses protected resources by presenting the - access token to the resource server. - - https://tools.ietf.org/html/rfc6749#section-7 - - This decorator also injects the token into `kwargs`""" - - def wrapper(view): - def view_wrapper(request, *args, **kwargs): - try: - access_token = extract_access_token(request) - if not access_token: - LOGGER.debug("No token passed") - raise BearerTokenError("invalid_token") - - try: - kwargs["token"] = RefreshToken.objects.get( - access_token=access_token - ) - except RefreshToken.DoesNotExist: - LOGGER.debug("Token does not exist", access_token=access_token) - raise BearerTokenError("invalid_token") - - if kwargs["token"].is_expired: - LOGGER.debug("Token has expired", access_token=access_token) - raise BearerTokenError("invalid_token") - - if not set(scopes).issubset(set(kwargs["token"].scope)): - LOGGER.warning( - "Scope missmatch.", - required=set(scopes), - token_has=set(kwargs["token"].scope), - ) - raise BearerTokenError("insufficient_scope") - except BearerTokenError as error: - response = HttpResponse(status=error.status) - response[ - "WWW-Authenticate" - ] = f'error="{error.code}", error_description="{error.description}"' - return response - - return view(request, *args, **kwargs) - - return view_wrapper - - return wrapper - - -def client_id_from_id_token(id_token): - """ - Extracts the client id from a JSON Web Token (JWT). - Returns a string or None. - """ - payload = JWT().unpack(id_token).payload() - aud = payload.get("aud", None) - if aud is None: - return None - if isinstance(aud, list): - return aud[0] - return aud diff --git a/passbook/providers/oauth2/views/authorize.py b/passbook/providers/oauth2/views/authorize.py deleted file mode 100644 index 4a482985..00000000 --- a/passbook/providers/oauth2/views/authorize.py +++ /dev/null @@ -1,382 +0,0 @@ -"""passbook OAuth2 Authorization views""" -from dataclasses import dataclass, field -from typing import List, Optional, Set -from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit -from uuid import uuid4 - -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.utils import timezone -from structlog import get_logger - -from passbook.audit.models import Event, EventAction -from passbook.core.models import Application -from passbook.flows.models import in_memory_stage -from passbook.flows.planner import ( - PLAN_CONTEXT_APPLICATION, - PLAN_CONTEXT_SSO, - FlowPlan, - FlowPlanner, -) -from passbook.flows.stage import StageView -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.lib.utils.time import timedelta_from_string -from passbook.lib.utils.urls import redirect_with_qs -from passbook.lib.views import bad_request_message -from passbook.policies.views import PolicyAccessView -from passbook.providers.oauth2.constants import ( - PROMPT_CONSNET, - PROMPT_NONE, - SCOPE_OPENID, -) -from passbook.providers.oauth2.errors import ( - AuthorizeError, - ClientIdError, - OAuth2Error, - RedirectUriError, -) -from passbook.providers.oauth2.models import ( - AuthorizationCode, - GrantTypes, - OAuth2Provider, - ResponseTypes, -) -from passbook.providers.oauth2.views.userinfo import UserInfoView -from passbook.stages.consent.models import ConsentMode, ConsentStage -from passbook.stages.consent.stage import ( - PLAN_CONTEXT_CONSENT_TEMPLATE, - ConsentStageView, -) - -LOGGER = get_logger() - -PLAN_CONTEXT_PARAMS = "params" -PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions" - -ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET} - - -@dataclass -class OAuthAuthorizationParams: - """Parameteres required to authorize an OAuth Client""" - - client_id: str - redirect_uri: str - response_type: str - scope: List[str] - state: str - nonce: str - prompt: Set[str] - grant_type: str - - provider: OAuth2Provider = field(default_factory=OAuth2Provider) - - code_challenge: Optional[str] = None - code_challenge_method: Optional[str] = None - - @staticmethod - def from_request(request: HttpRequest) -> "OAuthAuthorizationParams": - """ - Get all the params used by the Authorization Code Flow - (and also for the Implicit and Hybrid). - - See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest - """ - # Because in this endpoint we handle both GET - # and POST request. - query_dict = request.POST if request.method == "POST" else request.GET - - response_type = query_dict.get("response_type", "") - grant_type = None - # Determine which flow to use. - if response_type in [ResponseTypes.CODE, ResponseTypes.CODE_ADFS]: - grant_type = GrantTypes.AUTHORIZATION_CODE - elif response_type in [ - ResponseTypes.ID_TOKEN, - ResponseTypes.ID_TOKEN_TOKEN, - ResponseTypes.CODE_TOKEN, - ]: - grant_type = GrantTypes.IMPLICIT - elif response_type in [ - ResponseTypes.CODE_TOKEN, - ResponseTypes.CODE_ID_TOKEN, - ResponseTypes.CODE_ID_TOKEN_TOKEN, - ]: - grant_type = GrantTypes.HYBRID - - # Grant type validation. - if not grant_type: - LOGGER.warning("Invalid response type", type=response_type) - raise AuthorizeError( - query_dict.get("redirect_uri", ""), - "unsupported_response_type", - grant_type, - ) - - return OAuthAuthorizationParams( - client_id=query_dict.get("client_id", ""), - redirect_uri=query_dict.get("redirect_uri", ""), - response_type=response_type, - grant_type=grant_type, - scope=query_dict.get("scope", "").split(), - state=query_dict.get("state", ""), - nonce=query_dict.get("nonce", ""), - prompt=ALLOWED_PROMPT_PARAMS.intersection( - set(query_dict.get("prompt", "").split()) - ), - code_challenge=query_dict.get("code_challenge"), - code_challenge_method=query_dict.get("code_challenge_method"), - ) - - def __post_init__(self): - try: - self.provider: OAuth2Provider = OAuth2Provider.objects.get( - client_id=self.client_id - ) - except OAuth2Provider.DoesNotExist: - LOGGER.warning("Invalid client identifier", client_id=self.client_id) - raise ClientIdError() - is_open_id = SCOPE_OPENID in self.scope - - # Redirect URI validation. - if is_open_id and not self.redirect_uri: - LOGGER.warning("Missing redirect uri.") - raise RedirectUriError() - if self.redirect_uri.lower() not in [ - x.lower() for x in self.provider.redirect_uris.split() - ]: - LOGGER.warning( - "Invalid redirect uri", - redirect_uri=self.redirect_uri, - excepted=self.provider.redirect_uris.split(), - ) - raise RedirectUriError() - - if not is_open_id and ( - self.grant_type == GrantTypes.HYBRID - or self.response_type - in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN] - ): - LOGGER.warning("Missing 'openid' scope.") - raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type) - - # Nonce parameter validation. - if is_open_id and self.grant_type == GrantTypes.IMPLICIT and not self.nonce: - raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type) - - # Response type parameter validation. - if is_open_id: - actual_response_type = self.provider.response_type - if "#" in self.provider.response_type: - hash_index = actual_response_type.index("#") - actual_response_type = actual_response_type[:hash_index] - if self.response_type != actual_response_type: - raise AuthorizeError( - self.redirect_uri, "invalid_request", self.grant_type - ) - - # PKCE validation of the transformation method. - if self.code_challenge: - if not (self.code_challenge_method in ["plain", "S256"]): - raise AuthorizeError( - self.redirect_uri, "invalid_request", self.grant_type - ) - - def create_code(self, request: HttpRequest) -> AuthorizationCode: - """Create an AuthorizationCode object for the request""" - code = AuthorizationCode() - code.user = request.user - code.provider = self.provider - - code.code = uuid4().hex - - if self.code_challenge and self.code_challenge_method: - code.code_challenge = self.code_challenge - code.code_challenge_method = self.code_challenge_method - - code.expires_at = timezone.now() + timedelta_from_string( - self.provider.token_validity - ) - code.scope = self.scope - code.nonce = self.nonce - code.is_open_id = SCOPE_OPENID in self.scope - - return code - - -class OAuthFulfillmentStage(StageView): - """Final stage, restores params from Flow.""" - - params: OAuthAuthorizationParams - provider: OAuth2Provider - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - self.params: OAuthAuthorizationParams = self.executor.plan.context.pop( - PLAN_CONTEXT_PARAMS - ) - application: Application = self.executor.plan.context.pop( - PLAN_CONTEXT_APPLICATION - ) - self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id) - try: - # At this point we don't need to check permissions anymore - if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt): - raise AuthorizeError( - self.params.redirect_uri, - "consent_required", - self.params.grant_type, - ) - Event.new( - EventAction.AUTHORIZE_APPLICATION, - authorized_application=application, - flow=self.executor.plan.flow_pk, - ).from_http(self.request) - return redirect(self.create_response_uri()) - except (ClientIdError, RedirectUriError) as error: - self.executor.stage_invalid() - # pylint: disable=no-member - return bad_request_message(request, error.description, title=error.error) - except AuthorizeError as error: - self.executor.stage_invalid() - uri = error.create_uri(self.params.redirect_uri, self.params.state) - return redirect(uri) - - def create_response_uri(self) -> str: - """Create a final Response URI the user is redirected to.""" - uri = urlsplit(self.params.redirect_uri) - query_params = parse_qs(uri.query) - query_fragment = {} - - try: - code = None - - if self.params.grant_type in [ - GrantTypes.AUTHORIZATION_CODE, - GrantTypes.HYBRID, - ]: - code = self.params.create_code(self.request) - code.save() - - if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE: - query_params["code"] = code.code - query_params["state"] = [ - str(self.params.state) if self.params.state else "" - ] - elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]: - token = self.provider.create_refresh_token( - user=self.request.user, - scope=self.params.scope, - ) - - # Check if response_type must include access_token in the response. - if self.params.response_type in [ - ResponseTypes.ID_TOKEN_TOKEN, - ResponseTypes.CODE_ID_TOKEN_TOKEN, - ResponseTypes.ID_TOKEN, - ResponseTypes.CODE_TOKEN, - ]: - query_fragment["access_token"] = token.access_token - - # We don't need id_token if it's an OAuth2 request. - if SCOPE_OPENID in self.params.scope: - id_token = token.create_id_token( - user=self.request.user, - request=self.request, - ) - id_token.nonce = self.params.nonce - - # Include at_hash when access_token is being returned. - if "access_token" in query_fragment: - id_token.at_hash = token.at_hash - - # Check if response_type must include id_token in the response. - if self.params.response_type in [ - ResponseTypes.ID_TOKEN, - ResponseTypes.ID_TOKEN_TOKEN, - ResponseTypes.CODE_ID_TOKEN, - ResponseTypes.CODE_ID_TOKEN_TOKEN, - ]: - query_fragment["id_token"] = self.provider.encode( - id_token.to_dict() - ) - token.id_token = id_token - - # Store the token. - token.save() - - # Code parameter must be present if it's Hybrid Flow. - if self.params.grant_type == GrantTypes.HYBRID: - query_fragment["code"] = code.code - - query_fragment["token_type"] = "bearer" - query_fragment["expires_in"] = timedelta_from_string( - self.provider.token_validity - ).seconds - query_fragment["state"] = self.params.state if self.params.state else "" - - except OAuth2Error as error: - LOGGER.exception("Error when trying to create response uri", error=error) - raise AuthorizeError( - self.params.redirect_uri, "server_error", self.params.grant_type - ) - - uri = uri._replace( - query=urlencode(query_params, doseq=True), - fragment=uri.fragment + urlencode(query_fragment, doseq=True), - ) - - return urlunsplit(uri) - - -class AuthorizationFlowInitView(PolicyAccessView): - """OAuth2 Flow initializer, checks access to application and starts flow""" - - def resolve_provider_application(self): - client_id = self.request.GET.get("client_id") - self.provider = get_object_or_404(OAuth2Provider, client_id=client_id) - self.application = self.provider.application - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Check access to application, start FlowPLanner, return to flow executor shell""" - # Extract params so we can save them in the plan context - try: - params = OAuthAuthorizationParams.from_request(request) - except (ClientIdError, RedirectUriError) as error: - # pylint: disable=no-member - return bad_request_message(request, error.description, title=error.error) - # Regardless, we start the planner and return to it - planner = FlowPlanner(self.provider.authorization_flow) - # planner.use_cache = False - planner.allow_empty_flows = True - plan: FlowPlan = planner.plan( - self.request, - { - PLAN_CONTEXT_SSO: True, - PLAN_CONTEXT_APPLICATION: self.application, - # OAuth2 related params - PLAN_CONTEXT_PARAMS: params, - PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions( - params.scope - ), - # Consent related params - PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html", - }, - ) - # OpenID clients can specify a `prompt` parameter, and if its set to consent we - # need to inject a consent stage - if PROMPT_CONSNET in params.prompt: - if not any([isinstance(x, ConsentStageView) for x in plan.stages]): - # Plan does not have any consent stage, so we add an in-memory one - stage = ConsentStage( - name="OAuth2 Provider In-memory consent stage", - mode=ConsentMode.ALWAYS_REQUIRE, - ) - plan.append(stage) - plan.append(in_memory_stage(OAuthFulfillmentStage)) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - self.request.GET, - flow_slug=self.provider.authorization_flow.slug, - ) diff --git a/passbook/providers/oauth2/views/github.py b/passbook/providers/oauth2/views/github.py deleted file mode 100644 index 5f9ba02b..00000000 --- a/passbook/providers/oauth2/views/github.py +++ /dev/null @@ -1,69 +0,0 @@ -"""passbook pretend GitHub Views""" -from django.http import HttpRequest, HttpResponse, JsonResponse -from django.views import View - -from passbook.providers.oauth2.models import RefreshToken - - -class GitHubUserView(View): - """Emulate GitHub's /user API Endpoint""" - - def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse: - """Emulate GitHub's /user API Endpoint""" - user = token.user - return JsonResponse( - { - "login": user.username, - "id": user.pk, - "node_id": "", - "avatar_url": "", - "gravatar_id": "", - "url": "", - "html_url": "", - "followers_url": "", - "following_url": "", - "gists_url": "", - "starred_url": "", - "subscriptions_url": "", - "organizations_url": "", - "repos_url": "", - "events_url": "", - "received_events_url": "", - "type": "User", - "site_admin": False, - "name": user.name, - "company": "", - "blog": "", - "location": "", - "email": user.email, - "hireable": False, - "bio": "", - "public_repos": 0, - "public_gists": 0, - "followers": 0, - "following": 0, - "created_at": user.date_joined, - "updated_at": user.date_joined, - "private_gists": 0, - "total_private_repos": 0, - "owned_private_repos": 0, - "disk_usage": 0, - "collaborators": 0, - "two_factor_authentication": True, - "plan": { - "name": "None", - "space": 0, - "private_repos": 0, - "collaborators": 0, - }, - } - ) - - -class GitHubUserTeamsView(View): - """Emulate GitHub's /user/teams API Endpoint""" - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse: - """Emulate GitHub's /user/teams API Endpoint""" - return JsonResponse([], safe=False) diff --git a/passbook/providers/oauth2/views/introspection.py b/passbook/providers/oauth2/views/introspection.py deleted file mode 100644 index 51485384..00000000 --- a/passbook/providers/oauth2/views/introspection.py +++ /dev/null @@ -1,124 +0,0 @@ -"""passbook OAuth2 Token Introspection Views""" -from dataclasses import dataclass, field - -from django.http import HttpRequest, HttpResponse -from django.views import View -from structlog import get_logger - -from passbook.providers.oauth2.errors import TokenIntrospectionError -from passbook.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken -from passbook.providers.oauth2.utils import ( - TokenResponse, - extract_access_token, - extract_client_auth, -) - -LOGGER = get_logger() - - -@dataclass -class TokenIntrospectionParams: - """Parameters for Token Introspection""" - - token: RefreshToken - - provider: OAuth2Provider = field(init=False) - id_token: IDToken = field(init=False) - - def __post_init__(self): - if self.token.is_expired: - LOGGER.debug("Token is not valid") - raise TokenIntrospectionError() - - self.provider = self.token.provider - self.id_token = self.token.id_token - - if not self.token.id_token: - LOGGER.debug( - "token not an authentication token", - token=self.token, - ) - raise TokenIntrospectionError() - - def authenticate_basic(self, request: HttpRequest) -> bool: - """Attempt to authenticate via Basic auth of client_id:client_secret""" - client_id, client_secret = extract_client_auth(request) - if client_id == client_secret == "": - return False - if ( - client_id != self.provider.client_id - or client_secret != self.provider.client_secret - ): - LOGGER.debug("(basic) Provider for basic auth does not exist") - raise TokenIntrospectionError() - return True - - def authenticate_bearer(self, request: HttpRequest) -> bool: - """Attempt to authenticate via token sent as bearer header""" - body_token = extract_access_token(request) - if not body_token: - return False - tokens = RefreshToken.objects.filter(access_token=body_token).select_related( - "provider" - ) - if not tokens.exists(): - LOGGER.debug("(bearer) Token does not exist") - raise TokenIntrospectionError() - if tokens.first().provider != self.provider: - LOGGER.debug("(bearer) Token providers don't match") - raise TokenIntrospectionError() - return True - - @staticmethod - def from_request(request: HttpRequest) -> "TokenIntrospectionParams": - """Extract required Parameters from HTTP Request""" - raw_token = request.POST.get("token") - token_type_hint = request.POST.get("token_type_hint", "access_token") - token_filter = {token_type_hint: raw_token} - - if token_type_hint not in ["access_token", "refresh_token"]: - LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) - raise TokenIntrospectionError() - - try: - token: RefreshToken = RefreshToken.objects.select_related("provider").get( - **token_filter - ) - except RefreshToken.DoesNotExist: - LOGGER.debug("Token does not exist", token=raw_token) - raise TokenIntrospectionError() - - params = TokenIntrospectionParams(token=token) - if not any( - [params.authenticate_basic(request), params.authenticate_bearer(request)] - ): - LOGGER.debug("Not authenticated") - raise TokenIntrospectionError() - return params - - -class TokenIntrospectionView(View): - """Token Introspection - https://tools.ietf.org/html/rfc7662""" - - token: RefreshToken - params: TokenIntrospectionParams - provider: OAuth2Provider - id_token: IDToken - - def post(self, request: HttpRequest) -> HttpResponse: - """Introspection handler""" - try: - self.params = TokenIntrospectionParams.from_request(request) - - response_dic = {} - if self.params.id_token: - token_dict = self.params.id_token.to_dict() - for k in ("aud", "sub", "exp", "iat", "iss"): - response_dic[k] = token_dict[k] - response_dic["active"] = True - response_dic["client_id"] = self.params.token.provider.client_id - - return TokenResponse(response_dic) - except TokenIntrospectionError: - return TokenResponse({"active": False}) diff --git a/passbook/providers/oauth2/views/jwks.py b/passbook/providers/oauth2/views/jwks.py deleted file mode 100644 index 9a274665..00000000 --- a/passbook/providers/oauth2/views/jwks.py +++ /dev/null @@ -1,40 +0,0 @@ -"""passbook OAuth2 JWKS Views""" -from django.http import HttpRequest, HttpResponse, JsonResponse -from django.shortcuts import get_object_or_404 -from django.views import View -from jwkest import long_to_base64 -from jwkest.jwk import import_rsa_key - -from passbook.core.models import Application -from passbook.providers.oauth2.models import JWTAlgorithms, OAuth2Provider - - -class JWKSView(View): - """Show RSA Key data for Provider""" - - def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: - """Show RSA Key data for Provider""" - application = get_object_or_404(Application, slug=application_slug) - provider: OAuth2Provider = get_object_or_404( - OAuth2Provider, pk=application.provider_id - ) - - response_data = {} - - if provider.jwt_alg == JWTAlgorithms.RS256: - public_key = import_rsa_key(provider.rsa_key.key_data).publickey() - response_data["keys"] = [ - { - "kty": "RSA", - "alg": "RS256", - "use": "sig", - "kid": provider.rsa_key.kid, - "n": long_to_base64(public_key.n), - "e": long_to_base64(public_key.e), - } - ] - - response = JsonResponse(response_data) - response["Access-Control-Allow-Origin"] = "*" - - return response diff --git a/passbook/providers/oauth2/views/provider.py b/passbook/providers/oauth2/views/provider.py deleted file mode 100644 index 69b69e8a..00000000 --- a/passbook/providers/oauth2/views/provider.py +++ /dev/null @@ -1,74 +0,0 @@ -"""passbook OAuth2 OpenID well-known views""" -from typing import Any, Dict - -from django.http import HttpRequest, HttpResponse, JsonResponse -from django.shortcuts import get_object_or_404, reverse -from django.views import View -from structlog import get_logger - -from passbook.core.models import Application -from passbook.providers.oauth2.models import OAuth2Provider - -LOGGER = get_logger() - -PLAN_CONTEXT_PARAMS = "params" -PLAN_CONTEXT_SCOPES = "scopes" - - -class ProviderInfoView(View): - """OpenID-compliant Provider Info""" - - def get_info(self, provider: OAuth2Provider) -> Dict[str, Any]: - """Get dictionary for OpenID Connect information""" - return { - "issuer": provider.get_issuer(self.request), - "authorization_endpoint": self.request.build_absolute_uri( - reverse("passbook_providers_oauth2:authorize") - ), - "token_endpoint": self.request.build_absolute_uri( - reverse("passbook_providers_oauth2:token") - ), - "userinfo_endpoint": self.request.build_absolute_uri( - reverse("passbook_providers_oauth2:userinfo") - ), - "end_session_endpoint": self.request.build_absolute_uri( - reverse( - "passbook_providers_oauth2:end-session", - kwargs={"application_slug": provider.application.slug}, - ) - ), - "introspection_endpoint": self.request.build_absolute_uri( - reverse("passbook_providers_oauth2:token-introspection") - ), - "response_types_supported": [provider.response_type], - "jwks_uri": self.request.build_absolute_uri( - reverse( - "passbook_providers_oauth2:jwks", - kwargs={"application_slug": provider.application.slug}, - ) - ), - "id_token_signing_alg_values_supported": [provider.jwt_alg], - # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes - "subject_types_supported": ["public"], - "token_endpoint_auth_methods_supported": [ - "client_secret_post", - "client_secret_basic", - ], - } - - # pylint: disable=unused-argument - def get( - self, request: HttpRequest, application_slug: str, *args, **kwargs - ) -> HttpResponse: - """OpenID-compliant Provider Info""" - - application = get_object_or_404(Application, slug=application_slug) - provider: OAuth2Provider = get_object_or_404( - OAuth2Provider, pk=application.provider_id - ) - response = JsonResponse( - self.get_info(provider), json_dumps_params={"indent": 2} - ) - response["Access-Control-Allow-Origin"] = "*" - - return response diff --git a/passbook/providers/oauth2/views/session.py b/passbook/providers/oauth2/views/session.py deleted file mode 100644 index 1a889a90..00000000 --- a/passbook/providers/oauth2/views/session.py +++ /dev/null @@ -1,22 +0,0 @@ -"""passbook OAuth2 Session Views""" -from typing import Any, Dict - -from django.shortcuts import get_object_or_404 -from django.views.generic.base import TemplateView - -from passbook.core.models import Application - - -class EndSessionView(TemplateView): - """Allow the client to end the Session""" - - template_name = "providers/oauth2/end_session.html" - - def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: - context = super().get_context_data(**kwargs) - - context["application"] = get_object_or_404( - Application, slug=self.kwargs["application_slug"] - ) - - return context diff --git a/passbook/providers/oauth2/views/token.py b/passbook/providers/oauth2/views/token.py deleted file mode 100644 index 2710291d..00000000 --- a/passbook/providers/oauth2/views/token.py +++ /dev/null @@ -1,256 +0,0 @@ -"""passbook OAuth2 Token views""" -from base64 import urlsafe_b64encode -from dataclasses import InitVar, dataclass -from hashlib import sha256 -from typing import Any, Dict, List, Optional - -from django.http import HttpRequest, HttpResponse -from django.views import View -from structlog import get_logger - -from passbook.lib.utils.time import timedelta_from_string -from passbook.providers.oauth2.constants import ( - GRANT_TYPE_AUTHORIZATION_CODE, - GRANT_TYPE_REFRESH_TOKEN, -) -from passbook.providers.oauth2.errors import TokenError, UserAuthError -from passbook.providers.oauth2.models import ( - AuthorizationCode, - OAuth2Provider, - RefreshToken, - ResponseTypes, -) -from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth - -LOGGER = get_logger() - - -@dataclass -class TokenParams: - """Token params""" - - client_id: str - client_secret: str - redirect_uri: str - grant_type: str - state: str - scope: List[str] - - authorization_code: Optional[AuthorizationCode] = None - refresh_token: Optional[RefreshToken] = None - - code_verifier: Optional[str] = None - - raw_code: InitVar[str] = "" - raw_token: InitVar[str] = "" - - @staticmethod - def from_request(request: HttpRequest) -> "TokenParams": - """Extract Token Parameters from http request""" - client_id, client_secret = extract_client_auth(request) - - return TokenParams( - client_id=client_id, - client_secret=client_secret, - redirect_uri=request.POST.get("redirect_uri", ""), - grant_type=request.POST.get("grant_type", ""), - raw_code=request.POST.get("code", ""), - raw_token=request.POST.get("refresh_token", ""), - state=request.POST.get("state", ""), - scope=request.POST.get("scope", "").split(), - # PKCE parameter. - code_verifier=request.POST.get("code_verifier"), - ) - - def __post_init__(self, raw_code, raw_token): - try: - provider: OAuth2Provider = OAuth2Provider.objects.get( - client_id=self.client_id - ) - self.provider = provider - except OAuth2Provider.DoesNotExist: - LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id) - raise TokenError("invalid_client") - - if self.provider.client_type == "confidential": - if self.provider.client_secret != self.client_secret: - LOGGER.warning( - "Invalid client secret: client does not have secret", - client_id=self.provider.client_id, - secret=self.provider.client_secret, - ) - raise TokenError("invalid_client") - - if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: - self.__post_init_code(raw_code) - - elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN: - if not raw_token: - LOGGER.warning("Missing refresh token") - raise TokenError("invalid_grant") - - try: - self.refresh_token = RefreshToken.objects.get( - refresh_token=raw_token, provider=self.provider - ) - - except RefreshToken.DoesNotExist: - LOGGER.warning( - "Refresh token does not exist", - token=raw_token, - ) - raise TokenError("invalid_grant") - - else: - LOGGER.warning("Invalid grant type", grant_type=self.grant_type) - raise TokenError("unsupported_grant_type") - - def __post_init_code(self, raw_code): - if not raw_code: - LOGGER.warning("Missing authorization code") - raise TokenError("invalid_grant") - - if self.redirect_uri not in self.provider.redirect_uris.split(): - LOGGER.warning( - "Invalid redirect uri", - uri=self.redirect_uri, - expected=self.provider.redirect_uris.split(), - ) - raise TokenError("invalid_client") - - try: - self.authorization_code = AuthorizationCode.objects.get(code=raw_code) - except AuthorizationCode.DoesNotExist: - LOGGER.warning("Code does not exist", code=raw_code) - raise TokenError("invalid_grant") - - if ( - self.authorization_code.provider != self.provider - or self.authorization_code.is_expired - ): - LOGGER.warning("Invalid code: invalid client or code has expired") - raise TokenError("invalid_grant") - - # Validate PKCE parameters. - if self.code_verifier: - if self.authorization_code.code_challenge_method == "S256": - new_code_challenge = ( - urlsafe_b64encode( - sha256(self.code_verifier.encode("ascii")).digest() - ) - .decode("utf-8") - .replace("=", "") - ) - else: - new_code_challenge = self.code_verifier - - if new_code_challenge != self.authorization_code.code_challenge: - LOGGER.warning("Code challenge not matching") - raise TokenError("invalid_grant") - - -class TokenView(View): - """Generate tokens for clients""" - - params: TokenParams - - def post(self, request: HttpRequest) -> HttpResponse: - """Generate tokens for clients""" - try: - self.params = TokenParams.from_request(request) - - if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: - return TokenResponse(self.create_code_response_dic()) - if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: - return TokenResponse(self.create_refresh_response_dic()) - raise ValueError(f"Invalid grant_type: {self.params.grant_type}") - except TokenError as error: - return TokenResponse(error.create_dict(), status=400) - except UserAuthError as error: - return TokenResponse(error.create_dict(), status=403) - - def create_code_response_dic(self) -> Dict[str, Any]: - """See https://tools.ietf.org/html/rfc6749#section-4.1""" - - refresh_token = self.params.authorization_code.provider.create_refresh_token( - user=self.params.authorization_code.user, - scope=self.params.authorization_code.scope, - ) - - if self.params.authorization_code.is_open_id: - id_token = refresh_token.create_id_token( - user=self.params.authorization_code.user, - request=self.request, - ) - id_token.nonce = self.params.authorization_code.nonce - id_token.at_hash = refresh_token.at_hash - refresh_token.id_token = id_token - - # Store the token. - refresh_token.save() - - # We don't need to store the code anymore. - self.params.authorization_code.delete() - - response_dict = { - "access_token": refresh_token.access_token, - "refresh_token": refresh_token.refresh_token, - "token_type": "Bearer", - "expires_in": timedelta_from_string( - self.params.provider.token_validity - ).seconds, - "id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()), - } - - if self.params.provider.response_type == ResponseTypes.CODE_ADFS: - # This seems to be expected by some OIDC Clients - # namely VMware vCenter. This is not documented in any OpenID or OAuth2 Standard. - # Maybe this should be a setting - # in the future? - response_dict["access_token"] = response_dict["id_token"] - - return response_dict - - def create_refresh_response_dic(self) -> Dict[str, Any]: - """See https://tools.ietf.org/html/rfc6749#section-6""" - - unauthorized_scopes = set(self.params.scope) - set( - self.params.refresh_token.scope - ) - if unauthorized_scopes: - raise TokenError("invalid_scope") - - provider: OAuth2Provider = self.params.refresh_token.provider - - refresh_token: RefreshToken = provider.create_refresh_token( - user=self.params.refresh_token.user, - scope=self.params.scope, - ) - - # If the Token has an id_token it's an Authentication request. - if self.params.refresh_token.id_token: - refresh_token.id_token = refresh_token.create_id_token( - user=self.params.refresh_token.user, - request=self.request, - ) - refresh_token.id_token.at_hash = refresh_token.at_hash - - # Store the refresh_token. - refresh_token.save() - - # Forget the old token. - self.params.refresh_token.delete() - - dic = { - "access_token": refresh_token.access_token, - "refresh_token": refresh_token.refresh_token, - "token_type": "bearer", - "expires_in": timedelta_from_string( - refresh_token.provider.token_validity - ).seconds, - "id_token": self.params.provider.encode( - self.params.refresh_token.id_token.to_dict() - ), - } - - return dic diff --git a/passbook/providers/oauth2/views/userinfo.py b/passbook/providers/oauth2/views/userinfo.py deleted file mode 100644 index eb1d324f..00000000 --- a/passbook/providers/oauth2/views/userinfo.py +++ /dev/null @@ -1,92 +0,0 @@ -"""passbook OAuth2 OpenID Userinfo views""" -from typing import Any, Dict, List - -from django.http import HttpRequest, HttpResponse -from django.utils.translation import gettext_lazy as _ -from django.views import View -from structlog import get_logger - -from passbook.providers.oauth2.constants import ( - SCOPE_GITHUB_ORG_READ, - SCOPE_GITHUB_USER, - SCOPE_GITHUB_USER_EMAIL, - SCOPE_GITHUB_USER_READ, -) -from passbook.providers.oauth2.models import RefreshToken, ScopeMapping -from passbook.providers.oauth2.utils import TokenResponse, cors_allow_any - -LOGGER = get_logger() - - -class UserInfoView(View): - """Create a dictionary with all the requested claims about the End-User. - See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse""" - - def get_scope_descriptions(self, scopes: List[str]) -> Dict[str, str]: - """Get a list of all Scopes's descriptions""" - scope_descriptions = {} - for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by( - "scope_name" - ): - if scope.description != "": - scope_descriptions[scope.scope_name] = scope.description - # GitHub Compatibility Scopes are handeled differently, since they required custom paths - # Hence they don't exist as Scope objects - github_scope_map = { - SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"), - SCOPE_GITHUB_USER_READ: _( - "GitHub Compatibility: Access your User Information" - ), - SCOPE_GITHUB_USER_EMAIL: _( - "GitHub Compatibility: Access you Email addresses" - ), - SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"), - } - for scope in scopes: - if scope in github_scope_map: - scope_descriptions[scope] = github_scope_map[scope] - return scope_descriptions - - def get_claims(self, token: RefreshToken) -> Dict[str, Any]: - """Get a dictionary of claims from scopes that the token - requires and are assigned to the provider.""" - - scopes_from_client = token.scope - final_claims = {} - for scope in ScopeMapping.objects.filter( - provider=token.provider, scope_name__in=scopes_from_client - ).order_by("scope_name"): - value = scope.evaluate( - user=token.user, - request=self.request, - provider=token.provider, - token=token, - ) - if value is None: - continue - if not isinstance(value, dict): - LOGGER.warning( - "Scope returned a non-dict value, ignoring", - scope=scope, - value=value, - ) - continue - LOGGER.debug("updated scope", scope=scope) - final_claims.update(value) - return final_claims - - def options(self, request: HttpRequest) -> HttpResponse: - return cors_allow_any(self.request, TokenResponse({})) - - def get(self, request: HttpRequest, **kwargs) -> HttpResponse: - """Handle GET Requests for UserInfo""" - token: RefreshToken = kwargs["token"] - claims = self.get_claims(token) - claims["sub"] = token.id_token.sub - response = TokenResponse(claims) - cors_allow_any(self.request, response) - return response - - def post(self, request: HttpRequest, **kwargs) -> HttpResponse: - """POST Requests behave the same as GET Requests, so the get handler is called here""" - return self.get(request, **kwargs) diff --git a/passbook/providers/proxy/api.py b/passbook/providers/proxy/api.py deleted file mode 100644 index bfc9ea1e..00000000 --- a/passbook/providers/proxy/api.py +++ /dev/null @@ -1,118 +0,0 @@ -"""ProxyProvider API Views""" -from drf_yasg2.utils import swagger_serializer_method -from rest_framework.fields import CharField, ListField, SerializerMethodField -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer, Serializer -from rest_framework.viewsets import ModelViewSet - -from passbook.providers.oauth2.views.provider import ProviderInfoView -from passbook.providers.proxy.models import ProxyProvider - - -class OpenIDConnectConfigurationSerializer(Serializer): - """rest_framework Serializer for OIDC Configuration""" - - issuer = CharField() - authorization_endpoint = CharField() - token_endpoint = CharField() - userinfo_endpoint = CharField() - end_session_endpoint = CharField() - introspection_endpoint = CharField() - jwks_uri = CharField() - - response_types_supported = ListField(child=CharField()) - id_token_signing_alg_values_supported = ListField(child=CharField()) - subject_types_supported = ListField(child=CharField()) - token_endpoint_auth_methods_supported = ListField(child=CharField()) - - def create(self, request: Request) -> Response: - raise NotImplementedError - - def update(self, request: Request) -> Response: - raise NotImplementedError - - -class ProxyProviderSerializer(ModelSerializer): - """ProxyProvider Serializer""" - - def create(self, validated_data): - instance: ProxyProvider = super().create(validated_data) - instance.set_oauth_defaults() - instance.save() - return instance - - def update(self, instance: ProxyProvider, validated_data): - instance.set_oauth_defaults() - return super().update(instance, validated_data) - - class Meta: - - model = ProxyProvider - fields = [ - "pk", - "name", - "internal_host", - "external_host", - "internal_host_ssl_validation", - "certificate", - "skip_path_regex", - "basic_auth_enabled", - "basic_auth_password_attribute", - "basic_auth_user_attribute", - ] - - -class ProxyProviderViewSet(ModelViewSet): - """ProxyProvider Viewset""" - - queryset = ProxyProvider.objects.all() - serializer_class = ProxyProviderSerializer - - -class ProxyOutpostConfigSerializer(ModelSerializer): - """ProxyProvider Serializer""" - - oidc_configuration = SerializerMethodField() - - def create(self, validated_data): - instance: ProxyProvider = super().create(validated_data) - instance.set_oauth_defaults() - instance.save() - return instance - - def update(self, instance: ProxyProvider, validated_data): - instance.set_oauth_defaults() - return super().update(instance, validated_data) - - class Meta: - - model = ProxyProvider - fields = [ - "pk", - "name", - "internal_host", - "external_host", - "internal_host_ssl_validation", - "client_id", - "client_secret", - "oidc_configuration", - "cookie_secret", - "certificate", - "skip_path_regex", - "basic_auth_enabled", - "basic_auth_password_attribute", - "basic_auth_user_attribute", - ] - - @swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer) - def get_oidc_configuration(self, obj: ProxyProvider): - """Embed OpenID Connect provider information""" - return ProviderInfoView(request=self.context["request"]._request).get_info(obj) - - -class ProxyOutpostConfigViewSet(ModelViewSet): - """ProxyProvider Viewset""" - - queryset = ProxyProvider.objects.filter(application__isnull=False) - serializer_class = ProxyOutpostConfigSerializer diff --git a/passbook/providers/proxy/apps.py b/passbook/providers/proxy/apps.py deleted file mode 100644 index 2b64d224..00000000 --- a/passbook/providers/proxy/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook Proxy app""" -from django.apps import AppConfig - - -class PassbookProviderProxyConfig(AppConfig): - """passbook proxy app""" - - name = "passbook.providers.proxy" - label = "passbook_providers_proxy" - verbose_name = "passbook Providers.Proxy" diff --git a/passbook/providers/proxy/controllers/docker.py b/passbook/providers/proxy/controllers/docker.py deleted file mode 100644 index 465b4f8c..00000000 --- a/passbook/providers/proxy/controllers/docker.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Proxy Provider Docker Contoller""" -from typing import Dict -from urllib.parse import urlparse - -from passbook.outposts.controllers.docker import DockerController -from passbook.outposts.models import DockerServiceConnection, Outpost -from passbook.providers.proxy.models import ProxyProvider - - -class ProxyDockerController(DockerController): - """Proxy Provider Docker Contoller""" - - def __init__(self, outpost: Outpost, connection: DockerServiceConnection): - super().__init__(outpost, connection) - self.deployment_ports = { - "http": 4180, - "https": 4443, - } - - def _get_labels(self) -> Dict[str, str]: - hosts = [] - for proxy_provider in ProxyProvider.objects.filter(outpost__in=[self.outpost]): - proxy_provider: ProxyProvider - external_host_name = urlparse(proxy_provider.external_host) - hosts.append(f"`{external_host_name}`") - traefik_name = f"pb-outpost-{self.outpost.pk.hex}" - return { - "traefik.enable": "true", - f"traefik.http.routers.{traefik_name}-router.rule": f"Host({','.join(hosts)})", - f"traefik.http.routers.{traefik_name}-router.tls": "true", - f"traefik.http.routers.{traefik_name}-router.service": f"{traefik_name}-service", - f"traefik.http.services.{traefik_name}-service.loadbalancer.healthcheck.path": "/", - f"traefik.http.services.{traefik_name}-service.loadbalancer.server.port": "4180", - } diff --git a/passbook/providers/proxy/controllers/k8s/ingress.py b/passbook/providers/proxy/controllers/k8s/ingress.py deleted file mode 100644 index 672f58c9..00000000 --- a/passbook/providers/proxy/controllers/k8s/ingress.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Kubernetes Ingress Reconciler""" -from typing import TYPE_CHECKING, Dict -from urllib.parse import urlparse - -from kubernetes.client import ( - NetworkingV1beta1Api, - NetworkingV1beta1HTTPIngressPath, - NetworkingV1beta1HTTPIngressRuleValue, - NetworkingV1beta1Ingress, - NetworkingV1beta1IngressBackend, - NetworkingV1beta1IngressSpec, - NetworkingV1beta1IngressTLS, -) -from kubernetes.client.models.networking_v1beta1_ingress_rule import ( - NetworkingV1beta1IngressRule, -) - -from passbook.outposts.controllers.k8s.base import ( - KubernetesObjectReconciler, - NeedsUpdate, -) -from passbook.providers.proxy.models import ProxyProvider - -if TYPE_CHECKING: - from passbook.outposts.controllers.kubernetes import KubernetesController - - -class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): - """Kubernetes Ingress Reconciler""" - - def __init__(self, controller: "KubernetesController") -> None: - super().__init__(controller) - self.api = NetworkingV1beta1Api(controller.client) - - @property - def name(self) -> str: - return f"passbook-outpost-{self.controller.outpost.uuid.hex}" - - def reconcile( - self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress - ): - # Create a list of all expected host and tls hosts - expected_hosts = [] - expected_hosts_tls = [] - for proxy_provider in ProxyProvider.objects.filter( - outpost__in=[self.controller.outpost] - ): - proxy_provider: ProxyProvider - external_host_name = urlparse(proxy_provider.external_host) - expected_hosts.append(external_host_name.hostname) - if external_host_name.scheme == "https": - expected_hosts_tls.append(external_host_name.hostname) - expected_hosts.sort() - expected_hosts_tls.sort() - - have_hosts = [rule.host for rule in reference.spec.rules] - have_hosts.sort() - - have_hosts_tls = [] - for tls_config in reference.spec.tls: - if tls_config: - have_hosts_tls += tls_config.hosts - have_hosts_tls.sort() - - if have_hosts != expected_hosts: - raise NeedsUpdate() - if have_hosts_tls != expected_hosts_tls: - raise NeedsUpdate() - - def get_ingress_annotations(self) -> Dict[str, str]: - """Get ingress annotations""" - annotations = { - # Ensure that with multiple proxy replicas deployed, the same CSRF request - # goes to the same pod - "nginx.ingress.kubernetes.io/affinity": "cookie", - "traefik.ingress.kubernetes.io/affinity": "true", - } - annotations.update( - self.controller.outpost.config.kubernetes_ingress_annotations - ) - return dict() - - def get_reference_object(self) -> NetworkingV1beta1Ingress: - """Get deployment object for outpost""" - meta = self.get_object_meta( - name=self.name, - annotations=self.get_ingress_annotations(), - ) - rules = [] - tls_hosts = [] - for proxy_provider in ProxyProvider.objects.filter( - outpost__in=[self.controller.outpost] - ): - proxy_provider: ProxyProvider - external_host_name = urlparse(proxy_provider.external_host) - if external_host_name.scheme == "https": - tls_hosts.append(external_host_name.hostname) - rule = NetworkingV1beta1IngressRule( - host=external_host_name.hostname, - http=NetworkingV1beta1HTTPIngressRuleValue( - paths=[ - NetworkingV1beta1HTTPIngressPath( - backend=NetworkingV1beta1IngressBackend( - service_name=self.name, - service_port=self.controller.deployment_ports["http"], - ), - path="/", - ) - ] - ), - ) - rules.append(rule) - tls_config = None - if tls_hosts: - tls_config = NetworkingV1beta1IngressTLS( - hosts=tls_hosts, - secret_name=self.controller.outpost.config.kubernetes_ingress_secret_name, - ) - return NetworkingV1beta1Ingress( - metadata=meta, - spec=NetworkingV1beta1IngressSpec(rules=rules, tls=[tls_config]), - ) - - def create(self, reference: NetworkingV1beta1Ingress): - return self.api.create_namespaced_ingress(self.namespace, reference) - - def delete(self, reference: NetworkingV1beta1Ingress): - return self.api.delete_namespaced_ingress( - reference.metadata.name, self.namespace - ) - - def retrieve(self) -> NetworkingV1beta1Ingress: - return self.api.read_namespaced_ingress(self.name, self.namespace) - - def update( - self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress - ): - return self.api.patch_namespaced_ingress( - current.metadata.name, self.namespace, reference - ) diff --git a/passbook/providers/proxy/controllers/kubernetes.py b/passbook/providers/proxy/controllers/kubernetes.py deleted file mode 100644 index 85193835..00000000 --- a/passbook/providers/proxy/controllers/kubernetes.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Proxy Provider Kubernetes Contoller""" -from passbook.outposts.controllers.kubernetes import KubernetesController -from passbook.outposts.models import KubernetesServiceConnection, Outpost -from passbook.providers.proxy.controllers.k8s.ingress import IngressReconciler - - -class ProxyKubernetesController(KubernetesController): - """Proxy Provider Kubernetes Contoller""" - - def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): - super().__init__(outpost, connection) - self.deployment_ports = { - "http": 4180, - "https": 4443, - } - self.reconcilers["ingress"] = IngressReconciler - self.reconcile_order.append("ingress") diff --git a/passbook/providers/proxy/forms.py b/passbook/providers/proxy/forms.py deleted file mode 100644 index e865c25a..00000000 --- a/passbook/providers/proxy/forms.py +++ /dev/null @@ -1,50 +0,0 @@ -"""passbook Proxy Provider Forms""" -from django import forms - -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow, FlowDesignation -from passbook.providers.proxy.models import ProxyProvider - - -class ProxyProviderForm(forms.ModelForm): - """Security Gateway Provider form""" - - instance: ProxyProvider - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authorization_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHORIZATION - ) - self.fields["certificate"].queryset = CertificateKeyPair.objects.filter( - key_data__isnull=False - ) - - def save(self, *args, **kwargs): - actual_save = super().save(*args, **kwargs) - self.instance.set_oauth_defaults() - self.instance.save() - return actual_save - - class Meta: - - model = ProxyProvider - fields = [ - "name", - "authorization_flow", - "internal_host", - "internal_host_ssl_validation", - "external_host", - "certificate", - "skip_path_regex", - "basic_auth_enabled", - "basic_auth_user_attribute", - "basic_auth_password_attribute", - ] - widgets = { - "name": forms.TextInput(), - "internal_host": forms.TextInput(), - "external_host": forms.TextInput(), - "basic_auth_user_attribute": forms.TextInput(), - "basic_auth_password_attribute": forms.TextInput(), - } diff --git a/passbook/providers/proxy/migrations/0001_initial.py b/passbook/providers/proxy/migrations/0001_initial.py deleted file mode 100644 index 19cfc62a..00000000 --- a/passbook/providers/proxy/migrations/0001_initial.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 3.1 on 2020-08-18 18:16 - -import django.core.validators -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_providers_oauth2", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="ProxyProvider", - fields=[ - ( - "oauth2provider_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_providers_oauth2.oauth2provider", - ), - ), - ( - "internal_host", - models.TextField( - validators=[ - django.core.validators.URLValidator( - schemes=("http", "https") - ) - ] - ), - ), - ( - "external_host", - models.TextField( - validators=[ - django.core.validators.URLValidator( - schemes=("http", "https") - ) - ] - ), - ), - ], - options={ - "verbose_name": "Proxy Provider", - "verbose_name_plural": "Proxy Providers", - }, - bases=("passbook_providers_oauth2.oauth2provider",), - ), - ] diff --git a/passbook/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py b/passbook/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py deleted file mode 100644 index a98b4120..00000000 --- a/passbook/providers/proxy/migrations/0002_proxyprovider_cookie_secret.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1 on 2020-08-19 14:50 - -from django.db import migrations, models - -import passbook.providers.proxy.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_proxy", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="proxyprovider", - name="cookie_secret", - field=models.TextField( - default=passbook.providers.proxy.models.get_cookie_secret - ), - ), - ] diff --git a/passbook/providers/proxy/migrations/0003_proxyprovider_certificate.py b/passbook/providers/proxy/migrations/0003_proxyprovider_certificate.py deleted file mode 100644 index 7d8f2561..00000000 --- a/passbook/providers/proxy/migrations/0003_proxyprovider_certificate.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.1 on 2020-08-23 22:46 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0002_create_self_signed_kp"), - ("passbook_providers_proxy", "0002_proxyprovider_cookie_secret"), - ] - - operations = [ - migrations.AddField( - model_name="proxyprovider", - name="certificate", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="passbook_crypto.certificatekeypair", - ), - ), - ] diff --git a/passbook/providers/proxy/migrations/0004_auto_20200913_1947.py b/passbook/providers/proxy/migrations/0004_auto_20200913_1947.py deleted file mode 100644 index 518850aa..00000000 --- a/passbook/providers/proxy/migrations/0004_auto_20200913_1947.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-13 19:47 - -from django.db import migrations, models - -import passbook.lib.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_proxy", "0003_proxyprovider_certificate"), - ] - - operations = [ - migrations.AlterField( - model_name="proxyprovider", - name="external_host", - field=models.TextField( - validators=[ - passbook.lib.models.DomainlessURLValidator( - schemes=("http", "https") - ) - ] - ), - ), - migrations.AlterField( - model_name="proxyprovider", - name="internal_host", - field=models.TextField( - validators=[ - passbook.lib.models.DomainlessURLValidator( - schemes=("http", "https") - ) - ] - ), - ), - ] diff --git a/passbook/providers/proxy/migrations/0005_auto_20200914_1536.py b/passbook/providers/proxy/migrations/0005_auto_20200914_1536.py deleted file mode 100644 index af5ba200..00000000 --- a/passbook/providers/proxy/migrations/0005_auto_20200914_1536.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-14 15:36 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0002_create_self_signed_kp"), - ("passbook_providers_proxy", "0004_auto_20200913_1947"), - ] - - operations = [ - migrations.AlterField( - model_name="proxyprovider", - name="certificate", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="passbook_crypto.certificatekeypair", - ), - ), - ] diff --git a/passbook/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py b/passbook/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py deleted file mode 100644 index 7b9860d4..00000000 --- a/passbook/providers/proxy/migrations/0006_proxyprovider_skip_path_regex.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-19 09:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_proxy", "0005_auto_20200914_1536"), - ] - - operations = [ - migrations.AddField( - model_name="proxyprovider", - name="skip_path_regex", - field=models.TextField( - blank=True, - default="", - help_text="Regular expression for which authentication is not required. Each new line is interpreted as a new Regular Expression.", - ), - ), - ] diff --git a/passbook/providers/proxy/migrations/0007_auto_20200923_1017.py b/passbook/providers/proxy/migrations/0007_auto_20200923_1017.py deleted file mode 100644 index 75ed6047..00000000 --- a/passbook/providers/proxy/migrations/0007_auto_20200923_1017.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-23 10:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_proxy", "0006_proxyprovider_skip_path_regex"), - ] - - operations = [ - migrations.AddField( - model_name="proxyprovider", - name="internal_host_ssl_validation", - field=models.BooleanField( - default=True, help_text="Validate SSL Certificates of upstream servers" - ), - ), - migrations.AlterField( - model_name="proxyprovider", - name="skip_path_regex", - field=models.TextField( - blank=True, - default="", - help_text="Regular expressions for which authentication is not required. Each new line is interpreted as a new Regular Expression.", - ), - ), - ] diff --git a/passbook/providers/proxy/migrations/0008_auto_20200930_0810.py b/passbook/providers/proxy/migrations/0008_auto_20200930_0810.py deleted file mode 100644 index 9ab9e09b..00000000 --- a/passbook/providers/proxy/migrations/0008_auto_20200930_0810.py +++ /dev/null @@ -1,78 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-30 08:10 - -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -SCOPE_PB_PROXY_EXPRESSION = """return { - "pb_proxy": { - "user_attributes": user.group_attributes() - } -}""" - - -def create_proxy_scope(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - from passbook.providers.proxy.models import SCOPE_PB_PROXY, ProxyProvider - - ScopeMapping = apps.get_model("passbook_providers_oauth2", "ScopeMapping") - - ScopeMapping.objects.update_or_create( - scope_name=SCOPE_PB_PROXY, - defaults={ - "name": "Autogenerated OAuth2 Mapping: passbook Proxy", - "scope_name": SCOPE_PB_PROXY, - "description": "", - "expression": SCOPE_PB_PROXY_EXPRESSION, - }, - ) - - for provider in ProxyProvider.objects.all(): - provider.set_oauth_defaults() - provider.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_proxy", "0007_auto_20200923_1017"), - ] - - operations = [ - migrations.AlterField( - model_name="proxyprovider", - name="internal_host_ssl_validation", - field=models.BooleanField( - default=True, - help_text="Validate SSL Certificates of upstream servers", - verbose_name="Internal host SSL Validation", - ), - ), - migrations.AddField( - model_name="proxyprovider", - name="basic_auth_enabled", - field=models.BooleanField( - default=False, - help_text="Set a custom HTTP-Basic Authentication header based on values from passbook.", - verbose_name="Set HTTP-Basic Authentication", - ), - ), - migrations.AddField( - model_name="proxyprovider", - name="basic_auth_password_attribute", - field=models.TextField( - blank=True, - help_text="User Attribute used for the password part of the HTTP-Basic Header.", - verbose_name="HTTP-Basic Password", - ), - ), - migrations.AddField( - model_name="proxyprovider", - name="basic_auth_user_attribute", - field=models.TextField( - blank=True, - help_text="User Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.", - verbose_name="HTTP-Basic Username", - ), - ), - migrations.RunPython(create_proxy_scope), - ] diff --git a/passbook/providers/proxy/migrations/0009_auto_20201007_1721.py b/passbook/providers/proxy/migrations/0009_auto_20201007_1721.py deleted file mode 100644 index b9eb84bc..00000000 --- a/passbook/providers/proxy/migrations/0009_auto_20201007_1721.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-07 17:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_proxy", "0008_auto_20200930_0810"), - ] - - operations = [ - migrations.AlterField( - model_name="proxyprovider", - name="basic_auth_password_attribute", - field=models.TextField( - blank=True, - help_text="User/Group Attribute used for the password part of the HTTP-Basic Header.", - verbose_name="HTTP-Basic Password Key", - ), - ), - migrations.AlterField( - model_name="proxyprovider", - name="basic_auth_user_attribute", - field=models.TextField( - blank=True, - help_text="User/Group Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.", - verbose_name="HTTP-Basic Username Key", - ), - ), - ] diff --git a/passbook/providers/proxy/models.py b/passbook/providers/proxy/models.py deleted file mode 100644 index 7e573002..00000000 --- a/passbook/providers/proxy/models.py +++ /dev/null @@ -1,154 +0,0 @@ -"""passbook proxy models""" -import string -from random import SystemRandom -from typing import Iterable, Optional, Type -from urllib.parse import urljoin - -from django.db import models -from django.forms import ModelForm -from django.http import HttpRequest -from django.utils.translation import gettext as _ - -from passbook.crypto.models import CertificateKeyPair -from passbook.lib.models import DomainlessURLValidator -from passbook.outposts.models import OutpostModel -from passbook.providers.oauth2.constants import ( - SCOPE_OPENID, - SCOPE_OPENID_EMAIL, - SCOPE_OPENID_PROFILE, -) -from passbook.providers.oauth2.models import ( - ClientTypes, - JWTAlgorithms, - OAuth2Provider, - ResponseTypes, - ScopeMapping, -) - -SCOPE_PB_PROXY = "pb_proxy" - - -def get_cookie_secret(): - """Generate random 32-character string for cookie-secret""" - return "".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32) - ) - - -def _get_callback_url(uri: str) -> str: - return urljoin(uri, "/pbprox/callback") - - -class ProxyProvider(OutpostModel, OAuth2Provider): - """Protect applications that don't support any of the other - Protocols by using a Reverse-Proxy.""" - - internal_host = models.TextField( - validators=[DomainlessURLValidator(schemes=("http", "https"))] - ) - external_host = models.TextField( - validators=[DomainlessURLValidator(schemes=("http", "https"))] - ) - internal_host_ssl_validation = models.BooleanField( - default=True, - help_text=_("Validate SSL Certificates of upstream servers"), - verbose_name=_("Internal host SSL Validation"), - ) - - skip_path_regex = models.TextField( - default="", - blank=True, - help_text=_( - ( - "Regular expressions for which authentication is not required. " - "Each new line is interpreted as a new Regular Expression." - ) - ), - ) - - basic_auth_enabled = models.BooleanField( - default=False, - verbose_name=_("Set HTTP-Basic Authentication"), - help_text=_( - "Set a custom HTTP-Basic Authentication header based on values from passbook." - ), - ) - basic_auth_user_attribute = models.TextField( - blank=True, - verbose_name=_("HTTP-Basic Username Key"), - help_text=_( - ( - "User/Group Attribute used for the user part of the HTTP-Basic Header. " - "If not set, the user's Email address is used." - ) - ), - ) - basic_auth_password_attribute = models.TextField( - blank=True, - verbose_name=_("HTTP-Basic Password Key"), - help_text=_( - ( - "User/Group Attribute used for the password part of the HTTP-Basic Header." - ) - ), - ) - - certificate = models.ForeignKey( - CertificateKeyPair, - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - - cookie_secret = models.TextField(default=get_cookie_secret) - - @property - def form(self) -> Type[ModelForm]: - from passbook.providers.proxy.forms import ProxyProviderForm - - return ProxyProviderForm - - @property - def launch_url(self) -> Optional[str]: - """Use external_host as launch URL""" - return self.external_host - - def html_setup_urls(self, request: HttpRequest) -> Optional[str]: - """Overwrite Setup URLs as they are not needed for proxy""" - return None - - def set_oauth_defaults(self): - """Ensure all OAuth2-related settings are correct""" - self.client_type = ClientTypes.CONFIDENTIAL - self.response_type = ResponseTypes.CODE - self.jwt_alg = JWTAlgorithms.RS256 - self.rsa_key = CertificateKeyPair.objects.first() - scopes = ScopeMapping.objects.filter( - scope_name__in=[ - SCOPE_OPENID, - SCOPE_OPENID_PROFILE, - SCOPE_OPENID_EMAIL, - SCOPE_PB_PROXY, - ] - ) - self.property_mappings.set(scopes) - self.redirect_uris = "\n".join( - [ - _get_callback_url(self.external_host), - _get_callback_url(self.internal_host), - ] - ) - - def __str__(self): - return f"Proxy Provider {self.name}" - - def get_required_objects(self) -> Iterable[models.Model]: - required_models = [self] - if self.certificate is not None: - required_models.append(self.certificate) - return required_models - - class Meta: - - verbose_name = _("Proxy Provider") - verbose_name_plural = _("Proxy Providers") diff --git a/passbook/providers/saml/api.py b/passbook/providers/saml/api.py deleted file mode 100644 index 341a6842..00000000 --- a/passbook/providers/saml/api.py +++ /dev/null @@ -1,51 +0,0 @@ -"""SAMLProvider API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider - - -class SAMLProviderSerializer(ModelSerializer): - """SAMLProvider Serializer""" - - class Meta: - - model = SAMLProvider - fields = [ - "pk", - "name", - "acs_url", - "audience", - "issuer", - "assertion_valid_not_before", - "assertion_valid_not_on_or_after", - "session_valid_not_on_or_after", - "property_mappings", - "digest_algorithm", - "signature_algorithm", - "signing_kp", - "verification_kp", - ] - - -class SAMLProviderViewSet(ModelViewSet): - """SAMLProvider Viewset""" - - queryset = SAMLProvider.objects.all() - serializer_class = SAMLProviderSerializer - - -class SAMLPropertyMappingSerializer(ModelSerializer): - """SAMLPropertyMapping Serializer""" - - class Meta: - - model = SAMLPropertyMapping - fields = ["pk", "name", "saml_name", "friendly_name", "expression"] - - -class SAMLPropertyMappingViewSet(ModelViewSet): - """SAMLPropertyMapping Viewset""" - - queryset = SAMLPropertyMapping.objects.all() - serializer_class = SAMLPropertyMappingSerializer diff --git a/passbook/providers/saml/apps.py b/passbook/providers/saml/apps.py deleted file mode 100644 index dcefbe59..00000000 --- a/passbook/providers/saml/apps.py +++ /dev/null @@ -1,12 +0,0 @@ -"""passbook SAML IdP app config""" - -from django.apps import AppConfig - - -class PassbookProviderSAMLConfig(AppConfig): - """passbook SAML IdP app config""" - - name = "passbook.providers.saml" - label = "passbook_providers_saml" - verbose_name = "passbook Providers.SAML" - mountpoint = "application/saml/" diff --git a/passbook/providers/saml/exceptions.py b/passbook/providers/saml/exceptions.py deleted file mode 100644 index 81b0b1b3..00000000 --- a/passbook/providers/saml/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -"""passbook SAML IDP Exceptions""" -from passbook.lib.sentry import SentryIgnoredException - - -class CannotHandleAssertion(SentryIgnoredException): - """This processor does not handle this assertion.""" diff --git a/passbook/providers/saml/forms.py b/passbook/providers/saml/forms.py deleted file mode 100644 index abfa3bde..00000000 --- a/passbook/providers/saml/forms.py +++ /dev/null @@ -1,85 +0,0 @@ -"""passbook SAML IDP Forms""" - -from django import forms -from django.utils.html import mark_safe -from django.utils.translation import gettext as _ - -from passbook.admin.fields import CodeMirrorWidget -from passbook.core.expression import PropertyMappingEvaluator -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow, FlowDesignation -from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider - - -class SAMLProviderForm(forms.ModelForm): - """SAML Provider form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authorization_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHORIZATION - ) - self.fields["property_mappings"].queryset = SAMLPropertyMapping.objects.all() - self.fields["signing_kp"].queryset = CertificateKeyPair.objects.exclude( - key_data__iexact="" - ) - - class Meta: - - model = SAMLProvider - fields = [ - "name", - "authorization_flow", - "acs_url", - "audience", - "issuer", - "sp_binding", - "assertion_valid_not_before", - "assertion_valid_not_on_or_after", - "session_valid_not_on_or_after", - "digest_algorithm", - "signature_algorithm", - "signing_kp", - "verification_kp", - "property_mappings", - ] - widgets = { - "name": forms.TextInput(), - "audience": forms.TextInput(), - "issuer": forms.TextInput(), - "assertion_valid_not_before": forms.TextInput(), - "assertion_valid_not_on_or_after": forms.TextInput(), - "session_valid_not_on_or_after": forms.TextInput(), - } - - -class SAMLPropertyMappingForm(forms.ModelForm): - """SAML Property Mapping form""" - - template_name = "providers/saml/property_mapping_form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - evaluator = PropertyMappingEvaluator() - evaluator.validate(expression) - return expression - - class Meta: - - model = SAMLPropertyMapping - fields = ["name", "saml_name", "friendly_name", "expression"] - widgets = { - "name": forms.TextInput(), - "saml_name": forms.TextInput(), - "friendly_name": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } - help_texts = { - "saml_name": mark_safe( - _( - "URN OID used by SAML. This is optional. " - 'Reference' - ) - ), - } diff --git a/passbook/providers/saml/migrations/0001_initial.py b/passbook/providers/saml/migrations/0001_initial.py deleted file mode 100644 index c50faae7..00000000 --- a/passbook/providers/saml/migrations/0001_initial.py +++ /dev/null @@ -1,134 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - -import passbook.lib.utils.time - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_crypto", "0001_initial"), - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="SAMLPropertyMapping", - fields=[ - ( - "propertymapping_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.PropertyMapping", - ), - ), - ("saml_name", models.TextField(verbose_name="SAML Name")), - ( - "friendly_name", - models.TextField(blank=True, default=None, null=True), - ), - ], - options={ - "verbose_name": "SAML Property Mapping", - "verbose_name_plural": "SAML Property Mappings", - }, - bases=("passbook_core.propertymapping",), - ), - migrations.CreateModel( - name="SAMLProvider", - fields=[ - ( - "provider_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.Provider", - ), - ), - ("name", models.TextField()), - ("processor_path", models.CharField(choices=[], max_length=255)), - ("acs_url", models.URLField(verbose_name="ACS URL")), - ("audience", models.TextField(default="")), - ("issuer", models.TextField(help_text="Also known as EntityID")), - ( - "assertion_valid_not_before", - models.TextField( - default="minutes=-5", - help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).", - validators=[passbook.lib.utils.time.timedelta_string_validator], - ), - ), - ( - "assertion_valid_not_on_or_after", - models.TextField( - default="minutes=5", - help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", - validators=[passbook.lib.utils.time.timedelta_string_validator], - ), - ), - ( - "session_valid_not_on_or_after", - models.TextField( - default="minutes=86400", - help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", - validators=[passbook.lib.utils.time.timedelta_string_validator], - ), - ), - ( - "digest_algorithm", - models.CharField( - choices=[("sha1", "SHA1"), ("sha256", "SHA256")], - default="sha256", - max_length=50, - ), - ), - ( - "signature_algorithm", - models.CharField( - choices=[ - ("rsa-sha1", "RSA-SHA1"), - ("rsa-sha256", "RSA-SHA256"), - ("ecdsa-sha256", "ECDSA-SHA256"), - ("dsa-sha1", "DSA-SHA1"), - ], - default="rsa-sha256", - max_length=50, - ), - ), - ( - "require_signing", - models.BooleanField( - default=False, - help_text="Require Requests to be signed by an X509 Certificate. Must match the Certificate selected in `Singing Keypair`.", - ), - ), - ( - "signing_kp", - models.ForeignKey( - default=None, - help_text="Singing is enabled upon selection of a Key Pair.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="passbook_crypto.CertificateKeyPair", - verbose_name="Signing Keypair", - ), - ), - ], - options={ - "verbose_name": "SAML Provider", - "verbose_name_plural": "SAML Providers", - }, - bases=("passbook_core.provider",), - ), - ] diff --git a/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py b/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py deleted file mode 100644 index 9f1f1335..00000000 --- a/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py +++ /dev/null @@ -1,63 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 19:32 - -from django.db import migrations - - -def create_default_property_mappings(apps, schema_editor): - """Create default SAML Property Mappings""" - SAMLPropertyMapping = apps.get_model( - "passbook_providers_saml", "SAMLPropertyMapping" - ) - db_alias = schema_editor.connection.alias - defaults = [ - { - "FriendlyName": "eduPersonPrincipalName", - "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", - "Expression": "return user.email", - }, - { - "FriendlyName": "cn", - "Name": "urn:oid:2.5.4.3", - "Expression": "return user.name", - }, - { - "FriendlyName": "mail", - "Name": "urn:oid:0.9.2342.19200300.100.1.3", - "Expression": "return user.email", - }, - { - "FriendlyName": "displayName", - "Name": "urn:oid:2.16.840.1.113730.3.1.241", - "Expression": "return user.username", - }, - { - "FriendlyName": "uid", - "Name": "urn:oid:0.9.2342.19200300.100.1.1", - "Expression": "return user.pk", - }, - { - "FriendlyName": "member-of", - "Name": "member-of", - "Expression": "for group in user.groups.all():\n yield group.name", - }, - ] - for default in defaults: - SAMLPropertyMapping.objects.using(db_alias).get_or_create( - saml_name=default["Name"], - friendly_name=default["FriendlyName"], - expression=default["Expression"], - defaults={ - "name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}" - }, - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_saml", "0001_initial"), - ] - - operations = [ - migrations.RunPython(create_default_property_mappings), - ] diff --git a/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py b/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py deleted file mode 100644 index 20ffbbf0..00000000 --- a/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.0.6 on 2020-06-06 13:25 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_saml", "0002_default_saml_property_mappings"), - ] - - operations = [ - migrations.AddField( - model_name="samlprovider", - name="sp_binding", - field=models.TextField( - choices=[("redirect", "Redirect"), ("post", "Post")], default="redirect" - ), - ), - ] diff --git a/passbook/providers/saml/migrations/0004_auto_20200620_1950.py b/passbook/providers/saml/migrations/0004_auto_20200620_1950.py deleted file mode 100644 index 175baeb5..00000000 --- a/passbook/providers/saml/migrations/0004_auto_20200620_1950.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-20 19:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_saml", "0003_samlprovider_sp_binding"), - ] - - operations = [ - migrations.AlterField( - model_name="samlprovider", - name="sp_binding", - field=models.TextField( - choices=[("redirect", "Redirect"), ("post", "Post")], - default="redirect", - verbose_name="Service Prodier Binding", - ), - ), - ] diff --git a/passbook/providers/saml/migrations/0005_remove_samlprovider_processor_path.py b/passbook/providers/saml/migrations/0005_remove_samlprovider_processor_path.py deleted file mode 100644 index b51e1e42..00000000 --- a/passbook/providers/saml/migrations/0005_remove_samlprovider_processor_path.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-11 00:02 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_saml", "0004_auto_20200620_1950"), - ] - - operations = [ - migrations.RemoveField( - model_name="samlprovider", - name="processor_path", - ), - ] diff --git a/passbook/providers/saml/migrations/0006_remove_samlprovider_name.py b/passbook/providers/saml/migrations/0006_remove_samlprovider_name.py deleted file mode 100644 index 71c7b7c5..00000000 --- a/passbook/providers/saml/migrations/0006_remove_samlprovider_name.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-03 17:37 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def update_name_temp(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - SAMLProvider = apps.get_model("passbook_providers_saml", "SAMLProvider") - db_alias = schema_editor.connection.alias - - for provider in SAMLProvider.objects.using(db_alias).all(): - provider.name_temp = provider.name - provider.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0011_provider_name_temp"), - ("passbook_providers_saml", "0005_remove_samlprovider_processor_path"), - ] - - operations = [ - migrations.RunPython(update_name_temp), - migrations.RemoveField( - model_name="samlprovider", - name="name", - ), - ] diff --git a/passbook/providers/saml/migrations/0007_samlprovider_verification_kp.py b/passbook/providers/saml/migrations/0007_samlprovider_verification_kp.py deleted file mode 100644 index 9e87edb1..00000000 --- a/passbook/providers/saml/migrations/0007_samlprovider_verification_kp.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-08 21:22 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0002_create_self_signed_kp"), - ("passbook_providers_saml", "0006_remove_samlprovider_name"), - ] - - operations = [ - migrations.AddField( - model_name="samlprovider", - name="verification_kp", - field=models.ForeignKey( - default=None, - help_text="If selected, incoming assertion's Signatures will be validated.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="passbook_crypto.certificatekeypair", - verbose_name="Verification Keypair", - ), - ), - ] diff --git a/passbook/providers/saml/migrations/0008_auto_20201112_1036.py b/passbook/providers/saml/migrations/0008_auto_20201112_1036.py deleted file mode 100644 index 44136a61..00000000 --- a/passbook/providers/saml/migrations/0008_auto_20201112_1036.py +++ /dev/null @@ -1,71 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-12 10:36 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0002_create_self_signed_kp"), - ("passbook_providers_saml", "0007_samlprovider_verification_kp"), - ] - - operations = [ - migrations.RemoveField( - model_name="samlprovider", - name="require_signing", - ), - migrations.AlterField( - model_name="samlprovider", - name="audience", - field=models.TextField( - default="", - help_text="Value of the audience restriction field of the asseration.", - ), - ), - migrations.AlterField( - model_name="samlprovider", - name="issuer", - field=models.TextField( - default="passbook", help_text="Also known as EntityID" - ), - ), - migrations.AlterField( - model_name="samlprovider", - name="signing_kp", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Keypair used to sign outgoing Responses going to the Service Provider.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="passbook_crypto.certificatekeypair", - verbose_name="Signing Keypair", - ), - ), - migrations.AlterField( - model_name="samlprovider", - name="sp_binding", - field=models.TextField( - choices=[("redirect", "Redirect"), ("post", "Post")], - default="redirect", - help_text="This determines how passbook sends the response back to the Service Provider.", - verbose_name="Service Provider Binding", - ), - ), - migrations.AlterField( - model_name="samlprovider", - name="verification_kp", - field=models.ForeignKey( - blank=True, - default=None, - help_text="When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="passbook_crypto.certificatekeypair", - verbose_name="Verification Certificate", - ), - ), - ] diff --git a/passbook/providers/saml/migrations/0009_auto_20201112_2016.py b/passbook/providers/saml/migrations/0009_auto_20201112_2016.py deleted file mode 100644 index c9e1e2b7..00000000 --- a/passbook/providers/saml/migrations/0009_auto_20201112_2016.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-12 20:16 - -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.sources.saml.processors import constants - - -def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - SAMLProvider = apps.get_model("passbook_providers_saml", "SAMLProvider") - signature_translation_map = { - "rsa-sha1": constants.RSA_SHA1, - "rsa-sha256": constants.RSA_SHA256, - "ecdsa-sha256": constants.RSA_SHA256, - "dsa-sha1": constants.DSA_SHA1, - } - digest_translation_map = { - "sha1": constants.SHA1, - "sha256": constants.SHA256, - } - - for source in SAMLProvider.objects.all(): - source.signature_algorithm = signature_translation_map.get( - source.signature_algorithm, constants.RSA_SHA256 - ) - source.digest_algorithm = digest_translation_map.get( - source.digest_algorithm, constants.SHA256 - ) - source.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_providers_saml", "0008_auto_20201112_1036"), - ] - - operations = [ - migrations.AlterField( - model_name="samlprovider", - name="digest_algorithm", - field=models.CharField( - choices=[ - (constants.SHA1, "SHA1"), - (constants.SHA256, "SHA256"), - (constants.SHA384, "SHA384"), - (constants.SHA512, "SHA512"), - ], - default=constants.SHA256, - max_length=50, - ), - ), - migrations.AlterField( - model_name="samlprovider", - name="signature_algorithm", - field=models.CharField( - choices=[ - (constants.RSA_SHA1, "RSA-SHA1"), - (constants.RSA_SHA256, "RSA-SHA256"), - (constants.RSA_SHA384, "RSA-SHA384"), - (constants.RSA_SHA512, "RSA-SHA512"), - (constants.DSA_SHA1, "DSA-SHA1"), - ], - default=constants.RSA_SHA256, - max_length=50, - ), - ), - ] diff --git a/passbook/providers/saml/models.py b/passbook/providers/saml/models.py deleted file mode 100644 index 27e4bca6..00000000 --- a/passbook/providers/saml/models.py +++ /dev/null @@ -1,205 +0,0 @@ -"""passbook saml_idp Models""" -from typing import Optional, Type -from urllib.parse import urlparse - -from django.db import models -from django.forms import ModelForm -from django.http import HttpRequest -from django.shortcuts import reverse -from django.utils.translation import gettext_lazy as _ -from structlog import get_logger - -from passbook.core.models import PropertyMapping, Provider -from passbook.crypto.models import CertificateKeyPair -from passbook.lib.utils.template import render_to_string -from passbook.lib.utils.time import timedelta_string_validator -from passbook.sources.saml.processors.constants import ( - DSA_SHA1, - RSA_SHA1, - RSA_SHA256, - RSA_SHA384, - RSA_SHA512, - SHA1, - SHA256, - SHA384, - SHA512, -) - -LOGGER = get_logger() - - -class SAMLBindings(models.TextChoices): - """SAML Bindings supported by passbook""" - - REDIRECT = "redirect" - POST = "post" - - -class SAMLProvider(Provider): - """SAML 2.0 Endpoint for applications which support SAML.""" - - acs_url = models.URLField(verbose_name=_("ACS URL")) - audience = models.TextField( - default="", - help_text=_("Value of the audience restriction field of the asseration."), - ) - issuer = models.TextField(help_text=_("Also known as EntityID"), default="passbook") - sp_binding = models.TextField( - choices=SAMLBindings.choices, - default=SAMLBindings.REDIRECT, - verbose_name=_("Service Provider Binding"), - help_text=_( - ( - "This determines how passbook sends the " - "response back to the Service Provider." - ) - ), - ) - - assertion_valid_not_before = models.TextField( - default="minutes=-5", - validators=[timedelta_string_validator], - help_text=_( - ( - "Assertion valid not before current time + this value " - "(Format: hours=-1;minutes=-2;seconds=-3)." - ) - ), - ) - assertion_valid_not_on_or_after = models.TextField( - default="minutes=5", - validators=[timedelta_string_validator], - help_text=_( - ( - "Assertion not valid on or after current time + this value " - "(Format: hours=1;minutes=2;seconds=3)." - ) - ), - ) - - session_valid_not_on_or_after = models.TextField( - default="minutes=86400", - validators=[timedelta_string_validator], - help_text=_( - ( - "Session not valid on or after current time + this value " - "(Format: hours=1;minutes=2;seconds=3)." - ) - ), - ) - - digest_algorithm = models.CharField( - max_length=50, - choices=( - (SHA1, _("SHA1")), - (SHA256, _("SHA256")), - (SHA384, _("SHA384")), - (SHA512, _("SHA512")), - ), - default=SHA256, - ) - signature_algorithm = models.CharField( - max_length=50, - choices=( - (RSA_SHA1, _("RSA-SHA1")), - (RSA_SHA256, _("RSA-SHA256")), - (RSA_SHA384, _("RSA-SHA384")), - (RSA_SHA512, _("RSA-SHA512")), - (DSA_SHA1, _("DSA-SHA1")), - ), - default=RSA_SHA256, - ) - - verification_kp = models.ForeignKey( - CertificateKeyPair, - default=None, - null=True, - blank=True, - help_text=_( - ( - "When selected, incoming assertion's Signatures will be validated against this " - "certificate. To allow unsigned Requests, leave on default." - ) - ), - on_delete=models.SET_NULL, - verbose_name=_("Verification Certificate"), - related_name="+", - ) - signing_kp = models.ForeignKey( - CertificateKeyPair, - default=None, - null=True, - blank=True, - help_text=_( - "Keypair used to sign outgoing Responses going to the Service Provider." - ), - on_delete=models.SET_NULL, - verbose_name=_("Signing Keypair"), - ) - - @property - def launch_url(self) -> Optional[str]: - """Guess launch_url based on acs URL""" - launch_url = urlparse(self.acs_url) - return self.acs_url.replace(launch_url.path, "") - - @property - def form(self) -> Type[ModelForm]: - from passbook.providers.saml.forms import SAMLProviderForm - - return SAMLProviderForm - - def __str__(self): - return f"SAML Provider {self.name}" - - def link_download_metadata(self): - """Get link to download XML metadata for admin interface""" - try: - # pylint: disable=no-member - return reverse( - "passbook_providers_saml:metadata", - kwargs={"application_slug": self.application.slug}, - ) - except Provider.application.RelatedObjectDoesNotExist: - return None - - def html_metadata_view(self, request: HttpRequest) -> Optional[str]: - """return template and context modal to view Metadata without downloading it""" - from passbook.providers.saml.views import DescriptorDownloadView - - try: - # pylint: disable=no-member - metadata = DescriptorDownloadView.get_metadata(request, self) - return render_to_string( - "providers/saml/admin_metadata_modal.html", - {"provider": self, "metadata": metadata}, - ) - except Provider.application.RelatedObjectDoesNotExist: - return None - - class Meta: - - verbose_name = _("SAML Provider") - verbose_name_plural = _("SAML Providers") - - -class SAMLPropertyMapping(PropertyMapping): - """Map User/Group attribute to SAML Attribute, which can be used by the Service Provider.""" - - saml_name = models.TextField(verbose_name="SAML Name") - friendly_name = models.TextField(default=None, blank=True, null=True) - - @property - def form(self) -> Type[ModelForm]: - from passbook.providers.saml.forms import SAMLPropertyMappingForm - - return SAMLPropertyMappingForm - - def __str__(self): - name = self.friendly_name if self.friendly_name != "" else self.saml_name - return f"{self.name} ({name})" - - class Meta: - - verbose_name = _("SAML Property Mapping") - verbose_name_plural = _("SAML Property Mappings") diff --git a/passbook/providers/saml/processors/assertion.py b/passbook/providers/saml/processors/assertion.py deleted file mode 100644 index 301f9d70..00000000 --- a/passbook/providers/saml/processors/assertion.py +++ /dev/null @@ -1,263 +0,0 @@ -"""SAML Assertion generator""" -from hashlib import sha256 -from types import GeneratorType - -import xmlsec -from django.http import HttpRequest -from lxml import etree # nosec -from lxml.etree import Element, SubElement # nosec -from structlog import get_logger - -from passbook.core.exceptions import PropertyMappingExpressionException -from passbook.lib.utils.time import timedelta_from_string -from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider -from passbook.providers.saml.processors.request_parser import AuthNRequest -from passbook.providers.saml.utils import get_random_id -from passbook.providers.saml.utils.time import get_time_string -from passbook.sources.saml.exceptions import UnsupportedNameIDFormat -from passbook.sources.saml.processors.constants import ( - DIGEST_ALGORITHM_TRANSLATION_MAP, - NS_MAP, - NS_SAML_ASSERTION, - NS_SAML_PROTOCOL, - SAML_NAME_ID_FORMAT_EMAIL, - SAML_NAME_ID_FORMAT_PERSISTENT, - SAML_NAME_ID_FORMAT_TRANSIENT, - SAML_NAME_ID_FORMAT_X509, - SIGN_ALGORITHM_TRANSFORM_MAP, -) - -LOGGER = get_logger() - - -class AssertionProcessor: - """Generate a SAML Response from an AuthNRequest""" - - provider: SAMLProvider - http_request: HttpRequest - auth_n_request: AuthNRequest - - _issue_instant: str - _assertion_id: str - - _valid_not_before: str - _valid_not_on_or_after: str - - def __init__( - self, provider: SAMLProvider, request: HttpRequest, auth_n_request: AuthNRequest - ): - self.provider = provider - self.http_request = request - self.auth_n_request = auth_n_request - - self._issue_instant = get_time_string() - self._assertion_id = get_random_id() - - self._valid_not_before = get_time_string( - timedelta_from_string(self.provider.assertion_valid_not_before) - ) - self._valid_not_on_or_after = get_time_string( - timedelta_from_string(self.provider.assertion_valid_not_on_or_after) - ) - - def get_attributes(self) -> Element: - """Get AttributeStatement Element with Attributes from Property Mappings.""" - # https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions - attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement") - for mapping in self.provider.property_mappings.all().select_subclasses(): - if not isinstance(mapping, SAMLPropertyMapping): - continue - try: - mapping: SAMLPropertyMapping - value = mapping.evaluate( - user=self.http_request.user, - request=self.http_request, - provider=self.provider, - ) - if value is None: - continue - - attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute") - attribute.attrib["FriendlyName"] = mapping.friendly_name - attribute.attrib["Name"] = mapping.saml_name - - if not isinstance(value, (list, GeneratorType)): - value = [value] - - for value_item in value: - attribute_value = SubElement( - attribute, f"{{{NS_SAML_ASSERTION}}}AttributeValue" - ) - if not isinstance(value_item, str): - value_item = str(value_item) - attribute_value.text = value_item - - attribute_statement.append(attribute) - - except PropertyMappingExpressionException as exc: - LOGGER.warning(exc) - continue - return attribute_statement - - def get_issuer(self) -> Element: - """Get Issuer Element""" - issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer", nsmap=NS_MAP) - issuer.text = self.provider.issuer - return issuer - - def get_assertion_auth_n_statement(self) -> Element: - """Generate AuthnStatement with AuthnContext and ContextClassRef Elements.""" - auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement") - auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before - auth_n_statement.attrib["SessionIndex"] = self._assertion_id - - auth_n_context = SubElement( - auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext" - ) - auth_n_context_class_ref = SubElement( - auth_n_context, f"{{{NS_SAML_ASSERTION}}}AuthnContextClassRef" - ) - auth_n_context_class_ref.text = ( - "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" - ) - return auth_n_statement - - def get_assertion_conditions(self) -> Element: - """Generate Conditions with AudienceRestriction and Audience Elements.""" - conditions = Element(f"{{{NS_SAML_ASSERTION}}}Conditions") - conditions.attrib["NotBefore"] = self._valid_not_before - conditions.attrib["NotOnOrAfter"] = self._valid_not_on_or_after - audience_restriction = SubElement( - conditions, f"{{{NS_SAML_ASSERTION}}}AudienceRestriction" - ) - audience = SubElement(audience_restriction, f"{{{NS_SAML_ASSERTION}}}Audience") - audience.text = self.provider.audience - return conditions - - def get_name_id(self) -> Element: - """Get NameID Element""" - name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID") - name_id.attrib["Format"] = self.auth_n_request.name_id_policy - if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_EMAIL: - name_id.text = self.http_request.user.email - return name_id - if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_PERSISTENT: - name_id.text = self.http_request.user.username - return name_id - if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509: - # This attribute is statically set by the LDAP source - name_id.text = self.http_request.user.attributes.get( - "distinguishedName", "" - ) - return name_id - if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: - # This attribute is statically set by the LDAP source - session_key: str = self.http_request.user.session.session_key - name_id.text = sha256(session_key.encode()).hexdigest() - return name_id - raise UnsupportedNameIDFormat( - f"Assertion contains NameID with unsupported format {name_id.attrib['Format']}." - ) - - def get_assertion_subject(self) -> Element: - """Generate Subject Element with NameID and SubjectConfirmation Objects""" - subject = Element(f"{{{NS_SAML_ASSERTION}}}Subject") - subject.append(self.get_name_id()) - - subject_confirmation = SubElement( - subject, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmation" - ) - subject_confirmation.attrib["Method"] = "urn:oasis:names:tc:SAML:2.0:cm:bearer" - - subject_confirmation_data = SubElement( - subject_confirmation, f"{{{NS_SAML_ASSERTION}}}SubjectConfirmationData" - ) - if self.auth_n_request.id: - subject_confirmation_data.attrib["InResponseTo"] = self.auth_n_request.id - subject_confirmation_data.attrib["NotOnOrAfter"] = self._valid_not_on_or_after - subject_confirmation_data.attrib["Recipient"] = self.provider.acs_url - return subject - - def get_assertion(self) -> Element: - """Generate Main Assertion Element""" - assertion = Element(f"{{{NS_SAML_ASSERTION}}}Assertion", nsmap=NS_MAP) - assertion.attrib["Version"] = "2.0" - assertion.attrib["ID"] = self._assertion_id - assertion.attrib["IssueInstant"] = self._issue_instant - assertion.append(self.get_issuer()) - - if self.provider.signing_kp: - sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get( - self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1 - ) - signature = xmlsec.template.create( - assertion, - xmlsec.constants.TransformExclC14N, - sign_algorithm_transform, - ns="ds", # type: ignore - ) - assertion.append(signature) - - assertion.append(self.get_assertion_subject()) - assertion.append(self.get_assertion_conditions()) - assertion.append(self.get_assertion_auth_n_statement()) - - assertion.append(self.get_attributes()) - return assertion - - def get_response(self) -> Element: - """Generate Root response element""" - response = Element(f"{{{NS_SAML_PROTOCOL}}}Response", nsmap=NS_MAP) - response.attrib["Version"] = "2.0" - response.attrib["IssueInstant"] = self._issue_instant - response.attrib["Destination"] = self.provider.acs_url - response.attrib["ID"] = get_random_id() - if self.auth_n_request.id: - response.attrib["InResponseTo"] = self.auth_n_request.id - - response.append(self.get_issuer()) - - status = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status") - status_code = SubElement(status, f"{{{NS_SAML_PROTOCOL}}}StatusCode") - status_code.attrib["Value"] = "urn:oasis:names:tc:SAML:2.0:status:Success" - - response.append(self.get_assertion()) - return response - - def build_response(self) -> str: - """Build string XML Response and sign if signing is enabled.""" - root_response = self.get_response() - if self.provider.signing_kp: - digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get( - self.provider.digest_algorithm, xmlsec.constants.TransformSha1 - ) - assertion = root_response.xpath("//saml:Assertion", namespaces=NS_MAP)[0] - xmlsec.tree.add_ids(assertion, ["ID"]) - signature_node = xmlsec.tree.find_node( - assertion, xmlsec.constants.NodeSignature - ) - ref = xmlsec.template.add_reference( - signature_node, - digest_algorithm_transform, - uri="#" + self._assertion_id, - ) - xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped) - xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N) - key_info = xmlsec.template.ensure_key_info(signature_node) - xmlsec.template.add_x509_data(key_info) - - ctx = xmlsec.SignatureContext() - - key = xmlsec.Key.from_memory( - self.provider.signing_kp.key_data, - xmlsec.constants.KeyDataFormatPem, - None, - ) - key.load_cert_from_memory( - self.provider.signing_kp.certificate_data, - xmlsec.constants.KeyDataFormatCertPem, - ) - ctx.key = key - ctx.sign(signature_node) - - return etree.tostring(root_response).decode("utf-8") # nosec diff --git a/passbook/providers/saml/processors/metadata.py b/passbook/providers/saml/processors/metadata.py deleted file mode 100644 index 8f824523..00000000 --- a/passbook/providers/saml/processors/metadata.py +++ /dev/null @@ -1,108 +0,0 @@ -"""SAML Identity Provider Metadata Processor""" -from typing import Iterator, Optional - -from django.http import HttpRequest -from django.shortcuts import reverse -from lxml.etree import Element, SubElement, tostring # nosec - -from passbook.providers.saml.models import SAMLProvider -from passbook.providers.saml.utils.encoding import strip_pem_header -from passbook.sources.saml.processors.constants import ( - NS_MAP, - NS_SAML_METADATA, - NS_SIGNATURE, - SAML_BINDING_POST, - SAML_BINDING_REDIRECT, - SAML_NAME_ID_FORMAT_EMAIL, - SAML_NAME_ID_FORMAT_PERSISTENT, - SAML_NAME_ID_FORMAT_TRANSIENT, - SAML_NAME_ID_FORMAT_X509, -) - - -class MetadataProcessor: - """SAML Identity Provider Metadata Processor""" - - provider: SAMLProvider - http_request: HttpRequest - - def __init__(self, provider: SAMLProvider, request: HttpRequest): - self.provider = provider - self.http_request = request - - def get_signing_key_descriptor(self) -> Optional[Element]: - """Get Singing KeyDescriptor, if enabled for the provider""" - if self.provider.signing_kp: - key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor") - key_descriptor.attrib["use"] = "signing" - key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo") - x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data") - x509_certificate = SubElement( - x509_data, f"{{{NS_SIGNATURE}}}X509Certificate" - ) - x509_certificate.text = strip_pem_header( - self.provider.signing_kp.certificate_data.replace("\r", "") - ) - return key_descriptor - return None - - def get_name_id_formats(self) -> Iterator[Element]: - """Get compatible NameID Formats""" - formats = [ - SAML_NAME_ID_FORMAT_EMAIL, - SAML_NAME_ID_FORMAT_PERSISTENT, - SAML_NAME_ID_FORMAT_X509, - SAML_NAME_ID_FORMAT_TRANSIENT, - ] - for name_id_format in formats: - element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat") - element.text = name_id_format - yield element - - def get_bindings(self) -> Iterator[Element]: - """Get all Bindings supported""" - binding_url_map = { - SAML_BINDING_POST: self.http_request.build_absolute_uri( - reverse( - "passbook_providers_saml:sso-post", - kwargs={"application_slug": self.provider.application.slug}, - ) - ), - SAML_BINDING_REDIRECT: self.http_request.build_absolute_uri( - reverse( - "passbook_providers_saml:sso-redirect", - kwargs={"application_slug": self.provider.application.slug}, - ) - ), - } - for binding, url in binding_url_map.items(): - element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService") - element.attrib["Binding"] = binding - element.attrib["Location"] = url - yield element - - def build_entity_descriptor(self) -> str: - """Build full EntityDescriptor""" - entity_descriptor = Element( - f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP - ) - entity_descriptor.attrib["entityID"] = self.provider.issuer - - idp_sso_descriptor = SubElement( - entity_descriptor, f"{{{NS_SAML_METADATA}}}IDPSSODescriptor" - ) - idp_sso_descriptor.attrib[ - "protocolSupportEnumeration" - ] = "urn:oasis:names:tc:SAML:2.0:protocol" - - signing_descriptor = self.get_signing_key_descriptor() - if signing_descriptor is not None: - idp_sso_descriptor.append(signing_descriptor) - - for name_id_format in self.get_name_id_formats(): - idp_sso_descriptor.append(name_id_format) - - for binding in self.get_bindings(): - idp_sso_descriptor.append(binding) - - return tostring(entity_descriptor, pretty_print=True).decode() diff --git a/passbook/providers/saml/processors/request_parser.py b/passbook/providers/saml/processors/request_parser.py deleted file mode 100644 index d05f28b6..00000000 --- a/passbook/providers/saml/processors/request_parser.py +++ /dev/null @@ -1,169 +0,0 @@ -"""SAML AuthNRequest Parser and dataclass""" -from base64 import b64decode -from dataclasses import dataclass -from typing import Optional -from urllib.parse import quote_plus - -import xmlsec -from defusedxml import ElementTree -from lxml import etree # nosec -from structlog import get_logger - -from passbook.providers.saml.exceptions import CannotHandleAssertion -from passbook.providers.saml.models import SAMLProvider -from passbook.providers.saml.utils.encoding import decode_base64_and_inflate -from passbook.sources.saml.processors.constants import ( - DSA_SHA1, - NS_MAP, - NS_SAML_PROTOCOL, - RSA_SHA1, - RSA_SHA256, - RSA_SHA384, - RSA_SHA512, - SAML_NAME_ID_FORMAT_EMAIL, -) - -LOGGER = get_logger() -ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = ( - "Verification Certificate configured, but request is not signed." -) -ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER = ( - "Provider does not have a Validation Certificate configured." -) -ERROR_FAILED_TO_VERIFY = "Failed to verify signature" - - -@dataclass -class AuthNRequest: - """AuthNRequest Dataclass""" - - # pylint: disable=invalid-name - id: Optional[str] = None - - relay_state: Optional[str] = None - - name_id_policy: str = SAML_NAME_ID_FORMAT_EMAIL - - -class AuthNRequestParser: - """AuthNRequest Parser""" - - provider: SAMLProvider - - def __init__(self, provider: SAMLProvider): - self.provider = provider - - def _parse_xml(self, decoded_xml: str, relay_state: Optional[str]) -> AuthNRequest: - root = ElementTree.fromstring(decoded_xml) - - request_acs_url = root.attrib["AssertionConsumerServiceURL"] - - if self.provider.acs_url.lower() != request_acs_url.lower(): - msg = ( - f"ACS URL of {request_acs_url} doesn't match Provider " - f"ACS URL of {self.provider.acs_url}." - ) - LOGGER.info(msg) - raise CannotHandleAssertion(msg) - - auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state) - - # Check if AuthnRequest has a NameID Policy object - name_id_policies = root.findall(f"{{{NS_SAML_PROTOCOL}}}:NameIDPolicy") - if len(name_id_policies) > 0: - name_id_policy = name_id_policies[0] - auth_n_request.name_id_policy = name_id_policy.attrib["Format"] - - return auth_n_request - - def parse(self, saml_request: str, relay_state: Optional[str]) -> AuthNRequest: - """Validate and parse raw request with enveloped signautre.""" - decoded_xml = b64decode(saml_request.encode()).decode() - - verifier = self.provider.verification_kp - - root = etree.fromstring(decoded_xml) # nosec - xmlsec.tree.add_ids(root, ["ID"]) - signature_nodes = root.xpath( - "/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP - ) - if len(signature_nodes) != 1: - raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) - - signature_node = signature_nodes[0] - - if verifier and signature_node is None: - raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) - - if signature_node is not None: - if not verifier: - raise CannotHandleAssertion(ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER) - - try: - ctx = xmlsec.SignatureContext() - key = xmlsec.Key.from_memory( - verifier.certificate_data, - xmlsec.constants.KeyDataFormatCertPem, - None, - ) - ctx.key = key - ctx.verify(signature_node) - except xmlsec.VerificationError as exc: - raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc - - return self._parse_xml(decoded_xml, relay_state) - - def parse_detached( - self, - saml_request: str, - relay_state: Optional[str], - signature: Optional[str] = None, - sig_alg: Optional[str] = None, - ) -> AuthNRequest: - """Validate and parse raw request with detached signature""" - decoded_xml = decode_base64_and_inflate(saml_request) - - verifier = self.provider.verification_kp - - if verifier and not (signature and sig_alg): - raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) - - if signature and sig_alg: - if not verifier: - raise CannotHandleAssertion(ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER) - - querystring = f"SAMLRequest={quote_plus(saml_request)}&" - if relay_state is not None: - querystring += f"RelayState={quote_plus(relay_state)}&" - querystring += f"SigAlg={quote_plus(sig_alg)}" - - dsig_ctx = xmlsec.SignatureContext() - key = xmlsec.Key.from_memory( - verifier.certificate_data, xmlsec.constants.KeyDataFormatCertPem, None - ) - dsig_ctx.key = key - - sign_algorithm_transform_map = { - DSA_SHA1: xmlsec.constants.TransformDsaSha1, - RSA_SHA1: xmlsec.constants.TransformRsaSha1, - RSA_SHA256: xmlsec.constants.TransformRsaSha256, - RSA_SHA384: xmlsec.constants.TransformRsaSha384, - RSA_SHA512: xmlsec.constants.TransformRsaSha512, - } - sign_algorithm_transform = sign_algorithm_transform_map.get( - sig_alg, xmlsec.constants.TransformRsaSha1 - ) - - try: - dsig_ctx.verify_binary( - querystring.encode("utf-8"), - sign_algorithm_transform, - b64decode(signature), - ) - except xmlsec.VerificationError as exc: - raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc - return self._parse_xml(decoded_xml, relay_state) - - def idp_initiated(self) -> AuthNRequest: - """Create IdP Initiated AuthNRequest""" - return AuthNRequest() diff --git a/passbook/providers/saml/settings.py b/passbook/providers/saml/settings.py deleted file mode 100644 index 6821f517..00000000 --- a/passbook/providers/saml/settings.py +++ /dev/null @@ -1,6 +0,0 @@ -"""saml provider settings""" - -PASSBOOK_PROVIDERS_SAML_PROCESSORS = [ - "passbook.providers.saml.processors.generic", - "passbook.providers.saml.processors.salesforce", -] diff --git a/passbook/providers/saml/templates/providers/saml/admin_metadata_modal.html b/passbook/providers/saml/templates/providers/saml/admin_metadata_modal.html deleted file mode 100644 index 2ca2290b..00000000 --- a/passbook/providers/saml/templates/providers/saml/admin_metadata_modal.html +++ /dev/null @@ -1,22 +0,0 @@ -{% load i18n %} - - - -
-
-

{% trans 'Metadata' %}

-
- - -
-
- diff --git a/passbook/providers/saml/templates/providers/saml/property_mapping_form.html b/passbook/providers/saml/templates/providers/saml/property_mapping_form.html deleted file mode 100644 index 4bd3085a..00000000 --- a/passbook/providers/saml/templates/providers/saml/property_mapping_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/passbook/providers/saml/tests/test_auth_n_request.py b/passbook/providers/saml/tests/test_auth_n_request.py deleted file mode 100644 index 74db0c0f..00000000 --- a/passbook/providers/saml/tests/test_auth_n_request.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Test AuthN Request generator and parser""" -from base64 import b64encode - -from django.contrib.sessions.middleware import SessionMiddleware -from django.http.request import HttpRequest, QueryDict -from django.test import RequestFactory, TestCase -from guardian.utils import get_anonymous_user - -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow -from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider -from passbook.providers.saml.processors.assertion import AssertionProcessor -from passbook.providers.saml.processors.request_parser import AuthNRequestParser -from passbook.sources.saml.exceptions import MismatchedRequestID -from passbook.sources.saml.models import SAMLSource -from passbook.sources.saml.processors.constants import SAML_NAME_ID_FORMAT_EMAIL -from passbook.sources.saml.processors.request import ( - SESSION_REQUEST_ID, - RequestProcessor, -) -from passbook.sources.saml.processors.response import ResponseProcessor - -REDIRECT_REQUEST = ( - "fZLNbsIwEIRfJfIdbKeFgEUipXAoEm0jSHvopTLJplhK7NTr9Oft6yRUKhekPdk73+yOdoWyqVuRdu6k9/DRAbrgu6k1iu" - "EjJp3VwkhUKLRsAIUrxCF92IlwykRrjTOFqUmQIoJ1yui10dg1YA9gP1UBz/tdTE7OtSgo5WzKQzYditGeP8GW9rSQZk+H" - "nAQbb6+07EGj7EI1j8SCeaVs21oVQ9dAoRqcf6OIhh6VLpV+pxZKZaFwlATbTUzeyqKazaqiDCO5WEQwZzKCagkwr8obWc" - "qjb0PsYKvRSe1iErKQTTj3lYdc3HLBl68kyM4L340u19M5j4LiPs+zybjgC1gclvMNJFn104vB2P5L/TpW/kVNkqvBrug/" - "+mjVikeP224y4/P7CdK6Nl9rC9JBTDihySi5vIbkFw==" -) -REDIRECT_SIGNATURE = ( - "UlOe1BItHVHM+io6rUZAenIqfibm7hM6wr9I1rcP5kPJ4N8cbkyqmAMh5LD2lUq3PDERJfjdO/oOKnvJmbD2y9MOObyR2d" - "7Udv62KERrA0qM917Q+w8wrLX7w2nHY96EDvkXD4iAomR5EE9dHRuubDy7uRv2syEevc0gfoLi7W/5vp96vJgsaSqxnTp+" - "QiYq49KyWyMtxRULF2yd+vYDnHCDME73mNSULEHfwCU71dvbKpnFaej78q7wS20gUk6ysOOXXtvDHbiVcpUb/9oyDgNAxU" - "jVvPdh96AhBFj2HCuGZhP0CGotafTciu6YlsiwUpuBkIYgZmNWYa3FR9LS4Q==" -) -REDIRECT_SIG_ALG = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" -REDIRECT_RELAY_STATE = ( - "ss:mem:7a054b4af44f34f89dd2d973f383c250b6b076e7f06cfa8276008a6504eaf3c7" -) -REDIRECT_CERT = """-----BEGIN CERTIFICATE----- -MIIDCDCCAfCgAwIBAgIRAM5s+bhOHk4ChSpPkGSh0NswDQYJKoZIhvcNAQELBQAw -KzEpMCcGA1UEAwwgcGFzc2Jvb2sgU2VsZi1zaWduZWQgQ2VydGlmaWNhdGUwHhcN -MjAxMTA3MjAzNDIxWhcNMjExMTA4MjAzNDIxWjBUMSkwJwYDVQQDDCBwYXNzYm9v -ayBTZWxmLXNpZ25lZCBDZXJ0aWZpY2F0ZTERMA8GA1UECgwIcGFzc2Jvb2sxFDAS -BgNVBAsMC1NlbGYtc2lnbmVkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC -AQEAuh+Bv6a/ogpic72X/sq86YiLzVjixnGqjc4wpsPPP00GX8jUAZJL4Tjo+sYK -IU2DF2/azlVqjkbLho4rGuuc8YkbFXBEXPYc5h3bseO2vk6sbbbWKV0mro1VFhBh -T59hBORuMMefmQdhFzsRNOGklIptQdg0quD8ET3+/uNfIT98S2ruZdYteFls46Sa -MokZFYVD6pWEYV4P2MKVAFqJX9bqBW0LfCCfFqHAOJjUZj9dtleg86d2WfedUOG2 -LK0iLrydjhThbI0GUDhv0jWYkRlv04fdJ1WSRANYA3gBOnyw+Iigh2xNnYbVZMXT -I0BupIJ4UoODMc4QpD2GYJ6oGwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCCEF3e -Y99KxEBSR4H4/TvKbnh4QtHswOf7MaGdjtrld7l4u4Hc4NEklNdDn1XLKhZwnq3Z -LRsRlJutDzZ18SRmAJPXPbka7z7D+LA1mbNQElOgiKyQHD9rIJSBr6X5SM9As3CR -7QUsb8dg7kc+Jn7WuLZIEVxxMtekt0buWEdMJiklF0tCS3LNsP083FaQk/H1K0z6 -3PWP26EFdwir3RyTKLY5CBLjKrUAo9O1l/WBVFYbdetnipbGGu5f6nk6nnxbwLLI -Dm52Vkq+xFDDUq9IqIoYvLaE86MDvtpMQEx65tIGU19vUf3fL/+sSfdRZ1HDzP4d -qNAZMq1DqpibfCBg ------END CERTIFICATE-----""" - - -def dummy_get_response(request: HttpRequest): # pragma: no cover - """Dummy get_response for SessionMiddleware""" - return None - - -class TestAuthNRequest(TestCase): - """Test AuthN Request generator and parser""" - - def setUp(self): - cert = CertificateKeyPair.objects.first() - self.provider: SAMLProvider = SAMLProvider.objects.create( - authorization_flow=Flow.objects.get( - slug="default-provider-authorization-implicit-consent" - ), - acs_url="http://testserver/source/saml/provider/acs/", - signing_kp=cert, - verification_kp=cert, - ) - self.provider.property_mappings.set(SAMLPropertyMapping.objects.all()) - self.provider.save() - self.source = SAMLSource.objects.create( - slug="provider", - issuer="passbook", - signing_kp=cert, - ) - self.factory = RequestFactory() - - def test_signed_valid(self): - """Test generated AuthNRequest with valid signature""" - http_request = self.factory.get("/") - - middleware = SessionMiddleware(dummy_get_response) - middleware.process_request(http_request) - http_request.session.save() - - # First create an AuthNRequest - request_proc = RequestProcessor(self.source, http_request, "test_state") - request = request_proc.build_auth_n() - # Now we check the ID and signature - parsed_request = AuthNRequestParser(self.provider).parse( - b64encode(request.encode()).decode(), "test_state" - ) - self.assertEqual(parsed_request.id, request_proc.request_id) - self.assertEqual(parsed_request.relay_state, "test_state") - - def test_request_full_signed(self): - """Test full SAML Request/Response flow, fully signed""" - http_request = self.factory.get("/") - http_request.user = get_anonymous_user() - - middleware = SessionMiddleware(dummy_get_response) - middleware.process_request(http_request) - http_request.session.save() - - # First create an AuthNRequest - request_proc = RequestProcessor(self.source, http_request, "test_state") - request = request_proc.build_auth_n() - - # To get an assertion we need a parsed request (parsed by provider) - parsed_request = AuthNRequestParser(self.provider).parse( - b64encode(request.encode()).decode(), "test_state" - ) - # Now create a response and convert it to string (provider) - response_proc = AssertionProcessor(self.provider, http_request, parsed_request) - response = response_proc.build_response() - - # Now parse the response (source) - http_request.POST = QueryDict(mutable=True) - http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() - - response_parser = ResponseProcessor(self.source) - response_parser.parse(http_request) - - def test_request_id_invalid(self): - """Test generated AuthNRequest with invalid request ID""" - http_request = self.factory.get("/") - http_request.user = get_anonymous_user() - - middleware = SessionMiddleware(dummy_get_response) - middleware.process_request(http_request) - http_request.session.save() - - # First create an AuthNRequest - request_proc = RequestProcessor(self.source, http_request, "test_state") - request = request_proc.build_auth_n() - - # change the request ID - http_request.session[SESSION_REQUEST_ID] = "test" - http_request.session.save() - - # To get an assertion we need a parsed request (parsed by provider) - parsed_request = AuthNRequestParser(self.provider).parse( - b64encode(request.encode()).decode(), "test_state" - ) - # Now create a response and convert it to string (provider) - response_proc = AssertionProcessor(self.provider, http_request, parsed_request) - response = response_proc.build_response() - - # Now parse the response (source) - http_request.POST = QueryDict(mutable=True) - http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() - - response_parser = ResponseProcessor(self.source) - - with self.assertRaises(MismatchedRequestID): - response_parser.parse(http_request) - - def test_signed_valid_detached(self): - """Test generated AuthNRequest with valid signature (detached)""" - http_request = self.factory.get("/") - - middleware = SessionMiddleware(dummy_get_response) - middleware.process_request(http_request) - http_request.session.save() - - # First create an AuthNRequest - request_proc = RequestProcessor(self.source, http_request, "test_state") - params = request_proc.build_auth_n_detached() - # Now we check the ID and signature - parsed_request = AuthNRequestParser(self.provider).parse_detached( - params["SAMLRequest"], - params["RelayState"], - params["Signature"], - params["SigAlg"], - ) - self.assertEqual(parsed_request.id, request_proc.request_id) - self.assertEqual(parsed_request.relay_state, "test_state") - - def test_signed_detached_static(self): - """Test request with detached signature, - taken from https://www.samltool.com/generic_sso_req.php""" - static_keypair = CertificateKeyPair.objects.create( - name="samltool", certificate_data=REDIRECT_CERT - ) - provider = SAMLProvider( - name="samltool", - authorization_flow=Flow.objects.get( - slug="default-provider-authorization-implicit-consent" - ), - acs_url="https://10.120.20.200/saml-sp/SAML2/POST", - audience="https://10.120.20.200/saml-sp/SAML2/POST", - issuer="https://10.120.20.200/saml-sp/SAML2/POST", - signing_kp=static_keypair, - verification_kp=static_keypair, - ) - parsed_request = AuthNRequestParser(provider).parse_detached( - REDIRECT_REQUEST, REDIRECT_RELAY_STATE, REDIRECT_SIGNATURE, REDIRECT_SIG_ALG - ) - self.assertEqual(parsed_request.id, "_dcf55fcd27a887e60a7ef9ee6fd3adab") - self.assertEqual(parsed_request.name_id_policy, SAML_NAME_ID_FORMAT_EMAIL) - self.assertEqual(parsed_request.relay_state, REDIRECT_RELAY_STATE) diff --git a/passbook/providers/saml/tests/test_utils_time.py b/passbook/providers/saml/tests/test_utils_time.py deleted file mode 100644 index a596481d..00000000 --- a/passbook/providers/saml/tests/test_utils_time.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Test time utils""" -from datetime import timedelta - -from django.core.exceptions import ValidationError -from django.test import TestCase - -from passbook.lib.utils.time import timedelta_from_string, timedelta_string_validator - - -class TestTimeUtils(TestCase): - """Test time-utils""" - - def test_valid(self): - """Test valid expression""" - expr = "hours=3;minutes=1" - expected = timedelta(hours=3, minutes=1) - self.assertEqual(timedelta_from_string(expr), expected) - - def test_invalid(self): - """Test invalid expression""" - with self.assertRaises(ValueError): - timedelta_from_string("foo") - - def test_validation(self): - """Test Django model field validator""" - with self.assertRaises(ValidationError): - timedelta_string_validator("foo") diff --git a/passbook/providers/saml/urls.py b/passbook/providers/saml/urls.py deleted file mode 100644 index 0a3edb65..00000000 --- a/passbook/providers/saml/urls.py +++ /dev/null @@ -1,29 +0,0 @@ -"""passbook SAML IDP URLs""" -from django.urls import path - -from passbook.providers.saml import views - -urlpatterns = [ - # SSO Bindings - path( - "/sso/binding/redirect/", - views.SAMLSSOBindingRedirectView.as_view(), - name="sso-redirect", - ), - path( - "/sso/binding/post/", - views.SAMLSSOBindingPOSTView.as_view(), - name="sso-post", - ), - # SSO IdP Initiated - path( - "/sso/binding/init/", - views.SAMLSSOBindingInitView.as_view(), - name="sso-init", - ), - path( - "/metadata/", - views.DescriptorDownloadView.as_view(), - name="metadata", - ), -] diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py deleted file mode 100644 index eed2d673..00000000 --- a/passbook/providers/saml/views.py +++ /dev/null @@ -1,239 +0,0 @@ -"""passbook SAML IDP Views""" -from typing import Optional - -from django.core.validators import URLValidator -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.utils.decorators import method_decorator -from django.utils.http import urlencode -from django.utils.translation import gettext_lazy as _ -from django.views import View -from django.views.decorators.csrf import csrf_exempt -from structlog import get_logger - -from passbook.audit.models import Event, EventAction -from passbook.core.models import Application, Provider -from passbook.flows.models import in_memory_stage -from passbook.flows.planner import ( - PLAN_CONTEXT_APPLICATION, - PLAN_CONTEXT_SSO, - FlowPlanner, -) -from passbook.flows.stage import StageView -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.lib.utils.urls import redirect_with_qs -from passbook.lib.views import bad_request_message -from passbook.policies.views import PolicyAccessView -from passbook.providers.saml.exceptions import CannotHandleAssertion -from passbook.providers.saml.models import SAMLBindings, SAMLProvider -from passbook.providers.saml.processors.assertion import AssertionProcessor -from passbook.providers.saml.processors.metadata import MetadataProcessor -from passbook.providers.saml.processors.request_parser import ( - AuthNRequest, - AuthNRequestParser, -) -from passbook.providers.saml.utils.encoding import deflate_and_base64_encode, nice64 -from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE - -LOGGER = get_logger() -URL_VALIDATOR = URLValidator(schemes=("http", "https")) -REQUEST_KEY_SAML_REQUEST = "SAMLRequest" -REQUEST_KEY_SAML_SIGNATURE = "Signature" -REQUEST_KEY_SAML_SIG_ALG = "SigAlg" -REQUEST_KEY_SAML_RESPONSE = "SAMLResponse" -REQUEST_KEY_RELAY_STATE = "RelayState" - -SESSION_KEY_AUTH_N_REQUEST = "authn_request" - - -class SAMLSSOView(PolicyAccessView): - """ "SAML SSO Base View, which plans a flow and injects our final stage. - Calls get/post handler.""" - - def resolve_provider_application(self): - self.application = get_object_or_404( - Application, slug=self.kwargs["application_slug"] - ) - self.provider: SAMLProvider = get_object_or_404( - SAMLProvider, pk=self.application.provider_id - ) - - def check_saml_request(self) -> Optional[HttpRequest]: - """Handler to verify the SAML Request. Must be implemented by a subclass""" - raise NotImplementedError - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: - """Verify the SAML Request, and if valid initiate the FlowPlanner for the application""" - # Call the method handler, which checks the SAML - # Request and returns a HTTP Response on error - method_response = self.check_saml_request() - if method_response: - return method_response - # Regardless, we start the planner and return to it - planner = FlowPlanner(self.provider.authorization_flow) - planner.allow_empty_flows = True - plan = planner.plan( - request, - { - PLAN_CONTEXT_SSO: True, - PLAN_CONTEXT_APPLICATION: self.application, - PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html", - }, - ) - plan.append(in_memory_stage(SAMLFlowFinalView)) - request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - request.GET, - flow_slug=self.provider.authorization_flow.slug, - ) - - def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: - """GET and POST use the same handler, but we can't - override .dispatch easily because PolicyAccessView's dispatch""" - return self.get(request, application_slug) - - -class SAMLSSOBindingRedirectView(SAMLSSOView): - """SAML Handler for SSO/Redirect bindings, which are sent via GET""" - - def check_saml_request(self) -> Optional[HttpRequest]: - """Handle REDIRECT bindings""" - if REQUEST_KEY_SAML_REQUEST not in self.request.GET: - LOGGER.info("handle_saml_request: SAML payload missing") - return bad_request_message( - self.request, "The SAML request payload is missing." - ) - - try: - auth_n_request = AuthNRequestParser(self.provider).parse_detached( - self.request.GET[REQUEST_KEY_SAML_REQUEST], - self.request.GET.get(REQUEST_KEY_RELAY_STATE), - self.request.GET.get(REQUEST_KEY_SAML_SIGNATURE), - self.request.GET.get(REQUEST_KEY_SAML_SIG_ALG), - ) - self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request - except CannotHandleAssertion as exc: - LOGGER.info(exc) - return bad_request_message(self.request, str(exc)) - return None - - -@method_decorator(csrf_exempt, name="dispatch") -class SAMLSSOBindingPOSTView(SAMLSSOView): - """SAML Handler for SSO/POST bindings""" - - def check_saml_request(self) -> Optional[HttpRequest]: - """Handle POST bindings""" - if REQUEST_KEY_SAML_REQUEST not in self.request.POST: - LOGGER.info("check_saml_request: SAML payload missing") - return bad_request_message( - self.request, "The SAML request payload is missing." - ) - - try: - auth_n_request = AuthNRequestParser(self.provider).parse( - self.request.POST[REQUEST_KEY_SAML_REQUEST], - self.request.POST.get(REQUEST_KEY_RELAY_STATE), - ) - self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request - except CannotHandleAssertion as exc: - LOGGER.info(exc) - return bad_request_message(self.request, str(exc)) - return None - - -class SAMLSSOBindingInitView(SAMLSSOView): - """SAML Handler for for IdP Initiated login flows""" - - def check_saml_request(self) -> Optional[HttpRequest]: - """Create SAML Response from scratch""" - LOGGER.debug( - "handle_saml_no_request: No SAML Request, using IdP-initiated flow." - ) - auth_n_request = AuthNRequestParser(self.provider).idp_initiated() - self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request - - -# This View doesn't have a URL on purpose, as its called by the FlowExecutor -class SAMLFlowFinalView(StageView): - """View used by FlowExecutor after all stages have passed. Logs the authorization, - and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for - (if POST is configured).""" - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] - provider: SAMLProvider = get_object_or_404( - SAMLProvider, pk=application.provider_id - ) - # Log Application Authorization - Event.new( - EventAction.AUTHORIZE_APPLICATION, - authorized_application=application, - flow=self.executor.plan.flow_pk, - ).from_http(self.request) - - if SESSION_KEY_AUTH_N_REQUEST not in self.request.session: - return self.executor.stage_invalid() - - auth_n_request: AuthNRequest = self.request.session.pop( - SESSION_KEY_AUTH_N_REQUEST - ) - response = AssertionProcessor( - provider, request, auth_n_request - ).build_response() - - if provider.sp_binding == SAMLBindings.POST: - form_attrs = { - "ACSUrl": provider.acs_url, - REQUEST_KEY_SAML_RESPONSE: nice64(response), - } - if auth_n_request.relay_state: - form_attrs[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state - return render( - self.request, - "generic/autosubmit_form.html", - { - "url": provider.acs_url, - "title": _("Redirecting to %(app)s..." % {"app": application.name}), - "attrs": form_attrs, - }, - ) - if provider.sp_binding == SAMLBindings.REDIRECT: - url_args = { - REQUEST_KEY_SAML_RESPONSE: deflate_and_base64_encode(response), - } - if auth_n_request.relay_state: - url_args[REQUEST_KEY_RELAY_STATE] = auth_n_request.relay_state - querystring = urlencode(url_args) - return redirect(f"{provider.acs_url}?{querystring}") - return bad_request_message(request, "Invalid sp_binding specified") - - -class DescriptorDownloadView(View): - """Replies with the XML Metadata IDSSODescriptor.""" - - @staticmethod - def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str: - """Return rendered XML Metadata""" - return MetadataProcessor(provider, request).build_entity_descriptor() - - def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: - """Replies with the XML Metadata IDSSODescriptor.""" - application = get_object_or_404(Application, slug=application_slug) - provider: SAMLProvider = get_object_or_404( - SAMLProvider, pk=application.provider_id - ) - try: - metadata = DescriptorDownloadView.get_metadata(request, provider) - except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member - return bad_request_message( - request, "Provider is not assigned to an application." - ) - else: - response = HttpResponse(metadata, content_type="application/xml") - response[ - "Content-Disposition" - ] = f'attachment; filename="{provider.name}_passbook_meta.xml"' - return response diff --git a/passbook/recovery/apps.py b/passbook/recovery/apps.py deleted file mode 100644 index def3c603..00000000 --- a/passbook/recovery/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook Recovery app config""" -from django.apps import AppConfig - - -class PassbookRecoveryConfig(AppConfig): - """passbook Recovery app config""" - - name = "passbook.recovery" - label = "passbook_recovery" - verbose_name = "passbook Recovery" - mountpoint = "recovery/" diff --git a/passbook/recovery/management/commands/create_recovery_key.py b/passbook/recovery/management/commands/create_recovery_key.py deleted file mode 100644 index 48fd083d..00000000 --- a/passbook/recovery/management/commands/create_recovery_key.py +++ /dev/null @@ -1,54 +0,0 @@ -"""passbook recovery createkey command""" -from datetime import timedelta -from getpass import getuser - -from django.core.management.base import BaseCommand -from django.urls import reverse -from django.utils.timezone import now -from django.utils.translation import gettext as _ -from structlog import get_logger - -from passbook.core.models import Token, TokenIntents, User - -LOGGER = get_logger() - - -class Command(BaseCommand): - """Create Token used to recover access""" - - help = _("Create a Key which can be used to restore access to passbook.") - - def add_arguments(self, parser): - parser.add_argument( - "duration", - default=1, - action="store", - help="How long the token is valid for (in years).", - ) - parser.add_argument( - "user", action="store", help="Which user the Token gives access to." - ) - - def get_url(self, token: Token) -> str: - """Get full recovery link""" - return reverse("passbook_recovery:use-token", kwargs={"key": str(token.key)}) - - def handle(self, *args, **options): - """Create Token used to recover access""" - duration = int(options.get("duration", 1)) - _now = now() - expiry = _now + timedelta(days=duration * 365.2425) - user = User.objects.get(username=options.get("user")) - token = Token.objects.create( - expires=expiry, - user=user, - intent=TokenIntents.INTENT_RECOVERY, - description=f"Recovery Token generated by {getuser()} on {_now}", - ) - self.stdout.write( - ( - f"Store this link safely, as it will allow" - f" anyone to access passbook as {user}." - ) - ) - self.stdout.write(self.get_url(token)) diff --git a/passbook/recovery/tests.py b/passbook/recovery/tests.py deleted file mode 100644 index e12d5a3d..00000000 --- a/passbook/recovery/tests.py +++ /dev/null @@ -1,34 +0,0 @@ -"""recovery tests""" -from io import StringIO - -from django.core.management import call_command -from django.shortcuts import reverse -from django.test import TestCase - -from passbook.core.models import Token, TokenIntents, User - - -class TestRecovery(TestCase): - """recovery tests""" - - def setUp(self): - self.user = User.objects.create_user(username="recovery-test-user") - - def test_create_key(self): - """Test creation of a new key""" - out = StringIO() - self.assertEqual(len(Token.objects.all()), 0) - call_command("create_recovery_key", "1", self.user.username, stdout=out) - token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user) - self.assertIn(token.key, out.getvalue()) - self.assertEqual(len(Token.objects.all()), 1) - - def test_recovery_view(self): - """Test recovery view""" - out = StringIO() - call_command("create_recovery_key", "1", self.user.username, stdout=out) - token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user) - self.client.get( - reverse("passbook_recovery:use-token", kwargs={"key": token.key}) - ) - self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk) diff --git a/passbook/recovery/urls.py b/passbook/recovery/urls.py deleted file mode 100644 index 136b78f4..00000000 --- a/passbook/recovery/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -"""recovery views""" - -from django.urls import path - -from passbook.recovery.views import UseTokenView - -urlpatterns = [ - path("use-token//", UseTokenView.as_view(), name="use-token"), -] diff --git a/passbook/recovery/views.py b/passbook/recovery/views.py deleted file mode 100644 index 561e14e9..00000000 --- a/passbook/recovery/views.py +++ /dev/null @@ -1,24 +0,0 @@ -"""recovery views""" -from django.contrib import messages -from django.contrib.auth import login -from django.http import Http404, HttpRequest, HttpResponse -from django.shortcuts import redirect -from django.utils.translation import gettext as _ -from django.views import View - -from passbook.core.models import Token, TokenIntents - - -class UseTokenView(View): - """Use token to login""" - - def get(self, request: HttpRequest, key: str) -> HttpResponse: - """Check if token exists, log user in and delete token.""" - tokens = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_RECOVERY) - if not tokens.exists(): - raise Http404 - token = tokens.first() - login(request, token.user, backend="django.contrib.auth.backends.ModelBackend") - token.delete() - messages.warning(request, _("Used recovery-link to authenticate.")) - return redirect("passbook_core:shell") diff --git a/passbook/root/asgi.py b/passbook/root/asgi.py deleted file mode 100644 index 3dc93d44..00000000 --- a/passbook/root/asgi.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -ASGI config for passbook project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ -""" -import typing -from time import time -from typing import Any, ByteString, Dict - -import django -from asgiref.compatibility import guarantee_single_callable -from channels.routing import ProtocolTypeRouter, URLRouter -from defusedxml import defuse_stdlib -from django.core.asgi import get_asgi_application -from sentry_sdk.integrations.asgi import SentryAsgiMiddleware -from structlog import get_logger - -# DJANGO_SETTINGS_MODULE is set in gunicorn.conf.py - -defuse_stdlib() -django.setup() - -# pylint: disable=wrong-import-position -from passbook.root import websocket # noqa # isort:skip - - -# See https://github.com/encode/starlette/blob/master/starlette/types.py -Scope = typing.MutableMapping[str, typing.Any] -Message = typing.MutableMapping[str, typing.Any] - -Receive = typing.Callable[[], typing.Awaitable[Message]] -Send = typing.Callable[[Message], typing.Awaitable[None]] - -ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]] - -ASGI_IP_HEADERS = ( - b"x-forwarded-for", - b"x-real-ip", -) - -LOGGER = get_logger("passbook.asgi") - - -class ASGILoggerMiddleware: - """Main ASGI Logger middleware, starts an ASGILogger for each request""" - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send): - responder = ASGILogger(self.app) - await responder(scope, receive, send) - return - - -class ASGILogger: - """ASGI Logger, instantiated for each request""" - - app: ASGIApp - - scope: Scope - headers: Dict[ByteString, Any] - - status_code: int - start: float - content_length: int - - def __init__(self, app: ASGIApp): - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - self.scope = scope - self.content_length = 0 - self.headers = dict(scope.get("headers", [])) - - async def send_hooked(message: Message) -> None: - """Hooked send method, which records status code and content-length, and for the final - requests logs it""" - headers = dict(message.get("headers", [])) - - if "status" in message: - self.status_code = message["status"] - - if b"Content-Length" in headers: - self.content_length += int(headers.get(b"Content-Length", b"0")) - - if message["type"] == "http.response.body" and not message.get( - "more_body", None - ): - runtime = int((time() - self.start) * 10 ** 6) - self.log(runtime) - await send(message) - - if self.headers.get(b"host", b"") == b"passbook-healthcheck-host": - # Don't log healthcheck/readiness requests - await send({"type": "http.response.start", "status": 204, "headers": []}) - await send({"type": "http.response.body", "body": ""}) - return - - self.start = time() - if scope["type"] == "lifespan": - # https://code.djangoproject.com/ticket/31508 - # https://github.com/encode/uvicorn/issues/266 - return - await self.app(scope, receive, send_hooked) - - def _get_ip(self) -> str: - client_ip = None - for header in ASGI_IP_HEADERS: - if header in self.headers: - client_ip = self.headers[header].decode() - if not client_ip: - client_ip, _ = self.scope.get("client", ("", 0)) - # Check if header has multiple values, and use the first one - return client_ip.split(", ")[0] - - def log(self, runtime: float): - """Outpot access logs in a structured format""" - host = self._get_ip() - query_string = "" - if self.scope.get("query_string", b"") != b"": - query_string = f"?{self.scope.get('query_string').decode()}" - LOGGER.info( - f"{self.scope.get('path', '')}{query_string}", - host=host, - method=self.scope.get("method", ""), - scheme=self.scope.get("scheme", ""), - status=self.status_code, - size=self.content_length / 1000 if self.content_length > 0 else "-", - runtime=runtime, - ) - - -application = ASGILogger( - guarantee_single_callable( - SentryAsgiMiddleware( - ProtocolTypeRouter( - { - "http": get_asgi_application(), - "websocket": URLRouter(websocket.websocket_urlpatterns), - } - ) - ) - ) -) diff --git a/passbook/root/celery.py b/passbook/root/celery.py deleted file mode 100644 index c8d12aab..00000000 --- a/passbook/root/celery.py +++ /dev/null @@ -1,55 +0,0 @@ -"""passbook core celery""" -import os -from logging.config import dictConfig - -from celery import Celery -from celery.signals import after_task_publish, setup_logging, task_postrun, task_prerun -from django.conf import settings -from structlog import get_logger - -# set the default Django settings module for the 'celery' program. -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings") - -LOGGER = get_logger() -CELERY_APP = Celery("passbook") - - -# pylint: disable=unused-argument -@setup_logging.connect -def config_loggers(*args, **kwags): - """Apply logging settings from settings.py to celery""" - dictConfig(settings.LOGGING) - - -# pylint: disable=unused-argument -@after_task_publish.connect -def after_task_publish(sender=None, headers=None, body=None, **kwargs): - """Log task_id after it was published""" - info = headers if "task" in headers else body - LOGGER.debug( - "Task published", task_id=info.get("id", ""), task_name=info.get("task", "") - ) - - -# pylint: disable=unused-argument -@task_prerun.connect -def task_prerun(task_id, task, *args, **kwargs): - """Log task_id on worker""" - LOGGER.debug("Task started", task_id=task_id, task_name=task.__name__) - - -# pylint: disable=unused-argument -@task_postrun.connect -def task_postrun(task_id, task, *args, retval=None, state=None, **kwargs): - """Log task_id on worker""" - LOGGER.debug("Task finished", task_id=task_id, task_name=task.__name__, state=state) - - -# Using a string here means the worker doesn't have to serialize -# the configuration object to child processes. -# - namespace='CELERY' means all celery-related configuration keys -# should have a `CELERY_` prefix. -CELERY_APP.config_from_object(settings, namespace="CELERY") - -# Load task modules from all registered Django app configs. -CELERY_APP.autodiscover_tasks() diff --git a/passbook/root/monitoring.py b/passbook/root/monitoring.py deleted file mode 100644 index fa814f65..00000000 --- a/passbook/root/monitoring.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Metrics view""" -from base64 import b64encode - -from django.conf import settings -from django.http import HttpRequest, HttpResponse -from django.views import View -from django_prometheus.exports import ExportToDjangoView - - -class MetricsView(View): - """Wrapper around ExportToDjangoView, using http-basic auth""" - - def get(self, request: HttpRequest) -> HttpResponse: - """Check for HTTP-Basic auth""" - auth_header = request.META.get("HTTP_AUTHORIZATION", "") - auth_type, _, given_credentials = auth_header.partition(" ") - credentials = f"monitor:{settings.SECRET_KEY}" - expected = b64encode(str.encode(credentials)).decode() - - if auth_type != "Basic" or given_credentials != expected: - response = HttpResponse(status=401) - response["WWW-Authenticate"] = 'Basic realm="passbook-monitoring"' - return response - - return ExportToDjangoView(request) diff --git a/passbook/root/settings.py b/passbook/root/settings.py deleted file mode 100644 index 81effe55..00000000 --- a/passbook/root/settings.py +++ /dev/null @@ -1,460 +0,0 @@ -""" -Django settings for passbook project. - -Generated by 'django-admin startproject' using Django 2.1.3. - -For more information on this file, see -https://docs.djangoproject.com/en/2.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.1/ref/settings/ -""" - -import importlib -import os -import sys -from json import dumps -from time import time - -import structlog -from celery.schedules import crontab -from sentry_sdk import init as sentry_init -from sentry_sdk.integrations.celery import CeleryIntegration -from sentry_sdk.integrations.django import DjangoIntegration -from sentry_sdk.integrations.redis import RedisIntegration - -from passbook import __version__ -from passbook.core.middleware import structlog_add_request_id -from passbook.lib.config import CONFIG -from passbook.lib.logging import add_common_fields, add_process_id -from passbook.lib.sentry import before_send - - -def j_print(event: str, log_level: str = "info", **kwargs): - """Print event in the same format as structlog with JSON. - Used before structlog is configured.""" - data = { - "event": event, - "level": log_level, - "logger": __name__, - "timestamp": time(), - } - data.update(**kwargs) - print(dumps(data), file=sys.stderr) - - -LOGGER = structlog.get_logger() - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -STATIC_ROOT = BASE_DIR + "/static" -STATICFILES_DIRS = [BASE_DIR + "/web"] -MEDIA_ROOT = BASE_DIR + "/media" - -SECRET_KEY = CONFIG.y( - "secret_key", "9$@r!d^1^jrn#fk#1#@ks#9&i$^s#1)_13%$rwjrhd=e8jfi_s" -) # noqa Debug - -DEBUG = CONFIG.y_bool("debug") -INTERNAL_IPS = ["127.0.0.1"] -ALLOWED_HOSTS = ["*"] -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") - -LOGIN_URL = "passbook_flows:default-authentication" - -# Custom user model -AUTH_USER_MODEL = "passbook_core.User" - -_cookie_suffix = "_debug" if DEBUG else "" -CSRF_COOKIE_NAME = "passbook_csrf" -LANGUAGE_COOKIE_NAME = f"passbook_language{_cookie_suffix}" -SESSION_COOKIE_NAME = f"passbook_session{_cookie_suffix}" - -AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", - "guardian.backends.ObjectPermissionBackend", -] - -# Application definition -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "django.contrib.humanize", - "passbook.admin.apps.PassbookAdminConfig", - "passbook.api.apps.PassbookAPIConfig", - "passbook.audit.apps.PassbookAuditConfig", - "passbook.crypto.apps.PassbookCryptoConfig", - "passbook.flows.apps.PassbookFlowsConfig", - "passbook.outposts.apps.PassbookOutpostConfig", - "passbook.lib.apps.PassbookLibConfig", - "passbook.policies.apps.PassbookPoliciesConfig", - "passbook.policies.dummy.apps.PassbookPolicyDummyConfig", - "passbook.policies.expiry.apps.PassbookPolicyExpiryConfig", - "passbook.policies.expression.apps.PassbookPolicyExpressionConfig", - "passbook.policies.hibp.apps.PassbookPolicyHIBPConfig", - "passbook.policies.password.apps.PassbookPoliciesPasswordConfig", - "passbook.policies.group_membership.apps.PassbookPoliciesGroupMembershipConfig", - "passbook.policies.reputation.apps.PassbookPolicyReputationConfig", - "passbook.providers.proxy.apps.PassbookProviderProxyConfig", - "passbook.providers.oauth2.apps.PassbookProviderOAuth2Config", - "passbook.providers.saml.apps.PassbookProviderSAMLConfig", - "passbook.recovery.apps.PassbookRecoveryConfig", - "passbook.sources.ldap.apps.PassbookSourceLDAPConfig", - "passbook.sources.oauth.apps.PassbookSourceOAuthConfig", - "passbook.sources.saml.apps.PassbookSourceSAMLConfig", - "passbook.stages.captcha.apps.PassbookStageCaptchaConfig", - "passbook.stages.consent.apps.PassbookStageConsentConfig", - "passbook.stages.dummy.apps.PassbookStageDummyConfig", - "passbook.stages.email.apps.PassbookStageEmailConfig", - "passbook.stages.prompt.apps.PassbookStagPromptConfig", - "passbook.stages.identification.apps.PassbookStageIdentificationConfig", - "passbook.stages.invitation.apps.PassbookStageUserInvitationConfig", - "passbook.stages.user_delete.apps.PassbookStageUserDeleteConfig", - "passbook.stages.user_login.apps.PassbookStageUserLoginConfig", - "passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig", - "passbook.stages.user_write.apps.PassbookStageUserWriteConfig", - "passbook.stages.otp_static.apps.PassbookStageOTPStaticConfig", - "passbook.stages.otp_time.apps.PassbookStageOTPTimeConfig", - "passbook.stages.otp_validate.apps.PassbookStageOTPValidateConfig", - "passbook.stages.password.apps.PassbookStagePasswordConfig", - "rest_framework", - "django_filters", - "drf_yasg2", - "guardian", - "django_prometheus", - "channels", - "dbbackup", -] - -GUARDIAN_MONKEY_PATCH = False - -SWAGGER_SETTINGS = { - "DEFAULT_INFO": "passbook.api.v2.urls.info", - "SECURITY_DEFINITIONS": { - "token": {"type": "apiKey", "name": "Authorization", "in": "header"} - }, -} - -REST_FRAMEWORK = { - "DEFAULT_PAGINATION_CLASS": "passbook.api.pagination.Pagination", - "PAGE_SIZE": 100, - "DEFAULT_FILTER_BACKENDS": [ - "rest_framework_guardian.filters.ObjectPermissionsFilter", - "django_filters.rest_framework.DjangoFilterBackend", - "rest_framework.filters.OrderingFilter", - "rest_framework.filters.SearchFilter", - ], - "DEFAULT_PERMISSION_CLASSES": ( - "rest_framework.permissions.DjangoObjectPermissions", - ), - "DEFAULT_AUTHENTICATION_CLASSES": ( - "passbook.api.auth.PassbookTokenAuthentication", - "rest_framework.authentication.SessionAuthentication", - ), -} - -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": ( - f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" - f"/{CONFIG.y('redis.cache_db')}" - ), - "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, - } -} -DJANGO_REDIS_IGNORE_EXCEPTIONS = True -DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True -SESSION_ENGINE = "django.contrib.sessions.backends.cache" -SESSION_CACHE_ALIAS = "default" -SESSION_COOKIE_SAMESITE = "lax" - -MESSAGE_STORAGE = "passbook.root.messages.storage.ChannelsStorage" - -MIDDLEWARE = [ - "django_prometheus.middleware.PrometheusBeforeMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "passbook.core.middleware.RequestIDMiddleware", - "passbook.audit.middleware.AuditMiddleware", - "django.middleware.security.SecurityMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "passbook.core.middleware.ImpersonateMiddleware", - "django_prometheus.middleware.PrometheusAfterMiddleware", -] - -ROOT_URLCONF = "passbook.root.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - "passbook.lib.config.context_processor", - ], - }, - }, -] - -ASGI_APPLICATION = "passbook.root.asgi.application" - -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels_redis.core.RedisChannelLayer", - "CONFIG": { - "hosts": [ - f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379" - f"/{CONFIG.y('redis.ws_db')}" - ], - }, - }, -} - - -# Database -# https://docs.djangoproject.com/en/2.1/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "HOST": CONFIG.y("postgresql.host"), - "NAME": CONFIG.y("postgresql.name"), - "USER": CONFIG.y("postgresql.user"), - "PASSWORD": CONFIG.y("postgresql.password"), - } -} - -# Password validation -# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, - {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, - {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, -] - - -# Internationalization -# https://docs.djangoproject.com/en/2.1/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Celery settings -# Add a 10 minute timeout to all Celery tasks. -CELERY_TASK_SOFT_TIME_LIMIT = 600 -CELERY_BEAT_SCHEDULE = { - "clean_expired_models": { - "task": "passbook.core.tasks.clean_expired_models", - "schedule": crontab(minute="*/5"), - "options": {"queue": "passbook_scheduled"}, - }, - "db_backup": { - "task": "passbook.core.tasks.backup_database", - "schedule": crontab(minute=0, hour=0), - "options": {"queue": "passbook_scheduled"}, - }, -} -CELERY_TASK_CREATE_MISSING_QUEUES = True -CELERY_TASK_DEFAULT_QUEUE = "passbook" -CELERY_BROKER_URL = ( - f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}" - f":6379/{CONFIG.y('redis.message_queue_db')}" -) -CELERY_RESULT_BACKEND = ( - f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}" - f":6379/{CONFIG.y('redis.message_queue_db')}" -) - -# Database backup -DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage" -DBBACKUP_STORAGE_OPTIONS = {"location": "./backups" if DEBUG else "/backups"} -DBBACKUP_CONNECTOR_MAPPING = { - "django_prometheus.db.backends.postgresql": "dbbackup.db.postgresql.PgDumpConnector" -} -if CONFIG.y("postgresql.s3_backup"): - DBBACKUP_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" - DBBACKUP_STORAGE_OPTIONS = { - "access_key": CONFIG.y("postgresql.s3_backup.access_key"), - "secret_key": CONFIG.y("postgresql.s3_backup.secret_key"), - "bucket_name": CONFIG.y("postgresql.s3_backup.bucket"), - "region_name": CONFIG.y("postgresql.s3_backup.region", "eu-central-1"), - "default_acl": "private", - "endpoint_url": CONFIG.y("postgresql.s3_backup.host"), - } - j_print( - "Database backup to S3 is configured.", - host=CONFIG.y("postgresql.s3_backup.host"), - ) - -# Sentry integration -_ERROR_REPORTING = CONFIG.y_bool("error_reporting.enabled", False) -if not DEBUG and _ERROR_REPORTING: - sentry_init( - dsn="https://33cdbcb23f8b436dbe0ee06847410b67@sentry.beryju.org/3", - integrations=[ - DjangoIntegration(transaction_style="function_name"), - CeleryIntegration(), - RedisIntegration(), - ], - before_send=before_send, - release="passbook@%s" % __version__, - traces_sample_rate=0.6, - environment=CONFIG.y("error_reporting.environment", "customer"), - send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False), - ) - j_print( - "Error reporting is enabled.", - env=CONFIG.y("error_reporting.environment", "customer"), - ) - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.1/howto/static-files/ - -STATIC_URL = "/static/" -MEDIA_URL = "/media/" - - -structlog.configure_once( - processors=[ - structlog.stdlib.add_log_level, - structlog.stdlib.add_logger_name, - add_process_id, - add_common_fields(CONFIG.y("error_reporting.environment", "customer")), - structlog_add_request_id, - structlog.stdlib.PositionalArgumentsFormatter(), - structlog.processors.TimeStamper(), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.stdlib.ProcessorFormatter.wrap_for_formatter, - ], - context_class=structlog.threadlocal.wrap_dict(dict), - logger_factory=structlog.stdlib.LoggerFactory(), - wrapper_class=structlog.stdlib.BoundLogger, - cache_logger_on_first_use=True, -) - -LOG_PRE_CHAIN = [ - # Add the log level and a timestamp to the event_dict if the log entry - # is not from structlog. - structlog.stdlib.add_log_level, - structlog.stdlib.add_logger_name, - structlog.processors.TimeStamper(), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, -] - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "plain": { - "()": structlog.stdlib.ProcessorFormatter, - "processor": structlog.processors.JSONRenderer(sort_keys=True), - "foreign_pre_chain": LOG_PRE_CHAIN, - }, - "colored": { - "()": structlog.stdlib.ProcessorFormatter, - "processor": structlog.dev.ConsoleRenderer(colors=DEBUG), - "foreign_pre_chain": LOG_PRE_CHAIN, - }, - }, - "handlers": { - "console": { - "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "colored" if DEBUG else "plain", - }, - }, - "loggers": {}, -} - -TEST = False -TEST_RUNNER = "passbook.root.test_runner.PytestTestRunner" -LOG_LEVEL = CONFIG.y("log_level").upper() - - -_LOGGING_HANDLER_MAP = { - "": LOG_LEVEL, - "passbook": LOG_LEVEL, - "django": "WARNING", - "celery": "WARNING", - "selenium": "WARNING", - "grpc": LOG_LEVEL, - "docker": "WARNING", - "urllib3": "WARNING", - "websockets": "WARNING", - "daphne": "WARNING", - "dbbackup": "ERROR", - "kubernetes": "INFO", - "asyncio": "WARNING", -} -for handler_name, level in _LOGGING_HANDLER_MAP.items(): - # pyright: reportGeneralTypeIssues=false - LOGGING["loggers"][handler_name] = { - "handlers": ["console"], - "level": level, - "propagate": False, - } - - -_DISALLOWED_ITEMS = [ - "INSTALLED_APPS", - "MIDDLEWARE", - "AUTHENTICATION_BACKENDS", - "CELERY_BEAT_SCHEDULE", -] -# Load subapps's INSTALLED_APPS -for _app in INSTALLED_APPS: - if _app.startswith("passbook"): - if "apps" in _app: - _app = ".".join(_app.split(".")[:-2]) - try: - app_settings = importlib.import_module("%s.settings" % _app) - INSTALLED_APPS.extend(getattr(app_settings, "INSTALLED_APPS", [])) - MIDDLEWARE.extend(getattr(app_settings, "MIDDLEWARE", [])) - AUTHENTICATION_BACKENDS.extend( - getattr(app_settings, "AUTHENTICATION_BACKENDS", []) - ) - CELERY_BEAT_SCHEDULE.update( - getattr(app_settings, "CELERY_BEAT_SCHEDULE", {}) - ) - for _attr in dir(app_settings): - if not _attr.startswith("__") and _attr not in _DISALLOWED_ITEMS: - globals()[_attr] = getattr(app_settings, _attr) - except ImportError: - pass - -if DEBUG: - INSTALLED_APPS.append("debug_toolbar") - MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") - CELERY_TASK_ALWAYS_EAGER = True - -INSTALLED_APPS.append("passbook.core.apps.PassbookCoreConfig") - -j_print("Booting passbook", version=__version__) diff --git a/passbook/root/test_runner.py b/passbook/root/test_runner.py deleted file mode 100644 index 6e9e5db6..00000000 --- a/passbook/root/test_runner.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Integrate ./manage.py test with pytest""" -from django.conf import settings - -from passbook.lib.config import CONFIG - - -class PytestTestRunner: - """Runs pytest to discover and run tests.""" - - def __init__(self, verbosity=1, failfast=False, keepdb=False, **_): - self.verbosity = verbosity - self.failfast = failfast - self.keepdb = keepdb - settings.TEST = True - settings.CELERY_TASK_ALWAYS_EAGER = True - CONFIG.raw.get("passbook")["avatars"] = "none" - - def run_tests(self, test_labels): - """Run pytest and return the exitcode. - - It translates some of Django's test command option to pytest's. - """ - import pytest - - argv = [] - if self.verbosity == 0: - argv.append("--quiet") - if self.verbosity == 2: - argv.append("--verbose") - if self.verbosity == 3: - argv.append("-vv") - if self.failfast: - argv.append("--exitfirst") - if self.keepdb: - argv.append("--reuse-db") - - argv.extend(test_labels) - return pytest.main(argv) diff --git a/passbook/root/urls.py b/passbook/root/urls.py deleted file mode 100644 index 6d8adbc4..00000000 --- a/passbook/root/urls.py +++ /dev/null @@ -1,73 +0,0 @@ -"""passbook URL Configuration""" -from django.conf import settings -from django.conf.urls.static import static -from django.contrib import admin -from django.urls import include, path -from django.views.generic import RedirectView -from django.views.i18n import JavaScriptCatalog -from structlog import get_logger - -from passbook.core.views import error -from passbook.lib.utils.reflection import get_apps -from passbook.root.monitoring import MetricsView - -LOGGER = get_logger() -admin.autodiscover() -admin.site.login = RedirectView.as_view( - pattern_name="passbook_flows:default-authentication" -) -admin.site.logout = RedirectView.as_view( - pattern_name="passbook_flows:default-invalidation" -) - -handler400 = error.BadRequestView.as_view() -handler403 = error.ForbiddenView.as_view() -handler404 = error.NotFoundView.as_view() -handler500 = error.ServerErrorView.as_view() - -urlpatterns = [] - -for _passbook_app in get_apps(): - mountpoints = None - base_url_module = _passbook_app.name + ".urls" - if hasattr(_passbook_app, "mountpoint"): - mountpoint = getattr(_passbook_app, "mountpoint") - mountpoints = {base_url_module: mountpoint} - if hasattr(_passbook_app, "mountpoints"): - mountpoints = getattr(_passbook_app, "mountpoints") - if not mountpoints: - continue - for module, mountpoint in mountpoints.items(): - namespace = _passbook_app.label + module.replace(base_url_module, "") - _path = path( - mountpoint, - include( - (module, _passbook_app.label), - namespace=namespace, - ), - ) - urlpatterns.append(_path) - LOGGER.debug( - "Mounted URLs", - app_name=_passbook_app.name, - mountpoint=mountpoint, - namespace=namespace, - ) - -urlpatterns += [ - path("administration/django/", admin.site.urls), - path("metrics/", MetricsView.as_view(), name="metrics"), - path("-/jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), -] - -if settings.DEBUG: - import debug_toolbar - - urlpatterns = ( - [ - path("-/debug/", include(debug_toolbar.urls)), - ] - + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - + urlpatterns - ) diff --git a/passbook/root/websocket.py b/passbook/root/websocket.py deleted file mode 100644 index 31b51811..00000000 --- a/passbook/root/websocket.py +++ /dev/null @@ -1,11 +0,0 @@ -"""root Websocket URLS""" -from channels.auth import AuthMiddlewareStack -from django.urls import path - -from passbook.outposts.channels import OutpostConsumer -from passbook.root.messages.consumer import MessageConsumer - -websocket_urlpatterns = [ - path("ws/outpost//", OutpostConsumer.as_asgi()), - path("ws/client/", AuthMiddlewareStack(MessageConsumer.as_asgi())), -] diff --git a/passbook/sources/ldap/api.py b/passbook/sources/ldap/api.py deleted file mode 100644 index 53fe13b3..00000000 --- a/passbook/sources/ldap/api.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Source API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.admin.forms.source import SOURCE_SERIALIZER_FIELDS -from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource - - -class LDAPSourceSerializer(ModelSerializer): - """LDAP Source Serializer""" - - class Meta: - model = LDAPSource - fields = SOURCE_SERIALIZER_FIELDS + [ - "server_uri", - "bind_cn", - "bind_password", - "start_tls", - "base_dn", - "additional_user_dn", - "additional_group_dn", - "user_object_filter", - "group_object_filter", - "user_group_membership_field", - "object_uniqueness_field", - "sync_users", - "sync_users_password", - "sync_groups", - "sync_parent_group", - "property_mappings", - ] - extra_kwargs = {"bind_password": {"write_only": True}} - - -class LDAPPropertyMappingSerializer(ModelSerializer): - """LDAP PropertyMapping Serializer""" - - class Meta: - model = LDAPPropertyMapping - fields = ["pk", "name", "expression", "object_field"] - - -class LDAPSourceViewSet(ModelViewSet): - """LDAP Source Viewset""" - - queryset = LDAPSource.objects.all() - serializer_class = LDAPSourceSerializer - - -class LDAPPropertyMappingViewSet(ModelViewSet): - """LDAP PropertyMapping Viewset""" - - queryset = LDAPPropertyMapping.objects.all() - serializer_class = LDAPPropertyMappingSerializer diff --git a/passbook/sources/ldap/apps.py b/passbook/sources/ldap/apps.py deleted file mode 100644 index 8a0cc79e..00000000 --- a/passbook/sources/ldap/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -"""passbook ldap source config""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookSourceLDAPConfig(AppConfig): - """Passbook ldap app config""" - - name = "passbook.sources.ldap" - label = "passbook_sources_ldap" - verbose_name = "passbook Sources.LDAP" - - def ready(self): - import_module("passbook.sources.ldap.signals") diff --git a/passbook/sources/ldap/auth.py b/passbook/sources/ldap/auth.py deleted file mode 100644 index dde9a723..00000000 --- a/passbook/sources/ldap/auth.py +++ /dev/null @@ -1,76 +0,0 @@ -"""passbook LDAP Authentication Backend""" -from typing import Optional - -import ldap3 -from django.contrib.auth.backends import ModelBackend -from django.http import HttpRequest -from structlog import get_logger - -from passbook.core.models import User -from passbook.sources.ldap.models import LDAPSource - -LOGGER = get_logger() - - -class LDAPBackend(ModelBackend): - """Authenticate users against LDAP Server""" - - def authenticate(self, request: HttpRequest, **kwargs): - """Try to authenticate a user via ldap""" - if "password" not in kwargs: - return None - for source in LDAPSource.objects.filter(enabled=True): - LOGGER.debug("LDAP Auth attempt", source=source) - user = self.auth_user(source, **kwargs) - if user: - return user - return None - - def auth_user( - self, source: LDAPSource, password: str, **filters: str - ) -> Optional[User]: - """Try to bind as either user_dn or mail with password. - Returns True on success, otherwise False""" - users = User.objects.filter(**filters) - if not users.exists(): - return None - user: User = users.first() - if "distinguishedName" not in user.attributes: - LOGGER.debug( - "User doesn't have DN set, assuming not LDAP imported.", user=user - ) - return None - # Either has unusable password, - # or has a password, but couldn't be authenticated by ModelBackend. - # This means we check with a bind to see if the LDAP password has changed - if self.auth_user_by_bind(source, user, password): - # Password given successfully binds to LDAP, so we save it in our Database - LOGGER.debug("Updating user's password in DB", user=user) - user.set_password(password, signal=False) - user.save() - return user - # Password doesn't match - LOGGER.debug("Failed to bind, password invalid") - return None - - def auth_user_by_bind( - self, source: LDAPSource, user: User, password: str - ) -> Optional[User]: - """Attempt authentication by binding to the LDAP server as `user`. This - method should be avoided as its slow to do the bind.""" - # Try to bind as new user - LOGGER.debug("Attempting Binding as user", user=user) - try: - temp_connection = ldap3.Connection( - source.connection.server, - user=user.attributes.get("distinguishedName"), - password=password, - raise_exceptions=True, - ) - temp_connection.bind() - return user - except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception: - LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception) - except ldap3.core.exceptions.LDAPException as exception: - LOGGER.warning(exception) - return None diff --git a/passbook/sources/ldap/forms.py b/passbook/sources/ldap/forms.py deleted file mode 100644 index 3028c545..00000000 --- a/passbook/sources/ldap/forms.py +++ /dev/null @@ -1,83 +0,0 @@ -"""passbook LDAP Forms""" - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.admin.fields import CodeMirrorWidget -from passbook.core.expression import PropertyMappingEvaluator -from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource - - -class LDAPSourceForm(forms.ModelForm): - """LDAPSource Form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all() - - class Meta: - - model = LDAPSource - fields = [ - # we don't use all common fields, as we don't use flows for this - "name", - "slug", - "enabled", - # -- start of our custom fields - "server_uri", - "start_tls", - "bind_cn", - "bind_password", - "base_dn", - "sync_users", - "sync_users_password", - "sync_groups", - "property_mappings", - "additional_user_dn", - "additional_group_dn", - "user_object_filter", - "group_object_filter", - "user_group_membership_field", - "object_uniqueness_field", - "sync_parent_group", - ] - widgets = { - "name": forms.TextInput(), - "server_uri": forms.TextInput(), - "bind_cn": forms.TextInput(), - "bind_password": forms.TextInput(), - "base_dn": forms.TextInput(), - "additional_user_dn": forms.TextInput(), - "additional_group_dn": forms.TextInput(), - "user_object_filter": forms.TextInput(), - "group_object_filter": forms.TextInput(), - "user_group_membership_field": forms.TextInput(), - "object_uniqueness_field": forms.TextInput(), - } - - -class LDAPPropertyMappingForm(forms.ModelForm): - """LDAP Property Mapping form""" - - template_name = "ldap/property_mapping_form.html" - - def clean_expression(self): - """Test Syntax""" - expression = self.cleaned_data.get("expression") - evaluator = PropertyMappingEvaluator() - evaluator.validate(expression) - return expression - - class Meta: - - model = LDAPPropertyMapping - fields = ["name", "object_field", "expression"] - widgets = { - "name": forms.TextInput(), - "ldap_property": forms.TextInput(), - "object_field": forms.TextInput(), - "expression": CodeMirrorWidget(mode="python"), - } - help_texts = { - "object_field": _("Field of the user object this value is written to.") - } diff --git a/passbook/sources/ldap/migrations/0001_initial.py b/passbook/sources/ldap/migrations/0001_initial.py deleted file mode 100644 index a1ff329f..00000000 --- a/passbook/sources/ldap/migrations/0001_initial.py +++ /dev/null @@ -1,131 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.core.validators -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="LDAPPropertyMapping", - fields=[ - ( - "propertymapping_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.PropertyMapping", - ), - ), - ("object_field", models.TextField()), - ], - options={ - "verbose_name": "LDAP Property Mapping", - "verbose_name_plural": "LDAP Property Mappings", - }, - bases=("passbook_core.propertymapping",), - ), - migrations.CreateModel( - name="LDAPSource", - fields=[ - ( - "source_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.Source", - ), - ), - ( - "server_uri", - models.TextField( - validators=[ - django.core.validators.URLValidator( - schemes=["ldap", "ldaps"] - ) - ], - verbose_name="Server URI", - ), - ), - ("bind_cn", models.TextField(verbose_name="Bind CN")), - ("bind_password", models.TextField()), - ( - "start_tls", - models.BooleanField(default=False, verbose_name="Enable Start TLS"), - ), - ("base_dn", models.TextField(verbose_name="Base DN")), - ( - "additional_user_dn", - models.TextField( - help_text="Prepended to Base DN for User-queries.", - verbose_name="Addition User DN", - ), - ), - ( - "additional_group_dn", - models.TextField( - help_text="Prepended to Base DN for Group-queries.", - verbose_name="Addition Group DN", - ), - ), - ( - "user_object_filter", - models.TextField( - default="(objectCategory=Person)", - help_text="Consider Objects matching this filter to be Users.", - ), - ), - ( - "user_group_membership_field", - models.TextField( - default="memberOf", - help_text="Field which contains Groups of user.", - ), - ), - ( - "group_object_filter", - models.TextField( - default="(objectCategory=Group)", - help_text="Consider Objects matching this filter to be Groups.", - ), - ), - ( - "object_uniqueness_field", - models.TextField( - default="objectSid", - help_text="Field which contains a unique Identifier.", - ), - ), - ("sync_groups", models.BooleanField(default=True)), - ( - "sync_parent_group", - models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - to="passbook_core.Group", - ), - ), - ], - options={ - "verbose_name": "LDAP Source", - "verbose_name_plural": "LDAP Sources", - }, - bases=("passbook_core.source",), - ), - ] diff --git a/passbook/sources/ldap/migrations/0002_ldapsource_sync_users.py b/passbook/sources/ldap/migrations/0002_ldapsource_sync_users.py deleted file mode 100644 index 27a0da2b..00000000 --- a/passbook/sources/ldap/migrations/0002_ldapsource_sync_users.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 19:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_ldap", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="ldapsource", - name="sync_users", - field=models.BooleanField(default=True), - ), - ] diff --git a/passbook/sources/ldap/migrations/0003_default_ldap_property_mappings.py b/passbook/sources/ldap/migrations/0003_default_ldap_property_mappings.py deleted file mode 100644 index 414ca84e..00000000 --- a/passbook/sources/ldap/migrations/0003_default_ldap_property_mappings.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 19:30 - -from django.apps.registry import Apps -from django.db import migrations - - -def create_default_ad_property_mappings(apps: Apps, schema_editor): - LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping") - mapping = { - "name": "return ldap.get('name')", - "first_name": "return ldap.get('givenName')", - "last_name": "return ldap.get('sn')", - "username": "return ldap.get('sAMAccountName')", - "email": "return ldap.get('mail')", - } - db_alias = schema_editor.connection.alias - for object_field, expression in mapping.items(): - LDAPPropertyMapping.objects.using(db_alias).get_or_create( - expression=expression, - object_field=object_field, - defaults={ - "name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}" - }, - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_ldap", "0002_ldapsource_sync_users"), - ] - - operations = [ - migrations.RunPython(create_default_ad_property_mappings), - ] diff --git a/passbook/sources/ldap/migrations/0004_auto_20200524_1146.py b/passbook/sources/ldap/migrations/0004_auto_20200524_1146.py deleted file mode 100644 index 475398b1..00000000 --- a/passbook/sources/ldap/migrations/0004_auto_20200524_1146.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-24 11:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_ldap", "0003_default_ldap_property_mappings"), - ] - - operations = [ - migrations.AlterField( - model_name="ldapsource", - name="additional_group_dn", - field=models.TextField( - blank=True, - help_text="Prepended to Base DN for Group-queries.", - verbose_name="Addition Group DN", - ), - ), - migrations.AlterField( - model_name="ldapsource", - name="additional_user_dn", - field=models.TextField( - blank=True, - help_text="Prepended to Base DN for User-queries.", - verbose_name="Addition User DN", - ), - ), - ] diff --git a/passbook/sources/ldap/migrations/0005_auto_20200913_1947.py b/passbook/sources/ldap/migrations/0005_auto_20200913_1947.py deleted file mode 100644 index d081cdfa..00000000 --- a/passbook/sources/ldap/migrations/0005_auto_20200913_1947.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-13 19:47 - -from django.db import migrations, models - -import passbook.lib.models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_ldap", "0004_auto_20200524_1146"), - ] - - operations = [ - migrations.AlterField( - model_name="ldapsource", - name="server_uri", - field=models.TextField( - validators=[ - passbook.lib.models.DomainlessURLValidator( - schemes=["ldap", "ldaps"] - ) - ], - verbose_name="Server URI", - ), - ), - ] diff --git a/passbook/sources/ldap/migrations/0006_auto_20200915_1919.py b/passbook/sources/ldap/migrations/0006_auto_20200915_1919.py deleted file mode 100644 index 5fd67e0b..00000000 --- a/passbook/sources/ldap/migrations/0006_auto_20200915_1919.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-15 19:19 - -from django.apps.registry import Apps -from django.db import migrations - - -def create_default_property_mappings(apps: Apps, schema_editor): - LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping") - db_alias = schema_editor.connection.alias - mapping = { - "name": "name", - "first_name": "givenName", - "last_name": "sn", - "email": "mail", - } - for object_field, ldap_field in mapping.items(): - expression = f"return ldap.get('{ldap_field}')" - LDAPPropertyMapping.objects.using(db_alias).get_or_create( - expression=expression, - object_field=object_field, - defaults={ - "name": f"Autogenerated LDAP Mapping: {ldap_field} -> {object_field}" - }, - ) - ad_mapping = { - "username": "sAMAccountName", - "attributes.upn": "userPrincipalName", - } - for object_field, ldap_field in ad_mapping.items(): - expression = f"return ldap.get('{ldap_field}')" - LDAPPropertyMapping.objects.using(db_alias).get_or_create( - expression=expression, - object_field=object_field, - defaults={ - "name": f"Autogenerated Active Directory Mapping: {ldap_field} -> {object_field}" - }, - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_ldap", "0005_auto_20200913_1947"), - ] - - operations = [ - migrations.RunPython(create_default_property_mappings), - ] diff --git a/passbook/sources/ldap/migrations/0007_ldapsource_sync_users_password.py b/passbook/sources/ldap/migrations/0007_ldapsource_sync_users_password.py deleted file mode 100644 index aa10efbb..00000000 --- a/passbook/sources/ldap/migrations/0007_ldapsource_sync_users_password.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-21 09:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_ldap", "0006_auto_20200915_1919"), - ] - - operations = [ - migrations.AddField( - model_name="ldapsource", - name="sync_users_password", - field=models.BooleanField( - default=True, - help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.", - unique=True, - ), - ), - ] diff --git a/passbook/sources/ldap/models.py b/passbook/sources/ldap/models.py deleted file mode 100644 index a5330834..00000000 --- a/passbook/sources/ldap/models.py +++ /dev/null @@ -1,132 +0,0 @@ -"""passbook LDAP Models""" -from datetime import datetime -from typing import Optional, Type - -from django.core.cache import cache -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext_lazy as _ -from ldap3 import ALL, Connection, Server - -from passbook.core.models import Group, PropertyMapping, Source -from passbook.lib.models import DomainlessURLValidator -from passbook.lib.utils.template import render_to_string - - -class LDAPSource(Source): - """Federate LDAP Directory with passbook, or create new accounts in LDAP.""" - - server_uri = models.TextField( - validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])], - verbose_name=_("Server URI"), - ) - bind_cn = models.TextField(verbose_name=_("Bind CN")) - bind_password = models.TextField() - start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS")) - - base_dn = models.TextField(verbose_name=_("Base DN")) - additional_user_dn = models.TextField( - help_text=_("Prepended to Base DN for User-queries."), - verbose_name=_("Addition User DN"), - blank=True, - ) - additional_group_dn = models.TextField( - help_text=_("Prepended to Base DN for Group-queries."), - verbose_name=_("Addition Group DN"), - blank=True, - ) - - user_object_filter = models.TextField( - default="(objectCategory=Person)", - help_text=_("Consider Objects matching this filter to be Users."), - ) - user_group_membership_field = models.TextField( - default="memberOf", help_text=_("Field which contains Groups of user.") - ) - group_object_filter = models.TextField( - default="(objectCategory=Group)", - help_text=_("Consider Objects matching this filter to be Groups."), - ) - object_uniqueness_field = models.TextField( - default="objectSid", help_text=_("Field which contains a unique Identifier.") - ) - - sync_users = models.BooleanField(default=True) - sync_users_password = models.BooleanField( - default=True, - help_text=_( - ( - "When a user changes their password, sync it back to LDAP. " - "This can only be enabled on a single LDAP source." - ) - ), - unique=True, - ) - sync_groups = models.BooleanField(default=True) - sync_parent_group = models.ForeignKey( - Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT - ) - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.ldap.forms import LDAPSourceForm - - return LDAPSourceForm - - def state_cache_prefix(self, suffix: str) -> str: - """Key by which the ldap source status is saved""" - return f"source_ldap_{self.pk}_state_{suffix}" - - @property - def ui_additional_info(self) -> str: - last_sync = cache.get(self.state_cache_prefix("last_sync"), None) - if last_sync: - last_sync = datetime.fromtimestamp(last_sync) - - return render_to_string( - "ldap/source_list_status.html", {"source": self, "last_sync": last_sync} - ) - - _connection: Optional[Connection] = None - - @property - def connection(self) -> Connection: - """Get a fully connected and bound LDAP Connection""" - if not self._connection: - server = Server(self.server_uri, get_info=ALL) - self._connection = Connection( - server, - raise_exceptions=True, - user=self.bind_cn, - password=self.bind_password, - ) - - self._connection.bind() - if self.start_tls: - self._connection.start_tls() - return self._connection - - class Meta: - - verbose_name = _("LDAP Source") - verbose_name_plural = _("LDAP Sources") - - -class LDAPPropertyMapping(PropertyMapping): - """Map LDAP Property to User or Group object attribute""" - - object_field = models.TextField() - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.ldap.forms import LDAPPropertyMappingForm - - return LDAPPropertyMappingForm - - def __str__(self): - return self.name - - class Meta: - - verbose_name = _("LDAP Property Mapping") - verbose_name_plural = _("LDAP Property Mappings") diff --git a/passbook/sources/ldap/password.py b/passbook/sources/ldap/password.py deleted file mode 100644 index 67d021ae..00000000 --- a/passbook/sources/ldap/password.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Help validate and update passwords in LDAP""" -from enum import IntFlag -from re import split -from typing import Optional - -import ldap3 -import ldap3.core.exceptions -from structlog import get_logger - -from passbook.core.models import User -from passbook.sources.ldap.models import LDAPSource - -LOGGER = get_logger() - -NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/" -RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t" - - -class PwdProperties(IntFlag): - """Possible values for the pwdProperties attribute""" - - DOMAIN_PASSWORD_COMPLEX = 1 - DOMAIN_PASSWORD_NO_ANON_CHANGE = 2 - DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4 - DOMAIN_LOCKOUT_ADMINS = 8 - DOMAIN_PASSWORD_STORE_CLEARTEXT = 16 - DOMAIN_REFUSE_PASSWORD_CHANGE = 32 - - -class PasswordCategories(IntFlag): - """Password categories as defined by Microsoft, a category can only be counted - once, hence intflag.""" - - NONE = 0 - ALPHA_LOWER = 1 - ALPHA_UPPER = 2 - ALPHA_OTHER = 4 - NUMERIC = 8 - SYMBOL = 16 - - -class LDAPPasswordChanger: - """Help validate and update passwords in LDAP""" - - _source: LDAPSource - - def __init__(self, source: LDAPSource) -> None: - self._source = source - - def get_domain_root_dn(self) -> str: - """Attempt to get root DN via MS specific fields or generic LDAP fields""" - info = self._source.connection.server.info - if "rootDomainNamingContext" in info.other: - return info.other["rootDomainNamingContext"][0] - naming_contexts = info.naming_contexts - naming_contexts.sort(key=len) - return naming_contexts[0] - - def check_ad_password_complexity_enabled(self) -> bool: - """Check if DOMAIN_PASSWORD_COMPLEX is enabled""" - root_dn = self.get_domain_root_dn() - root_attrs = self._source.connection.extend.standard.paged_search( - search_base=root_dn, - search_filter="(objectClass=*)", - search_scope=ldap3.BASE, - attributes=["pwdProperties"], - ) - root_attrs = list(root_attrs)[0] - pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"]) - if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties: - return True - - return False - - def change_password(self, user: User, password: str): - """Change user's password""" - user_dn = user.attributes.get("distinguishedName", None) - if not user_dn: - raise AttributeError("User has no distinguishedName set.") - self._source.connection.extend.microsoft.modify_password(user_dn, password) - - def _ad_check_password_existing(self, password: str, user_dn: str) -> bool: - """Check if a password contains sAMAccount or displayName""" - users = list( - self._source.connection.extend.standard.paged_search( - search_base=user_dn, - search_filter=self._source.user_object_filter, - search_scope=ldap3.BASE, - attributes=["displayName", "sAMAccountName"], - ) - ) - if len(users) != 1: - raise AssertionError() - user_attributes = users[0]["attributes"] - # If sAMAccountName is longer than 3 chars, check if its contained in password - if len(user_attributes["sAMAccountName"]) >= 3: - if password.lower() in user_attributes["sAMAccountName"].lower(): - return False - display_name_tokens = split( - RE_DISPLAYNAME_SEPARATORS, user_attributes["displayName"] - ) - for token in display_name_tokens: - # Ignore tokens under 3 chars - if len(token) < 3: - continue - if token.lower() in password.lower(): - return False - return True - - def ad_password_complexity( - self, password: str, user: Optional[User] = None - ) -> bool: - """Check if password matches Active direcotry password policies - - https://docs.microsoft.com/en-us/windows/security/threat-protection/ - security-policy-settings/password-must-meet-complexity-requirements - """ - if user: - # Check if password contains sAMAccountName or displayNames - if "distinguishedName" in user.attributes: - existing_user_check = self._ad_check_password_existing( - password, user.attributes.get("distinguishedName") - ) - if not existing_user_check: - LOGGER.debug("Password failed name check", user=user) - return existing_user_check - - # Step 2, match at least 3 of 5 categories - matched_categories = PasswordCategories.NONE - required = 3 - for letter in password: - # Only match one category per letter, - if letter.islower(): - matched_categories |= PasswordCategories.ALPHA_LOWER - elif letter.isupper(): - matched_categories |= PasswordCategories.ALPHA_UPPER - elif not letter.isascii() and letter.isalpha(): - # Not exactly matching microsoft's policy, but count it as "Other unicode" char - # when its alpha and not ascii - matched_categories |= PasswordCategories.ALPHA_OTHER - elif letter.isnumeric(): - matched_categories |= PasswordCategories.NUMERIC - elif letter in NON_ALPHA: - matched_categories |= PasswordCategories.SYMBOL - if bin(matched_categories).count("1") < required: - LOGGER.debug( - "Password didn't match enough categories", - has=matched_categories, - must=required, - ) - return False - LOGGER.debug( - "Password matched categories", has=matched_categories, must=required - ) - return True diff --git a/passbook/sources/ldap/settings.py b/passbook/sources/ldap/settings.py deleted file mode 100644 index fcc76462..00000000 --- a/passbook/sources/ldap/settings.py +++ /dev/null @@ -1,14 +0,0 @@ -"""LDAP Settings""" -from celery.schedules import crontab - -AUTHENTICATION_BACKENDS = [ - "passbook.sources.ldap.auth.LDAPBackend", -] - -CELERY_BEAT_SCHEDULE = { - "sources_ldap_sync": { - "task": "passbook.sources.ldap.tasks.ldap_sync_all", - "schedule": crontab(minute=0), # Run every hour - "options": {"queue": "passbook_scheduled"}, - } -} diff --git a/passbook/sources/ldap/signals.py b/passbook/sources/ldap/signals.py deleted file mode 100644 index 9408bdf0..00000000 --- a/passbook/sources/ldap/signals.py +++ /dev/null @@ -1,59 +0,0 @@ -"""passbook ldap source signals""" -from typing import Any, Dict - -from django.core.exceptions import ValidationError -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.utils.translation import gettext_lazy as _ -from ldap3.core.exceptions import LDAPException - -from passbook.core.models import User -from passbook.core.signals import password_changed -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.sources.ldap.models import LDAPSource -from passbook.sources.ldap.password import LDAPPasswordChanger -from passbook.sources.ldap.tasks import ldap_sync -from passbook.stages.prompt.signals import password_validate - - -@receiver(post_save, sender=LDAPSource) -# pylint: disable=unused-argument -def sync_ldap_source_on_save(sender, instance: LDAPSource, **_): - """Ensure that source is synced on save (if enabled)""" - if instance.enabled: - ldap_sync.delay(instance.pk) - - -@receiver(password_validate) -# pylint: disable=unused-argument -def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__): - """if there's an LDAP Source with enabled password sync, check the password""" - sources = LDAPSource.objects.filter(sync_users_password=True) - if not sources.exists(): - return - source = sources.first() - changer = LDAPPasswordChanger(source) - if changer.check_ad_password_complexity_enabled(): - passing = changer.ad_password_complexity( - password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None) - ) - if not passing: - raise ValidationError( - _("Password does not match Active Direcory Complexity.") - ) - - -@receiver(password_changed) -# pylint: disable=unused-argument -def ldap_sync_password(sender, user: User, password: str, **_): - """Connect to ldap and update password. We do this in the background to get - automatic retries on error.""" - sources = LDAPSource.objects.filter(sync_users_password=True) - if not sources.exists(): - return - source = sources.first() - changer = LDAPPasswordChanger(source) - try: - changer.change_password(user, password) - except LDAPException as exc: - raise ValidationError("Failed to set password") from exc diff --git a/passbook/sources/ldap/sync.py b/passbook/sources/ldap/sync.py deleted file mode 100644 index b6ffbab2..00000000 --- a/passbook/sources/ldap/sync.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Sync LDAP Users and groups into passbook""" -from typing import Any, Dict - -import ldap3 -import ldap3.core.exceptions -from django.db.utils import IntegrityError -from structlog import get_logger - -from passbook.core.exceptions import PropertyMappingExpressionException -from passbook.core.models import Group, User -from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource - -LOGGER = get_logger() - - -class LDAPSynchronizer: - """Sync LDAP Users and groups into passbook""" - - _source: LDAPSource - - def __init__(self, source: LDAPSource): - self._source = source - - @property - def base_dn_users(self) -> str: - """Shortcut to get full base_dn for user lookups""" - if self._source.additional_user_dn: - return f"{self._source.additional_user_dn},{self._source.base_dn}" - return self._source.base_dn - - @property - def base_dn_groups(self) -> str: - """Shortcut to get full base_dn for group lookups""" - if self._source.additional_group_dn: - return f"{self._source.additional_group_dn},{self._source.base_dn}" - return self._source.base_dn - - def sync_groups(self) -> int: - """Iterate over all LDAP Groups and create passbook_core.Group instances""" - if not self._source.sync_groups: - LOGGER.warning("Group syncing is disabled for this Source") - return -1 - groups = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_groups, - search_filter=self._source.group_object_filter, - search_scope=ldap3.SUBTREE, - attributes=ldap3.ALL_ATTRIBUTES, - ) - group_count = 0 - for group in groups: - attributes = group.get("attributes", {}) - if self._source.object_uniqueness_field not in attributes: - LOGGER.warning( - "Cannot find uniqueness Field in attributes", user=attributes.keys() - ) - continue - uniq = attributes[self._source.object_uniqueness_field] - _, created = Group.objects.update_or_create( - attributes__ldap_uniq=uniq, - parent=self._source.sync_parent_group, - defaults={ - "name": attributes.get("name", ""), - "attributes": { - "ldap_uniq": uniq, - "distinguishedName": attributes.get("distinguishedName"), - }, - }, - ) - LOGGER.debug( - "Synced group", group=attributes.get("name", ""), created=created - ) - group_count += 1 - return group_count - - def sync_users(self) -> int: - """Iterate over all LDAP Users and create passbook_core.User instances""" - if not self._source.sync_users: - LOGGER.warning("User syncing is disabled for this Source") - return -1 - users = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_users, - search_filter=self._source.user_object_filter, - search_scope=ldap3.SUBTREE, - attributes=ldap3.ALL_ATTRIBUTES, - ) - user_count = 0 - for user in users: - attributes = user.get("attributes", {}) - if self._source.object_uniqueness_field not in attributes: - LOGGER.warning( - "Cannot find uniqueness Field in attributes", user=user.keys() - ) - continue - uniq = attributes[self._source.object_uniqueness_field] - try: - defaults = self._build_object_properties(attributes) - user, created = User.objects.update_or_create( - attributes__ldap_uniq=uniq, - defaults=defaults, - ) - except IntegrityError as exc: - LOGGER.warning("Failed to create user", exc=exc) - LOGGER.warning( - ( - "To merge new User with existing user, set the User's " - f"Attribute 'ldap_uniq' to '{uniq}'" - ) - ) - else: - if created: - user.set_unusable_password() - user.save() - LOGGER.debug( - "Synced User", user=attributes.get("name", ""), created=created - ) - user_count += 1 - return user_count - - def sync_membership(self): - """Iterate over all Users and assign Groups using memberOf Field""" - users = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_users, - search_filter=self._source.user_object_filter, - search_scope=ldap3.SUBTREE, - attributes=[ - self._source.user_group_membership_field, - self._source.object_uniqueness_field, - ], - ) - group_cache: Dict[str, Group] = {} - for user in users: - member_of = user.get("attributes", {}).get( - self._source.user_group_membership_field, [] - ) - uniq = user.get("attributes", {}).get( - self._source.object_uniqueness_field, [] - ) - for group_dn in member_of: - # Check if group_dn is within our base_dn_groups, and skip if not - if not group_dn.endswith(self.base_dn_groups): - continue - # Check if we fetched the group already, and if not cache it for later - if group_dn not in group_cache: - groups = Group.objects.filter( - attributes__distinguishedName=group_dn - ) - if not groups.exists(): - LOGGER.warning( - "Group does not exist in our DB yet, run sync_groups first.", - group=group_dn, - ) - return - group_cache[group_dn] = groups.first() - group = group_cache[group_dn] - users = User.objects.filter(attributes__ldap_uniq=uniq) - group.users.add(*list(users)) - # Now that all users are added, lets write everything - for _, group in group_cache.items(): - group.save() - LOGGER.debug("Successfully updated group membership") - - def _build_object_properties( - self, attributes: Dict[str, Any] - ) -> Dict[str, Dict[Any, Any]]: - properties = {"attributes": {}} - for mapping in self._source.property_mappings.all().select_subclasses(): - if not isinstance(mapping, LDAPPropertyMapping): - continue - mapping: LDAPPropertyMapping - try: - value = mapping.evaluate(user=None, request=None, ldap=attributes) - if value is None: - continue - object_field = mapping.object_field - if object_field.startswith("attributes."): - properties["attributes"][ - object_field.replace("attributes.", "") - ] = value - else: - properties[object_field] = value - except PropertyMappingExpressionException as exc: - LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping) - continue - if self._source.object_uniqueness_field in attributes: - properties["attributes"]["ldap_uniq"] = attributes.get( - self._source.object_uniqueness_field - ) - properties["attributes"]["distinguishedName"] = attributes.get( - "distinguishedName" - ) - return properties diff --git a/passbook/sources/ldap/tasks.py b/passbook/sources/ldap/tasks.py deleted file mode 100644 index 952b90a4..00000000 --- a/passbook/sources/ldap/tasks.py +++ /dev/null @@ -1,45 +0,0 @@ -"""LDAP Sync tasks""" -from time import time - -from django.core.cache import cache -from django.utils.text import slugify -from ldap3.core.exceptions import LDAPException - -from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus -from passbook.root.celery import CELERY_APP -from passbook.sources.ldap.models import LDAPSource -from passbook.sources.ldap.sync import LDAPSynchronizer - - -@CELERY_APP.task() -def ldap_sync_all(): - """Sync all sources""" - for source in LDAPSource.objects.filter(enabled=True): - ldap_sync.delay(source.pk) - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def ldap_sync(self: MonitoredTask, source_pk: int): - """Synchronization of an LDAP Source""" - try: - source: LDAPSource = LDAPSource.objects.get(pk=source_pk) - except LDAPSource.DoesNotExist: - # Because the source couldn't be found, we don't have a UID - # to set the state with - return - self.set_uid(slugify(source.name)) - try: - syncer = LDAPSynchronizer(source) - user_count = syncer.sync_users() - group_count = syncer.sync_groups() - syncer.sync_membership() - cache_key = source.state_cache_prefix("last_sync") - cache.set(cache_key, time(), timeout=60 * 60) - self.set_status( - TaskResult( - TaskResultStatus.SUCCESSFUL, - [f"Synced {user_count} users", f"Synced {group_count} groups"], - ) - ) - except LDAPException as exc: - self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) diff --git a/passbook/sources/ldap/templates/ldap/property_mapping_form.html b/passbook/sources/ldap/templates/ldap/property_mapping_form.html deleted file mode 100644 index 4bd3085a..00000000 --- a/passbook/sources/ldap/templates/ldap/property_mapping_form.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "generic/form.html" %} - -{% load i18n %} - -{% block beneath_form %} -
- -
-

- Expression using Python. See here for a list of all variables. -

-
-
-{% endblock %} diff --git a/passbook/sources/ldap/tests/test_auth.py b/passbook/sources/ldap/tests/test_auth.py deleted file mode 100644 index 09eb3081..00000000 --- a/passbook/sources/ldap/tests/test_auth.py +++ /dev/null @@ -1,47 +0,0 @@ -"""LDAP Source tests""" -from unittest.mock import Mock, PropertyMock, patch - -from django.test import TestCase - -from passbook.core.models import User -from passbook.providers.oauth2.generators import generate_client_secret -from passbook.sources.ldap.auth import LDAPBackend -from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource -from passbook.sources.ldap.sync import LDAPSynchronizer -from passbook.sources.ldap.tests.utils import _build_mock_connection - -LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) - - -class LDAPSyncTests(TestCase): - """LDAP Sync tests""" - - def setUp(self): - self.source = LDAPSource.objects.create( - name="ldap", - slug="ldap", - base_dn="DC=AD2012,DC=LAB", - additional_user_dn="ou=users", - additional_group_dn="ou=groups", - ) - self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) - self.source.save() - - @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_auth_synced_user(self): - """Test Cached auth""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_users() - - user = User.objects.get(username="user0_sn") - auth_user_by_bind = Mock(return_value=user) - with patch( - "passbook.sources.ldap.auth.LDAPBackend.auth_user_by_bind", - auth_user_by_bind, - ): - backend = LDAPBackend() - self.assertEqual( - backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD), - user, - ) diff --git a/passbook/sources/ldap/tests/test_password.py b/passbook/sources/ldap/tests/test_password.py deleted file mode 100644 index 6581c83d..00000000 --- a/passbook/sources/ldap/tests/test_password.py +++ /dev/null @@ -1,54 +0,0 @@ -"""LDAP Source tests""" -from unittest.mock import PropertyMock, patch - -from django.test import TestCase - -from passbook.core.models import User -from passbook.providers.oauth2.generators import generate_client_secret -from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource -from passbook.sources.ldap.password import LDAPPasswordChanger -from passbook.sources.ldap.tests.utils import _build_mock_connection - -LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) - - -class LDAPPasswordTests(TestCase): - """LDAP Password tests""" - - def setUp(self): - self.source = LDAPSource.objects.create( - name="ldap", - slug="ldap", - base_dn="DC=AD2012,DC=LAB", - additional_user_dn="ou=users", - additional_group_dn="ou=groups", - ) - self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) - self.source.save() - - @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_password_complexity(self): - """Test password without user""" - pwc = LDAPPasswordChanger(self.source) - self.assertFalse(pwc.ad_password_complexity("test")) # 1 category - self.assertFalse(pwc.ad_password_complexity("test1")) # 2 categories - self.assertTrue(pwc.ad_password_complexity("test1!")) # 2 categories - - @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_password_complexity_user(self): - """test password with user""" - pwc = LDAPPasswordChanger(self.source) - user = User.objects.create( - username="test", - attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"}, - ) - self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category - self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories - self.assertTrue(pwc.ad_password_complexity("test1!", user)) # 2 categories - self.assertFalse( - pwc.ad_password_complexity("erin!qewrqewr", user) - ) # displayName token - self.assertFalse( - pwc.ad_password_complexity("hagens!qewrqewr", user) - ) # displayName token diff --git a/passbook/sources/ldap/tests/test_sync.py b/passbook/sources/ldap/tests/test_sync.py deleted file mode 100644 index 3f919ab8..00000000 --- a/passbook/sources/ldap/tests/test_sync.py +++ /dev/null @@ -1,51 +0,0 @@ -"""LDAP Source tests""" -from unittest.mock import PropertyMock, patch - -from django.test import TestCase - -from passbook.core.models import Group, User -from passbook.providers.oauth2.generators import generate_client_secret -from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource -from passbook.sources.ldap.sync import LDAPSynchronizer -from passbook.sources.ldap.tasks import ldap_sync_all -from passbook.sources.ldap.tests.utils import _build_mock_connection - -LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) - - -class LDAPSyncTests(TestCase): - """LDAP Sync tests""" - - def setUp(self): - self.source = LDAPSource.objects.create( - name="ldap", - slug="ldap", - base_dn="DC=AD2012,DC=LAB", - additional_user_dn="ou=users", - additional_group_dn="ou=groups", - ) - self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) - self.source.save() - - @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_sync_users(self): - """Test user sync""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_users() - self.assertTrue(User.objects.filter(username="user0_sn").exists()) - self.assertFalse(User.objects.filter(username="user1_sn").exists()) - - @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_sync_groups(self): - """Test group sync""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_groups() - syncer.sync_membership() - group = Group.objects.filter(name="test-group") - self.assertTrue(group.exists()) - - @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_tasks(self): - """Test Scheduled tasks""" - ldap_sync_all.delay().get() diff --git a/passbook/sources/oauth/api.py b/passbook/sources/oauth/api.py deleted file mode 100644 index 4f6c909a..00000000 --- a/passbook/sources/oauth/api.py +++ /dev/null @@ -1,29 +0,0 @@ -"""OAuth Source Serializer""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.admin.forms.source import SOURCE_SERIALIZER_FIELDS -from passbook.sources.oauth.models import OAuthSource - - -class OAuthSourceSerializer(ModelSerializer): - """OAuth Source Serializer""" - - class Meta: - model = OAuthSource - fields = SOURCE_SERIALIZER_FIELDS + [ - "provider_type", - "request_token_url", - "authorization_url", - "access_token_url", - "profile_url", - "consumer_key", - "consumer_secret", - ] - - -class OAuthSourceViewSet(ModelViewSet): - """Source Viewset""" - - queryset = OAuthSource.objects.all() - serializer_class = OAuthSourceSerializer diff --git a/passbook/sources/oauth/apps.py b/passbook/sources/oauth/apps.py deleted file mode 100644 index e439e70d..00000000 --- a/passbook/sources/oauth/apps.py +++ /dev/null @@ -1,26 +0,0 @@ -"""passbook oauth_client config""" -from importlib import import_module - -from django.apps import AppConfig -from django.conf import settings -from structlog import get_logger - -LOGGER = get_logger() - - -class PassbookSourceOAuthConfig(AppConfig): - """passbook source.oauth config""" - - name = "passbook.sources.oauth" - label = "passbook_sources_oauth" - verbose_name = "passbook Sources.OAuth" - mountpoint = "source/oauth/" - - def ready(self): - """Load source_types from config file""" - for source_type in settings.PASSBOOK_SOURCES_OAUTH_TYPES: - try: - import_module(source_type) - LOGGER.debug("Loaded OAuth Source Type", type=source_type) - except ImportError as exc: - LOGGER.debug(exc) diff --git a/passbook/sources/oauth/auth.py b/passbook/sources/oauth/auth.py deleted file mode 100644 index 0428365d..00000000 --- a/passbook/sources/oauth/auth.py +++ /dev/null @@ -1,23 +0,0 @@ -"""passbook oauth_client Authorization backend""" -from typing import Optional - -from django.contrib.auth.backends import ModelBackend -from django.http import HttpRequest - -from passbook.core.models import User -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection - - -class AuthorizedServiceBackend(ModelBackend): - "Authentication backend for users registered with remote OAuth provider." - - def authenticate( - self, request: HttpRequest, source: OAuthSource, identifier: str - ) -> Optional[User]: - "Fetch user for a given source by id." - access = UserOAuthSourceConnection.objects.filter( - source=source, identifier=identifier - ).select_related("user") - if not access.exists(): - return None - return access.first().user diff --git a/passbook/sources/oauth/clients/base.py b/passbook/sources/oauth/clients/base.py deleted file mode 100644 index e45fba97..00000000 --- a/passbook/sources/oauth/clients/base.py +++ /dev/null @@ -1,75 +0,0 @@ -"""OAuth Clients""" -from typing import Any, Dict, Optional -from urllib.parse import urlencode - -from django.http import HttpRequest -from requests import Session -from requests.exceptions import RequestException -from requests.models import Response -from structlog import get_logger - -from passbook import __version__ -from passbook.sources.oauth.models import OAuthSource - -LOGGER = get_logger() - - -class BaseOAuthClient: - """Base OAuth Client""" - - session: Session - - source: OAuthSource - request: HttpRequest - - callback: Optional[str] - - def __init__( - self, source: OAuthSource, request: HttpRequest, callback: Optional[str] = None - ): - self.source = source - self.session = Session() - self.request = request - self.callback = callback - self.session.headers.update({"User-Agent": f"passbook {__version__}"}) - - def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]: - "Fetch access token from callback request." - raise NotImplementedError("Defined in a sub-class") # pragma: no cover - - def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]: - "Fetch user profile information." - try: - response = self.do_request("get", self.source.profile_url, token=token) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch user profile", exc=exc) - return None - else: - return response.json() - - def get_redirect_args(self) -> Dict[str, str]: - "Get request parameters for redirect url." - raise NotImplementedError("Defined in a sub-class") # pragma: no cover - - def get_redirect_url(self, parameters=None): - "Build authentication redirect url." - args = self.get_redirect_args() - additional = parameters or {} - args.update(additional) - params = urlencode(args) - LOGGER.info("redirect args", **args) - return f"{self.source.authorization_url}?{params}" - - def parse_raw_token(self, raw_token: str) -> Dict[str, Any]: - "Parse token and secret from raw token response." - raise NotImplementedError("Defined in a sub-class") # pragma: no cover - - def do_request(self, method: str, url: str, **kwargs) -> Response: - """Wrapper around self.session.request, which can add special headers""" - return self.session.request(method, url, **kwargs) - - @property - def session_key(self) -> str: - """Return Session Key""" - raise NotImplementedError("Defined in a sub-class") # pragma: no cover diff --git a/passbook/sources/oauth/clients/oauth1.py b/passbook/sources/oauth/clients/oauth1.py deleted file mode 100644 index 783efb87..00000000 --- a/passbook/sources/oauth/clients/oauth1.py +++ /dev/null @@ -1,102 +0,0 @@ -"""OAuth 1 Clients""" -from typing import Any, Dict, Optional -from urllib.parse import parse_qsl - -from requests.exceptions import RequestException -from requests.models import Response -from requests_oauthlib import OAuth1 -from structlog import get_logger - -from passbook.sources.oauth.clients.base import BaseOAuthClient -from passbook.sources.oauth.exceptions import OAuthSourceException - -LOGGER = get_logger() - - -class OAuthClient(BaseOAuthClient): - """OAuth1 Client""" - - _default_headers = { - "Accept": "application/json", - } - - def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]: - "Fetch access token from callback request." - raw_token = self.request.session.get(self.session_key, None) - verifier = self.request.GET.get("oauth_verifier", None) - callback = self.request.build_absolute_uri(self.callback) - if raw_token is not None and verifier is not None: - token = self.parse_raw_token(raw_token) - try: - response = self.do_request( - "post", - self.source.access_token_url, - token=token, - headers=self._default_headers, - oauth_verifier=verifier, - oauth_callback=callback, - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch access token", exc=exc) - return None - else: - return self.parse_raw_token(response.text) - return None - - def get_request_token(self) -> str: - "Fetch the OAuth request token. Only required for OAuth 1.0." - callback = self.request.build_absolute_uri(self.callback) - try: - response = self.do_request( - "post", - self.source.request_token_url, - headers=self._default_headers, - oauth_callback=callback, - ) - response.raise_for_status() - except RequestException as exc: - raise OAuthSourceException from exc - else: - return response.text - - def get_redirect_args(self) -> Dict[str, Any]: - "Get request parameters for redirect url." - callback = self.request.build_absolute_uri(self.callback) - raw_token = self.get_request_token() - token = self.parse_raw_token(raw_token) - self.request.session[self.session_key] = raw_token - return { - "oauth_token": token["oauth_token"], - "oauth_callback": callback, - } - - def parse_raw_token(self, raw_token: str) -> Dict[str, Any]: - "Parse token and secret from raw token response." - return dict(parse_qsl(raw_token)) - - def do_request(self, method: str, url: str, **kwargs) -> Response: - "Build remote url request. Constructs necessary auth." - resource_owner_key = None - resource_owner_secret = None - if "token" in kwargs: - user_token: Dict[str, Any] = kwargs.pop("token") - resource_owner_key = user_token["oauth_token"] - resource_owner_secret = user_token["oauth_token_secret"] - - callback = kwargs.pop("oauth_callback", None) - verifier = kwargs.pop("oauth_verifier", None) - oauth = OAuth1( - resource_owner_key=resource_owner_key, - resource_owner_secret=resource_owner_secret, - client_key=self.source.consumer_key, - client_secret=self.source.consumer_secret, - verifier=verifier, - callback_uri=callback, - ) - kwargs["auth"] = oauth - return super().do_request(method, url, **kwargs) - - @property - def session_key(self) -> str: - return f"oauth-client-{self.source.name}-request-token" diff --git a/passbook/sources/oauth/clients/oauth2.py b/passbook/sources/oauth/clients/oauth2.py deleted file mode 100644 index 6975d35a..00000000 --- a/passbook/sources/oauth/clients/oauth2.py +++ /dev/null @@ -1,113 +0,0 @@ -"""OAuth 2 Clients""" -from json import loads -from typing import Any, Dict, Optional -from urllib.parse import parse_qsl - -from django.utils.crypto import constant_time_compare, get_random_string -from requests.exceptions import RequestException -from requests.models import Response -from structlog import get_logger - -from passbook.sources.oauth.clients.base import BaseOAuthClient - -LOGGER = get_logger() - - -class OAuth2Client(BaseOAuthClient): - """OAuth2 Client""" - - _default_headers = { - "Accept": "application/json", - } - - def check_application_state(self) -> bool: - "Check optional state parameter." - stored = self.request.session.get(self.session_key, None) - returned = self.request.GET.get("state", None) - check = False - if stored is not None: - if returned is not None: - check = constant_time_compare(stored, returned) - else: - LOGGER.warning("No state parameter returned by the source.") - else: - LOGGER.warning("No state stored in the session.") - return check - - def get_application_state(self) -> str: - "Generate state optional parameter." - return get_random_string(32) - - def get_access_token(self, **request_kwargs) -> Optional[Dict[str, Any]]: - "Fetch access token from callback request." - callback = self.request.build_absolute_uri(self.callback or self.request.path) - if not self.check_application_state(): - LOGGER.warning("Application state check failed.") - return None - if "code" in self.request.GET: - args = { - "client_id": self.source.consumer_key, - "redirect_uri": callback, - "client_secret": self.source.consumer_secret, - "code": self.request.GET["code"], - "grant_type": "authorization_code", - } - else: - LOGGER.warning("No code returned by the source") - return None - try: - response = self.session.request( - "post", - self.source.access_token_url, - data=args, - headers=self._default_headers, - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch access token", exc=exc) - return None - else: - return response.json() - - def get_redirect_args(self) -> Dict[str, str]: - "Get request parameters for redirect url." - callback = self.request.build_absolute_uri(self.callback) - client_id: str = self.source.consumer_key - args: Dict[str, str] = { - "client_id": client_id, - "redirect_uri": callback, - "response_type": "code", - } - state = self.get_application_state() - if state is not None: - args["state"] = state - self.request.session[self.session_key] = state - return args - - def parse_raw_token(self, raw_token: str) -> Dict[str, Any]: - "Parse token and secret from raw token response." - # Load as json first then parse as query string - try: - token_data = loads(raw_token) - except ValueError: - return dict(parse_qsl(raw_token)) - else: - return token_data - - def do_request(self, method: str, url: str, **kwargs) -> Response: - "Build remote url request. Constructs necessary auth." - if "token" in kwargs: - token = kwargs.pop("token") - - params = kwargs.get("params", {}) - params["access_token"] = token["access_token"] - kwargs["params"] = params - - headers = kwargs.get("headers", {}) - headers["Authorization"] = f"{token['token_type']} {token['access_token']}" - kwargs["headers"] = headers - return super().do_request(method, url, **kwargs) - - @property - def session_key(self): - return "oauth-client-{0}-request-state".format(self.source.name) diff --git a/passbook/sources/oauth/exceptions.py b/passbook/sources/oauth/exceptions.py deleted file mode 100644 index 9825f246..00000000 --- a/passbook/sources/oauth/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -"""OAuth Source Exception""" -from passbook.lib.sentry import SentryIgnoredException - - -class OAuthSourceException(SentryIgnoredException): - """General Error during OAuth Flow occurred""" diff --git a/passbook/sources/oauth/forms.py b/passbook/sources/oauth/forms.py deleted file mode 100644 index d2b02028..00000000 --- a/passbook/sources/oauth/forms.py +++ /dev/null @@ -1,131 +0,0 @@ -"""passbook oauth_client forms""" - -from django import forms - -from passbook.admin.forms.source import SOURCE_FORM_FIELDS -from passbook.flows.models import Flow, FlowDesignation -from passbook.sources.oauth.models import OAuthSource -from passbook.sources.oauth.types.manager import MANAGER - - -class OAuthSourceForm(forms.ModelForm): - """OAuthSource Form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["authentication_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHENTICATION - ) - self.fields["enrollment_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.ENROLLMENT - ) - if hasattr(self.Meta, "overrides"): - for overide_field, overide_value in getattr(self.Meta, "overrides").items(): - self.fields[overide_field].initial = overide_value - self.fields[overide_field].widget.attrs["readonly"] = "readonly" - - class Meta: - - model = OAuthSource - fields = SOURCE_FORM_FIELDS + [ - "provider_type", - "request_token_url", - "authorization_url", - "access_token_url", - "profile_url", - "consumer_key", - "consumer_secret", - ] - widgets = { - "name": forms.TextInput(), - "consumer_key": forms.TextInput(), - "consumer_secret": forms.TextInput(), - "provider_type": forms.Select(choices=MANAGER.get_name_tuple()), - } - - -class GitHubOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for GitHub""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "github", - "request_token_url": "", - "authorization_url": "https://github.com/login/oauth/authorize", - "access_token_url": "https://github.com/login/oauth/access_token", - "profile_url": "https://api.github.com/user", - } - - -class TwitterOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Twitter""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "twitter", - "request_token_url": "https://api.twitter.com/oauth/request_token", - "authorization_url": "https://api.twitter.com/oauth/authenticate", - "access_token_url": "https://api.twitter.com/oauth/access_token", - "profile_url": ( - "https://api.twitter.com/1.1/account/" - "verify_credentials.json?include_email=true" - ), - } - - -class FacebookOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Facebook""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "facebook", - "request_token_url": "", - "authorization_url": "https://www.facebook.com/v7.0/dialog/oauth", - "access_token_url": "https://graph.facebook.com/v7.0/oauth/access_token", - "profile_url": "https://graph.facebook.com/v7.0/me?fields=id,name,email", - } - - -class DiscordOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Discord""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "discord", - "request_token_url": "", - "authorization_url": "https://discord.com/api/oauth2/authorize", - "access_token_url": "https://discord.com/api/oauth2/token", - "profile_url": "https://discord.com/api/users/@me", - } - - -class GoogleOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for Google""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "google", - "request_token_url": "", - "authorization_url": "https://accounts.google.com/o/oauth2/auth", - "access_token_url": "https://accounts.google.com/o/oauth2/token", - "profile_url": "https://www.googleapis.com/oauth2/v1/userinfo", - } - - -class AzureADOAuthSourceForm(OAuthSourceForm): - """OAuth Source form with pre-determined URL for AzureAD""" - - class Meta(OAuthSourceForm.Meta): - - overrides = { - "provider_type": "azure-ad", - "request_token_url": "", - "authorization_url": "https://login.microsoftonline.com/common/oauth2/authorize", - "access_token_url": "https://login.microsoftonline.com/common/oauth2/token", - "profile_url": "https://graph.windows.net/myorganization/me?api-version=1.6", - } diff --git a/passbook/sources/oauth/migrations/0001_initial.py b/passbook/sources/oauth/migrations/0001_initial.py deleted file mode 100644 index 7aef6b19..00000000 --- a/passbook/sources/oauth/migrations/0001_initial.py +++ /dev/null @@ -1,81 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="OAuthSource", - fields=[ - ( - "source_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.Source", - ), - ), - ("provider_type", models.CharField(max_length=255)), - ( - "request_token_url", - models.CharField( - blank=True, max_length=255, verbose_name="Request Token URL" - ), - ), - ( - "authorization_url", - models.CharField(max_length=255, verbose_name="Authorization URL"), - ), - ( - "access_token_url", - models.CharField(max_length=255, verbose_name="Access Token URL"), - ), - ( - "profile_url", - models.CharField(max_length=255, verbose_name="Profile URL"), - ), - ("consumer_key", models.TextField()), - ("consumer_secret", models.TextField()), - ], - options={ - "verbose_name": "Generic OAuth Source", - "verbose_name_plural": "Generic OAuth Sources", - }, - bases=("passbook_core.source",), - ), - migrations.CreateModel( - name="UserOAuthSourceConnection", - fields=[ - ( - "usersourceconnection_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.UserSourceConnection", - ), - ), - ("identifier", models.CharField(max_length=255)), - ("access_token", models.TextField(blank=True, default=None, null=True)), - ], - options={ - "verbose_name": "User OAuth Source Connection", - "verbose_name_plural": "User OAuth Source Connections", - }, - bases=("passbook_core.usersourceconnection",), - ), - ] diff --git a/passbook/sources/oauth/migrations/0002_auto_20200520_1108.py b/passbook/sources/oauth/migrations/0002_auto_20200520_1108.py deleted file mode 100644 index e1177614..00000000 --- a/passbook/sources/oauth/migrations/0002_auto_20200520_1108.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-20 11:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_oauth", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="oauthsource", - name="access_token_url", - field=models.CharField( - help_text="URL used by passbook to retrive tokens.", - max_length=255, - verbose_name="Access Token URL", - ), - ), - migrations.AlterField( - model_name="oauthsource", - name="request_token_url", - field=models.CharField( - blank=True, - help_text="URL used to request the initial token. This URL is only required for OAuth 1.", - max_length=255, - verbose_name="Request Token URL", - ), - ), - migrations.AlterField( - model_name="oauthsource", - name="authorization_url", - field=models.CharField( - help_text="URL the user is redirect to to conest the flow.", - max_length=255, - verbose_name="Authorization URL", - ), - ), - migrations.AlterField( - model_name="oauthsource", - name="profile_url", - field=models.CharField( - help_text="URL used by passbook to get user information.", - max_length=255, - verbose_name="Profile URL", - ), - ), - ] diff --git a/passbook/sources/oauth/models.py b/passbook/sources/oauth/models.py deleted file mode 100644 index 0adb48aa..00000000 --- a/passbook/sources/oauth/models.py +++ /dev/null @@ -1,207 +0,0 @@ -"""OAuth Client models""" -from typing import Optional, Type - -from django.db import models -from django.forms import ModelForm -from django.urls import reverse, reverse_lazy -from django.utils.translation import gettext_lazy as _ - -from passbook.core.models import Source, UserSourceConnection -from passbook.core.types import UILoginButton - - -class OAuthSource(Source): - """Login using a Generic OAuth provider.""" - - provider_type = models.CharField(max_length=255) - request_token_url = models.CharField( - blank=True, - max_length=255, - verbose_name=_("Request Token URL"), - help_text=_( - "URL used to request the initial token. This URL is only required for OAuth 1." - ), - ) - authorization_url = models.CharField( - max_length=255, - verbose_name=_("Authorization URL"), - help_text=_("URL the user is redirect to to conest the flow."), - ) - access_token_url = models.CharField( - max_length=255, - verbose_name=_("Access Token URL"), - help_text=_("URL used by passbook to retrive tokens."), - ) - profile_url = models.CharField( - max_length=255, - verbose_name=_("Profile URL"), - help_text=_("URL used by passbook to get user information."), - ) - consumer_key = models.TextField() - consumer_secret = models.TextField() - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import OAuthSourceForm - - return OAuthSourceForm - - @property - def ui_login_button(self) -> UILoginButton: - return UILoginButton( - url=reverse_lazy( - "passbook_sources_oauth:oauth-client-login", - kwargs={"source_slug": self.slug}, - ), - icon_path=f"passbook/sources/{self.provider_type}.svg", - name=self.name, - ) - - @property - def ui_additional_info(self) -> str: - url = reverse_lazy( - "passbook_sources_oauth:oauth-client-callback", - kwargs={"source_slug": self.slug}, - ) - return f"Callback URL:
{url}
" - - @property - def ui_user_settings(self) -> Optional[str]: - view_name = "passbook_sources_oauth:oauth-client-user" - return reverse(view_name, kwargs={"source_slug": self.slug}) - - def __str__(self) -> str: - return f"OAuth Source {self.name}" - - class Meta: - - verbose_name = _("Generic OAuth Source") - verbose_name_plural = _("Generic OAuth Sources") - - -class GitHubOAuthSource(OAuthSource): - """Social Login using GitHub.com or a GitHub-Enterprise Instance.""" - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import GitHubOAuthSourceForm - - return GitHubOAuthSourceForm - - class Meta: - - abstract = True - verbose_name = _("GitHub OAuth Source") - verbose_name_plural = _("GitHub OAuth Sources") - - -class TwitterOAuthSource(OAuthSource): - """Social Login using Twitter.com""" - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import TwitterOAuthSourceForm - - return TwitterOAuthSourceForm - - class Meta: - - abstract = True - verbose_name = _("Twitter OAuth Source") - verbose_name_plural = _("Twitter OAuth Sources") - - -class FacebookOAuthSource(OAuthSource): - """Social Login using Facebook.com.""" - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import FacebookOAuthSourceForm - - return FacebookOAuthSourceForm - - class Meta: - - abstract = True - verbose_name = _("Facebook OAuth Source") - verbose_name_plural = _("Facebook OAuth Sources") - - -class DiscordOAuthSource(OAuthSource): - """Social Login using Discord.""" - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import DiscordOAuthSourceForm - - return DiscordOAuthSourceForm - - class Meta: - - abstract = True - verbose_name = _("Discord OAuth Source") - verbose_name_plural = _("Discord OAuth Sources") - - -class GoogleOAuthSource(OAuthSource): - """Social Login using Google or Gsuite.""" - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import GoogleOAuthSourceForm - - return GoogleOAuthSourceForm - - class Meta: - - abstract = True - verbose_name = _("Google OAuth Source") - verbose_name_plural = _("Google OAuth Sources") - - -class AzureADOAuthSource(OAuthSource): - """Social Login using Azure AD.""" - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import AzureADOAuthSourceForm - - return AzureADOAuthSourceForm - - class Meta: - - abstract = True - verbose_name = _("Azure AD OAuth Source") - verbose_name_plural = _("Azure AD OAuth Sources") - - -class OpenIDOAuthSource(OAuthSource): - """Login using a Generic OpenID-Connect compliant provider.""" - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.oauth.forms import OAuthSourceForm - - return OAuthSourceForm - - class Meta: - - abstract = True - verbose_name = _("OpenID OAuth Source") - verbose_name_plural = _("OpenID OAuth Sources") - - -class UserOAuthSourceConnection(UserSourceConnection): - """Authorized remote OAuth provider.""" - - identifier = models.CharField(max_length=255) - access_token = models.TextField(blank=True, null=True, default=None) - - def save(self, *args, **kwargs): - self.access_token = self.access_token or None - super().save(*args, **kwargs) - - class Meta: - - verbose_name = _("User OAuth Source Connection") - verbose_name_plural = _("User OAuth Source Connections") diff --git a/passbook/sources/oauth/settings.py b/passbook/sources/oauth/settings.py deleted file mode 100644 index 0fd524a7..00000000 --- a/passbook/sources/oauth/settings.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Oauth2 Client Settings""" - -PASSBOOK_SOURCES_OAUTH_TYPES = [ - "passbook.sources.oauth.types.discord", - "passbook.sources.oauth.types.facebook", - "passbook.sources.oauth.types.github", - "passbook.sources.oauth.types.google", - "passbook.sources.oauth.types.reddit", - "passbook.sources.oauth.types.twitter", - "passbook.sources.oauth.types.azure_ad", - "passbook.sources.oauth.types.oidc", -] diff --git a/passbook/sources/oauth/templates/oauth_client/user.html b/passbook/sources/oauth/templates/oauth_client/user.html deleted file mode 100644 index 0011035d..00000000 --- a/passbook/sources/oauth/templates/oauth_client/user.html +++ /dev/null @@ -1,24 +0,0 @@ -{% load i18n %} - -
-
- {% blocktrans with source_name=source.name %} - Source {{ source_name }} - {% endblocktrans %} -
-
- {% if connections.exists %} -

{% trans 'Connected.' %}

- - {% trans 'Disconnect' %} - - {% else %} -

Not connected.

- - {% trans 'Connect' %} - - {% endif %} -
-
diff --git a/passbook/sources/oauth/tests.py b/passbook/sources/oauth/tests.py deleted file mode 100644 index 1bc83970..00000000 --- a/passbook/sources/oauth/tests.py +++ /dev/null @@ -1,38 +0,0 @@ -"""OAuth Source tests""" -from django.shortcuts import reverse -from django.test import Client, TestCase - -from passbook.sources.oauth.models import OAuthSource - - -class OAuthSourceTests(TestCase): - """OAuth Source tests""" - - def setUp(self): - self.client = Client() - self.source = OAuthSource.objects.create( - name="test", - slug="test", - provider_type="openid-connect", - authorization_url="", - profile_url="", - consumer_key="", - ) - - def test_source_redirect(self): - """test redirect view""" - self.client.get( - reverse( - "passbook_sources_oauth:oauth-client-login", - kwargs={"source_slug": self.source.slug}, - ) - ) - - def test_source_callback(self): - """test callback view""" - self.client.get( - reverse( - "passbook_sources_oauth:oauth-client-callback", - kwargs={"source_slug": self.source.slug}, - ) - ) diff --git a/passbook/sources/oauth/types/azure_ad.py b/passbook/sources/oauth/types/azure_ad.py deleted file mode 100644 index d1fd83a1..00000000 --- a/passbook/sources/oauth/types/azure_ad.py +++ /dev/null @@ -1,28 +0,0 @@ -"""AzureAD OAuth2 Views""" -from typing import Any, Dict -from uuid import UUID - -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback - - -@MANAGER.source(kind=RequestKind.callback, name="Azure AD") -class AzureADOAuthCallback(OAuthCallback): - """AzureAD OAuth2 Callback""" - - def get_user_id(self, source: OAuthSource, info: Dict[str, Any]) -> str: - return str(UUID(info.get("objectId")).int) - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - mail = info.get("mail", None) or info.get("otherMails", [None])[0] - return { - "username": info.get("displayName"), - "email": mail, - "name": info.get("displayName"), - } diff --git a/passbook/sources/oauth/types/discord.py b/passbook/sources/oauth/types/discord.py deleted file mode 100644 index d1e9fb7e..00000000 --- a/passbook/sources/oauth/types/discord.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Discord OAuth Views""" -from typing import Any, Dict - -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback -from passbook.sources.oauth.views.redirect import OAuthRedirect - - -@MANAGER.source(kind=RequestKind.redirect, name="Discord") -class DiscordOAuthRedirect(OAuthRedirect): - """Discord OAuth2 Redirect""" - - def get_additional_parameters(self, source): - return { - "scope": "email identify", - } - - -@MANAGER.source(kind=RequestKind.callback, name="Discord") -class DiscordOAuth2Callback(OAuthCallback): - """Discord OAuth2 Callback""" - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - return { - "username": info.get("username"), - "email": info.get("email", None), - "name": info.get("username"), - } diff --git a/passbook/sources/oauth/types/facebook.py b/passbook/sources/oauth/types/facebook.py deleted file mode 100644 index 5bed3116..00000000 --- a/passbook/sources/oauth/types/facebook.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Facebook OAuth Views""" -from typing import Any, Dict, Optional - -from facebook import GraphAPI - -from passbook.sources.oauth.clients.oauth2 import OAuth2Client -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback -from passbook.sources.oauth.views.redirect import OAuthRedirect - - -@MANAGER.source(kind=RequestKind.redirect, name="Facebook") -class FacebookOAuthRedirect(OAuthRedirect): - """Facebook OAuth2 Redirect""" - - def get_additional_parameters(self, source): - return { - "scope": "email", - } - - -class FacebookOAuth2Client(OAuth2Client): - """Facebook OAuth2 Client""" - - def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]: - api = GraphAPI(access_token=token["access_token"]) - return api.get_object("me", fields="id,name,email") - - -@MANAGER.source(kind=RequestKind.callback, name="Facebook") -class FacebookOAuth2Callback(OAuthCallback): - """Facebook OAuth2 Callback""" - - client_class = FacebookOAuth2Client - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - return { - "username": info.get("name"), - "email": info.get("email"), - "name": info.get("name"), - } diff --git a/passbook/sources/oauth/types/github.py b/passbook/sources/oauth/types/github.py deleted file mode 100644 index e0eb4f08..00000000 --- a/passbook/sources/oauth/types/github.py +++ /dev/null @@ -1,23 +0,0 @@ -"""GitHub OAuth Views""" -from typing import Any, Dict - -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback - - -@MANAGER.source(kind=RequestKind.callback, name="GitHub") -class GitHubOAuth2Callback(OAuthCallback): - """GitHub OAuth2 Callback""" - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - return { - "username": info.get("login"), - "email": info.get("email"), - "name": info.get("name"), - } diff --git a/passbook/sources/oauth/types/google.py b/passbook/sources/oauth/types/google.py deleted file mode 100644 index 9aee80cc..00000000 --- a/passbook/sources/oauth/types/google.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Google OAuth Views""" -from typing import Any, Dict - -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback -from passbook.sources.oauth.views.redirect import OAuthRedirect - - -@MANAGER.source(kind=RequestKind.redirect, name="Google") -class GoogleOAuthRedirect(OAuthRedirect): - """Google OAuth2 Redirect""" - - def get_additional_parameters(self, source): - return { - "scope": "email profile", - } - - -@MANAGER.source(kind=RequestKind.callback, name="Google") -class GoogleOAuth2Callback(OAuthCallback): - """Google OAuth2 Callback""" - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - return { - "username": info.get("email"), - "email": info.get("email"), - "name": info.get("name"), - } diff --git a/passbook/sources/oauth/types/manager.py b/passbook/sources/oauth/types/manager.py deleted file mode 100644 index 0355b910..00000000 --- a/passbook/sources/oauth/types/manager.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Source type manager""" -from enum import Enum -from typing import Callable, Dict, List - -from django.utils.text import slugify -from structlog import get_logger - -from passbook.sources.oauth.models import OAuthSource -from passbook.sources.oauth.views.callback import OAuthCallback -from passbook.sources.oauth.views.redirect import OAuthRedirect - -LOGGER = get_logger() - - -class RequestKind(Enum): - """Enum of OAuth Request types""" - - callback = "callback" - redirect = "redirect" - - -class SourceTypeManager: - """Manager to hold all Source types.""" - - __source_types: Dict[RequestKind, Dict[str, Callable]] = {} - __names: List[str] = [] - - def source(self, kind: RequestKind, name: str): - """Class decorator to register classes inline.""" - - def inner_wrapper(cls): - if kind.value not in self.__source_types: - self.__source_types[kind.value] = {} - self.__source_types[kind.value][slugify(name)] = cls - self.__names.append(name) - return cls - - return inner_wrapper - - def get_name_tuple(self): - """Get list of tuples of all registered names""" - return [(slugify(x), x) for x in set(self.__names)] - - def find(self, source: OAuthSource, kind: RequestKind) -> Callable: - """Find fitting Source Type""" - if kind.value in self.__source_types: - if source.provider_type in self.__source_types[kind.value]: - return self.__source_types[kind.value][source.provider_type] - LOGGER.warning( - "no matching type found, using default", - wanted=source.provider_type, - have=self.__source_types[kind.value].keys(), - ) - # Return defaults - if kind == RequestKind.callback: - return OAuthCallback - if kind == RequestKind.redirect: - return OAuthRedirect - raise KeyError( - f"Provider Type {source.provider_type} (type {kind.value}) not found." - ) - - -MANAGER = SourceTypeManager() diff --git a/passbook/sources/oauth/types/oidc.py b/passbook/sources/oauth/types/oidc.py deleted file mode 100644 index e3f5cfdc..00000000 --- a/passbook/sources/oauth/types/oidc.py +++ /dev/null @@ -1,37 +0,0 @@ -"""OpenID Connect OAuth Views""" -from typing import Any, Dict - -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback -from passbook.sources.oauth.views.redirect import OAuthRedirect - - -@MANAGER.source(kind=RequestKind.redirect, name="OpenID Connect") -class OpenIDConnectOAuthRedirect(OAuthRedirect): - """OpenIDConnect OAuth2 Redirect""" - - def get_additional_parameters(self, source: OAuthSource): - return { - "scope": "openid email profile", - } - - -@MANAGER.source(kind=RequestKind.callback, name="OpenID Connect") -class OpenIDConnectOAuth2Callback(OAuthCallback): - """OpenIDConnect OAuth2 Callback""" - - def get_user_id(self, source: OAuthSource, info: Dict[str, str]) -> str: - return info.get("sub", "") - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - return { - "username": info.get("nickname"), - "email": info.get("email"), - "name": info.get("name"), - } diff --git a/passbook/sources/oauth/types/reddit.py b/passbook/sources/oauth/types/reddit.py deleted file mode 100644 index 2c1f11c5..00000000 --- a/passbook/sources/oauth/types/reddit.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Reddit OAuth Views""" -from typing import Any, Dict - -from requests.auth import HTTPBasicAuth - -from passbook.sources.oauth.clients.oauth2 import OAuth2Client -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback -from passbook.sources.oauth.views.redirect import OAuthRedirect - - -@MANAGER.source(kind=RequestKind.redirect, name="reddit") -class RedditOAuthRedirect(OAuthRedirect): - """Reddit OAuth2 Redirect""" - - def get_additional_parameters(self, source): - return { - "scope": "identity", - "duration": "permanent", - } - - -class RedditOAuth2Client(OAuth2Client): - """Reddit OAuth2 Client""" - - def get_access_token(self, **request_kwargs): - "Fetch access token from callback request." - auth = HTTPBasicAuth(self.source.consumer_key, self.source.consumer_secret) - return super().get_access_token(auth=auth) - - -@MANAGER.source(kind=RequestKind.callback, name="reddit") -class RedditOAuth2Callback(OAuthCallback): - """Reddit OAuth2 Callback""" - - client_class = RedditOAuth2Client - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - return { - "username": info.get("name"), - "email": None, - "name": info.get("name"), - "password": None, - } diff --git a/passbook/sources/oauth/types/twitter.py b/passbook/sources/oauth/types/twitter.py deleted file mode 100644 index 0509ecc8..00000000 --- a/passbook/sources/oauth/types/twitter.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Twitter OAuth Views""" -from typing import Any, Dict - -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.types.manager import MANAGER, RequestKind -from passbook.sources.oauth.views.callback import OAuthCallback - - -@MANAGER.source(kind=RequestKind.callback, name="Twitter") -class TwitterOAuthCallback(OAuthCallback): - """Twitter OAuth2 Callback""" - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - return { - "username": info.get("screen_name"), - "email": info.get("email"), - "name": info.get("name"), - } diff --git a/passbook/sources/oauth/urls.py b/passbook/sources/oauth/urls.py deleted file mode 100644 index ff203b21..00000000 --- a/passbook/sources/oauth/urls.py +++ /dev/null @@ -1,30 +0,0 @@ -"""passbook OAuth source urls""" - -from django.urls import path - -from passbook.sources.oauth.types.manager import RequestKind -from passbook.sources.oauth.views.dispatcher import DispatcherView -from passbook.sources.oauth.views.user import DisconnectView, UserSettingsView - -urlpatterns = [ - path( - "login//", - DispatcherView.as_view(kind=RequestKind.redirect), - name="oauth-client-login", - ), - path( - "callback//", - DispatcherView.as_view(kind=RequestKind.callback), - name="oauth-client-callback", - ), - path( - "user//", - UserSettingsView.as_view(), - name="oauth-client-user", - ), - path( - "user//disconnect/", - DisconnectView.as_view(), - name="oauth-client-disconnect", - ), -] diff --git a/passbook/sources/oauth/views/base.py b/passbook/sources/oauth/views/base.py deleted file mode 100644 index b7472624..00000000 --- a/passbook/sources/oauth/views/base.py +++ /dev/null @@ -1,27 +0,0 @@ -"""OAuth Base views""" -from typing import Optional, Type - -from django.http.request import HttpRequest - -from passbook.sources.oauth.clients.base import BaseOAuthClient -from passbook.sources.oauth.clients.oauth1 import OAuthClient -from passbook.sources.oauth.clients.oauth2 import OAuth2Client -from passbook.sources.oauth.models import OAuthSource - - -# pylint: disable=too-few-public-methods -class OAuthClientMixin: - "Mixin for getting OAuth client for a source." - - request: HttpRequest # Set by View class - - client_class: Optional[Type[BaseOAuthClient]] = None - - def get_client(self, source: OAuthSource, **kwargs) -> BaseOAuthClient: - "Get instance of the OAuth client for this source." - if self.client_class is not None: - # pylint: disable=not-callable - return self.client_class(source, self.request, **kwargs) - if source.request_token_url: - return OAuthClient(source, self.request, **kwargs) - return OAuth2Client(source, self.request, **kwargs) diff --git a/passbook/sources/oauth/views/callback.py b/passbook/sources/oauth/views/callback.py deleted file mode 100644 index 567cdb5a..00000000 --- a/passbook/sources/oauth/views/callback.py +++ /dev/null @@ -1,234 +0,0 @@ -"""OAuth Callback Views""" -from typing import Any, Dict, Optional - -from django.conf import settings -from django.contrib import messages -from django.http import Http404, HttpRequest, HttpResponse -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.translation import gettext as _ -from django.views.generic import View -from structlog import get_logger - -from passbook.audit.models import Event, EventAction -from passbook.core.models import User -from passbook.flows.models import Flow, in_memory_stage -from passbook.flows.planner import ( - PLAN_CONTEXT_PENDING_USER, - PLAN_CONTEXT_SSO, - FlowPlanner, -) -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.lib.utils.urls import redirect_with_qs -from passbook.policies.utils import delete_none_keys -from passbook.sources.oauth.auth import AuthorizedServiceBackend -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection -from passbook.sources.oauth.views.base import OAuthClientMixin -from passbook.sources.oauth.views.flows import ( - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS, - PostUserEnrollmentStage, -) -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT - -LOGGER = get_logger() - - -class OAuthCallback(OAuthClientMixin, View): - "Base OAuth callback view." - - source_id = None - source = None - - # pylint: disable=too-many-return-statements - def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse: - """View Get handler""" - slug = kwargs.get("source_slug", "") - try: - self.source = OAuthSource.objects.get(slug=slug) - except OAuthSource.DoesNotExist: - raise Http404(f"Unknown OAuth source '{slug}'.") - - if not self.source.enabled: - raise Http404(f"Source {slug} is not enabled.") - client = self.get_client( - self.source, callback=self.get_callback_url(self.source) - ) - # Fetch access token - token = client.get_access_token() - if token is None: - return self.handle_login_failure(self.source, "Could not retrieve token.") - if "error" in token: - return self.handle_login_failure(self.source, token["error"]) - # Fetch profile info - info = client.get_profile_info(token) - if info is None: - return self.handle_login_failure(self.source, "Could not retrieve profile.") - identifier = self.get_user_id(self.source, info) - if identifier is None: - return self.handle_login_failure(self.source, "Could not determine id.") - # Get or create access record - defaults = { - "access_token": token.get("access_token"), - } - existing = UserOAuthSourceConnection.objects.filter( - source=self.source, identifier=identifier - ) - - if existing.exists(): - connection = existing.first() - connection.access_token = token.get("access_token") - UserOAuthSourceConnection.objects.filter(pk=connection.pk).update( - **defaults - ) - else: - connection = UserOAuthSourceConnection( - source=self.source, - identifier=identifier, - access_token=token.get("access_token"), - ) - user = AuthorizedServiceBackend().authenticate( - source=self.source, identifier=identifier, request=request - ) - if user is None: - if self.request.user.is_authenticated: - LOGGER.debug("Linking existing user", source=self.source) - return self.handle_existing_user_link(self.source, connection, info) - LOGGER.debug("Handling enrollment of new user", source=self.source) - return self.handle_enroll(self.source, connection, info) - LOGGER.debug("Handling existing user", source=self.source) - return self.handle_existing_user(self.source, user, connection, info) - - # pylint: disable=unused-argument - def get_callback_url(self, source: OAuthSource) -> str: - "Return callback url if different than the current url." - return "" - - # pylint: disable=unused-argument - def get_error_redirect(self, source: OAuthSource, reason: str) -> str: - "Return url to redirect on login failure." - return settings.LOGIN_URL - - def get_user_enroll_context( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> Dict[str, Any]: - """Create a dict of User data""" - raise NotImplementedError() - - # pylint: disable=unused-argument - def get_user_id( - self, source: UserOAuthSourceConnection, info: Dict[str, Any] - ) -> Optional[str]: - """Return unique identifier from the profile info.""" - if "id" in info: - return info["id"] - return None - - def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse: - "Message user and redirect on error." - LOGGER.warning("Authentication Failure", reason=reason) - messages.error(self.request, _("Authentication Failed.")) - return redirect(self.get_error_redirect(source, reason)) - - def handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: - """Prepare Authentication Plan, redirect user FlowExecutor""" - kwargs.update( - { - # Since we authenticate the user by their token, they have no backend set - PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", - PLAN_CONTEXT_SSO: True, - } - ) - # We run the Flow planner here so we can pass the Pending user in the context - planner = FlowPlanner(flow) - plan = planner.plan(self.request, kwargs) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - self.request.GET, - flow_slug=flow.slug, - ) - - # pylint: disable=unused-argument - def handle_existing_user( - self, - source: OAuthSource, - user: User, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> HttpResponse: - "Login user and redirect." - messages.success( - self.request, - _( - "Successfully authenticated with %(source)s!" - % {"source": self.source.name} - ), - ) - flow_kwargs = {PLAN_CONTEXT_PENDING_USER: user} - return self.handle_login_flow(source.authentication_flow, **flow_kwargs) - - def handle_existing_user_link( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> HttpResponse: - """Handler when the user was already authenticated and linked an external source - to their account.""" - # there's already a user logged in, just link them up - user = self.request.user - access.user = user - access.save() - UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) - Event.new( - EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source - ).from_http(self.request) - messages.success( - self.request, - _("Successfully linked %(source)s!" % {"source": self.source.name}), - ) - return redirect( - reverse( - "passbook_sources_oauth:oauth-client-user", - kwargs={"source_slug": self.source.slug}, - ) - ) - - def handle_enroll( - self, - source: OAuthSource, - access: UserOAuthSourceConnection, - info: Dict[str, Any], - ) -> HttpResponse: - """User was not authenticated and previous request was not authenticated.""" - messages.success( - self.request, - _( - "Successfully authenticated with %(source)s!" - % {"source": self.source.name} - ), - ) - # Because we inject a stage into the planned flow, we can't use `self.handle_login_flow` - context = { - # Since we authenticate the user by their token, they have no backend set - PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", - PLAN_CONTEXT_SSO: True, - PLAN_CONTEXT_PROMPT: delete_none_keys( - self.get_user_enroll_context(source, access, info) - ), - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access, - } - # We run the Flow planner here so we can pass the Pending user in the context - planner = FlowPlanner(source.enrollment_flow) - plan = planner.plan(self.request, context) - plan.append(in_memory_stage(PostUserEnrollmentStage)) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - self.request.GET, - flow_slug=source.enrollment_flow.slug, - ) diff --git a/passbook/sources/oauth/views/dispatcher.py b/passbook/sources/oauth/views/dispatcher.py deleted file mode 100644 index 110f26eb..00000000 --- a/passbook/sources/oauth/views/dispatcher.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Dispatch OAuth views to respective views""" -from django.http import Http404 -from django.shortcuts import get_object_or_404 -from django.views import View -from structlog import get_logger - -from passbook.sources.oauth.models import OAuthSource -from passbook.sources.oauth.types.manager import MANAGER, RequestKind - -LOGGER = get_logger() - - -class DispatcherView(View): - """Dispatch OAuth Redirect/Callback views to their proper class based on URL parameters""" - - kind = "" - - def dispatch(self, *args, **kwargs): - """Find Source by slug and forward request""" - slug = kwargs.get("source_slug", None) - if not slug: - raise Http404 - source = get_object_or_404(OAuthSource, slug=slug) - view = MANAGER.find(source, kind=RequestKind(self.kind)) - LOGGER.debug("dispatching OAuth2 request to", view=view, kind=self.kind) - return view.as_view()(*args, **kwargs) diff --git a/passbook/sources/oauth/views/flows.py b/passbook/sources/oauth/views/flows.py deleted file mode 100644 index e6039102..00000000 --- a/passbook/sources/oauth/views/flows.py +++ /dev/null @@ -1,30 +0,0 @@ -"""OAuth Stages""" -from django.http import HttpRequest, HttpResponse - -from passbook.audit.models import Event, EventAction -from passbook.core.models import User -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.sources.oauth.models import UserOAuthSourceConnection - -PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access" - - -class PostUserEnrollmentStage(StageView): - """Dynamically injected stage which saves the OAuth Connection after - the user has been enrolled.""" - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - access: UserOAuthSourceConnection = self.executor.plan.context[ - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS - ] - user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - access.user = user - access.save() - UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) - Event.new( - EventAction.SOURCE_LINKED, - message="Linked OAuth Source", - source=access.source, - ).from_http(self.request) - return self.executor.stage_ok() diff --git a/passbook/sources/oauth/views/redirect.py b/passbook/sources/oauth/views/redirect.py deleted file mode 100644 index a3557e01..00000000 --- a/passbook/sources/oauth/views/redirect.py +++ /dev/null @@ -1,45 +0,0 @@ -"""OAuth Redirect Views""" -from typing import Any, Dict - -from django.http import Http404 -from django.urls import reverse -from django.views.generic import RedirectView -from structlog import get_logger - -from passbook.sources.oauth.models import OAuthSource -from passbook.sources.oauth.views.base import OAuthClientMixin - -LOGGER = get_logger() - - -class OAuthRedirect(OAuthClientMixin, RedirectView): - "Redirect user to OAuth source to enable access." - - permanent = False - params = None - - # pylint: disable=unused-argument - def get_additional_parameters(self, source: OAuthSource) -> Dict[str, Any]: - "Return additional redirect parameters for this source." - return self.params or {} - - def get_callback_url(self, source: OAuthSource) -> str: - "Return the callback url for this source." - return reverse( - "passbook_sources_oauth:oauth-client-callback", - kwargs={"source_slug": source.slug}, - ) - - def get_redirect_url(self, **kwargs) -> str: - "Build redirect url for a given source." - slug = kwargs.get("source_slug", "") - try: - source = OAuthSource.objects.get(slug=slug) - except OAuthSource.DoesNotExist: - raise Http404(f"Unknown OAuth source '{slug}'.") - else: - if not source.enabled: - raise Http404(f"source {slug} is not enabled.") - client = self.get_client(source, callback=self.get_callback_url(source)) - params = self.get_additional_parameters(source) - return client.get_redirect_url(params) diff --git a/passbook/sources/oauth/views/user.py b/passbook/sources/oauth/views/user.py deleted file mode 100644 index 0aa0fba0..00000000 --- a/passbook/sources/oauth/views/user.py +++ /dev/null @@ -1,70 +0,0 @@ -"""passbook oauth_client user views""" -from typing import Optional - -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.utils.translation import gettext as _ -from django.views.generic import TemplateView, View - -from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection - - -class UserSettingsView(LoginRequiredMixin, TemplateView): - """Show user current connection state""" - - template_name = "oauth_client/user.html" - - def get_context_data(self, **kwargs): - source = get_object_or_404(OAuthSource, slug=self.kwargs.get("source_slug")) - connections = UserOAuthSourceConnection.objects.filter( - user=self.request.user, source=source - ) - kwargs["source"] = source - kwargs["connections"] = connections - return super().get_context_data(**kwargs) - - -class DisconnectView(LoginRequiredMixin, View): - """Delete connection with source""" - - source: Optional[OAuthSource] = None - aas: Optional[UserOAuthSourceConnection] = None - - def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse: - self.source = get_object_or_404(OAuthSource, slug=source_slug) - self.aas = get_object_or_404( - UserOAuthSourceConnection, source=self.source, user=request.user - ) - return super().dispatch(request, source_slug) - - def post(self, request: HttpRequest, source_slug: str) -> HttpResponse: - """Delete connection object""" - if "confirmdelete" in request.POST: - # User confirmed deletion - self.aas.delete() - messages.success(request, _("Connection successfully deleted")) - return redirect( - reverse( - "passbook_sources_oauth:oauth-client-user", - kwargs={"source_slug": self.source.slug}, - ) - ) - return self.get(request, source_slug) - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, source_slug: str) -> HttpResponse: - """Show delete form""" - return render( - request, - "generic/delete.html", - { - "object": self.source, - "delete_url": reverse( - "passbook_sources_oauth:oauth-client-disconnect", - kwargs={"source_slug": self.source.slug}, - ), - }, - ) diff --git a/passbook/sources/saml/api.py b/passbook/sources/saml/api.py deleted file mode 100644 index e87b7586..00000000 --- a/passbook/sources/saml/api.py +++ /dev/null @@ -1,33 +0,0 @@ -"""SAMLSource API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.admin.forms.source import SOURCE_FORM_FIELDS -from passbook.sources.saml.models import SAMLSource - - -class SAMLSourceSerializer(ModelSerializer): - """SAMLSource Serializer""" - - class Meta: - - model = SAMLSource - fields = SOURCE_FORM_FIELDS + [ - "issuer", - "sso_url", - "slo_url", - "allow_idp_initiated", - "name_id_policy", - "binding_type", - "signing_kp", - "digest_algorithm", - "signature_algorithm", - "temporary_user_delete_after", - ] - - -class SAMLSourceViewSet(ModelViewSet): - """SAMLSource Viewset""" - - queryset = SAMLSource.objects.all() - serializer_class = SAMLSourceSerializer diff --git a/passbook/sources/saml/apps.py b/passbook/sources/saml/apps.py deleted file mode 100644 index 39afcbc7..00000000 --- a/passbook/sources/saml/apps.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Passbook SAML app config""" - -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookSourceSAMLConfig(AppConfig): - """passbook saml_idp app config""" - - name = "passbook.sources.saml" - label = "passbook_sources_saml" - verbose_name = "passbook Sources.SAML" - mountpoint = "source/saml/" - - def ready(self): - import_module("passbook.sources.saml.signals") diff --git a/passbook/sources/saml/exceptions.py b/passbook/sources/saml/exceptions.py deleted file mode 100644 index 095f4640..00000000 --- a/passbook/sources/saml/exceptions.py +++ /dev/null @@ -1,18 +0,0 @@ -"""passbook saml source exceptions""" -from passbook.lib.sentry import SentryIgnoredException - - -class MissingSAMLResponse(SentryIgnoredException): - """Exception raised when request does not contain SAML Response.""" - - -class UnsupportedNameIDFormat(SentryIgnoredException): - """Exception raised when SAML Response contains NameID Format not supported.""" - - -class MismatchedRequestID(SentryIgnoredException): - """Exception raised when the returned request ID doesn't match the saved ID.""" - - -class InvalidSignature(SentryIgnoredException): - """Signature of XML Object is either missing or invalid""" diff --git a/passbook/sources/saml/forms.py b/passbook/sources/saml/forms.py deleted file mode 100644 index 3f2776bb..00000000 --- a/passbook/sources/saml/forms.py +++ /dev/null @@ -1,49 +0,0 @@ -"""passbook SAML SP Forms""" - -from django import forms - -from passbook.admin.forms.source import SOURCE_FORM_FIELDS -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow, FlowDesignation -from passbook.sources.saml.models import SAMLSource - - -class SAMLSourceForm(forms.ModelForm): - """SAML Provider form""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields["authentication_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.AUTHENTICATION - ) - self.fields["enrollment_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.ENROLLMENT - ) - self.fields["signing_kp"].queryset = CertificateKeyPair.objects.filter( - certificate_data__isnull=False, - key_data__isnull=False, - ) - - class Meta: - - model = SAMLSource - fields = SOURCE_FORM_FIELDS + [ - "issuer", - "sso_url", - "slo_url", - "binding_type", - "name_id_policy", - "allow_idp_initiated", - "signing_kp", - "digest_algorithm", - "signature_algorithm", - "temporary_user_delete_after", - ] - widgets = { - "name": forms.TextInput(), - "issuer": forms.TextInput(), - "sso_url": forms.TextInput(), - "slo_url": forms.TextInput(), - "temporary_user_delete_after": forms.TextInput(), - } diff --git a/passbook/sources/saml/migrations/0001_initial.py b/passbook/sources/saml/migrations/0001_initial.py deleted file mode 100644 index 664bfb36..00000000 --- a/passbook/sources/saml/migrations/0001_initial.py +++ /dev/null @@ -1,68 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_crypto", "0001_initial"), - ("passbook_core", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="SAMLSource", - fields=[ - ( - "source_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_core.Source", - ), - ), - ( - "issuer", - models.TextField( - blank=True, - default=None, - help_text="Also known as Entity ID. Defaults the Metadata URL.", - verbose_name="Issuer", - ), - ), - ("idp_url", models.URLField(verbose_name="IDP URL")), - ( - "idp_logout_url", - models.URLField( - blank=True, - default=None, - null=True, - verbose_name="IDP Logout URL", - ), - ), - ("auto_logout", models.BooleanField(default=False)), - ( - "signing_kp", - models.ForeignKey( - default=None, - help_text="Certificate Key Pair of the IdP which Assertions are validated against.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="passbook_crypto.CertificateKeyPair", - ), - ), - ], - options={ - "verbose_name": "SAML Source", - "verbose_name_plural": "SAML Sources", - }, - bases=("passbook_core.source",), - ), - ] diff --git a/passbook/sources/saml/migrations/0002_auto_20200523_2329.py b/passbook/sources/saml/migrations/0002_auto_20200523_2329.py deleted file mode 100644 index 8703b351..00000000 --- a/passbook/sources/saml/migrations/0002_auto_20200523_2329.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-23 23:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_saml", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="samlsource", - name="binding_type", - field=models.CharField( - choices=[("REDIRECT", "Redirect"), ("POST", "Post")], - default="REDIRECT", - max_length=100, - ), - ), - migrations.AlterField( - model_name="samlsource", - name="idp_url", - field=models.URLField( - help_text="URL that the initial SAML Request is sent to. Also known as a Binding.", - verbose_name="IDP URL", - ), - ), - ] diff --git a/passbook/sources/saml/migrations/0003_auto_20200624_1957.py b/passbook/sources/saml/migrations/0003_auto_20200624_1957.py deleted file mode 100644 index ce5b7185..00000000 --- a/passbook/sources/saml/migrations/0003_auto_20200624_1957.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-24 19:57 - -import django.db.models.deletion -from django.db import migrations, models - -import passbook.lib.utils.time - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0002_create_self_signed_kp"), - ("passbook_sources_saml", "0002_auto_20200523_2329"), - ] - - operations = [ - migrations.RemoveField( - model_name="samlsource", - name="auto_logout", - ), - migrations.RenameField( - model_name="samlsource", - old_name="idp_url", - new_name="sso_url", - ), - migrations.RenameField( - model_name="samlsource", - old_name="idp_logout_url", - new_name="slo_url", - ), - migrations.AddField( - model_name="samlsource", - name="temporary_user_delete_after", - field=models.TextField( - default="days=1", - help_text="Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format 'transient', and the user doesn't log out manually. (Format: hours=1;minutes=2;seconds=3).", - validators=[passbook.lib.utils.time.timedelta_string_validator], - verbose_name="Delete temporary users after", - ), - ), - migrations.AlterField( - model_name="samlsource", - name="signing_kp", - field=models.ForeignKey( - help_text="Certificate Key Pair of the IdP which Assertion's Signature is validated against.", - on_delete=django.db.models.deletion.PROTECT, - to="passbook_crypto.CertificateKeyPair", - verbose_name="Singing Keypair", - ), - ), - migrations.AlterField( - model_name="samlsource", - name="slo_url", - field=models.URLField( - blank=True, - default=None, - help_text="Optional URL if your IDP supports Single-Logout.", - null=True, - verbose_name="SLO URL", - ), - ), - migrations.AlterField( - model_name="samlsource", - name="sso_url", - field=models.URLField( - help_text="URL that the initial Login request is sent to.", - verbose_name="SSO URL", - ), - ), - ] diff --git a/passbook/sources/saml/migrations/0004_auto_20200708_1207.py b/passbook/sources/saml/migrations/0004_auto_20200708_1207.py deleted file mode 100644 index 7fca9268..00000000 --- a/passbook/sources/saml/migrations/0004_auto_20200708_1207.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-08 12:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_saml", "0003_auto_20200624_1957"), - ] - - operations = [ - migrations.AlterField( - model_name="samlsource", - name="binding_type", - field=models.CharField( - choices=[ - ("REDIRECT", "Redirect Binding"), - ("POST", "POST Binding"), - ("POST_AUTO", "POST Binding with auto-confirmation"), - ], - default="REDIRECT", - max_length=100, - ), - ), - ] diff --git a/passbook/sources/saml/migrations/0005_samlsource_name_id_policy.py b/passbook/sources/saml/migrations/0005_samlsource_name_id_policy.py deleted file mode 100644 index 5b5e6f64..00000000 --- a/passbook/sources/saml/migrations/0005_samlsource_name_id_policy.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-08 13:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_saml", "0004_auto_20200708_1207"), - ] - - operations = [ - migrations.AddField( - model_name="samlsource", - name="name_id_policy", - field=models.TextField( - choices=[ - ("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"), - ( - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", - "Persistent", - ), - ( - "urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName", - "X509", - ), - ( - "urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName", - "Windows", - ), - ( - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "Transient", - ), - ], - default="urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - help_text="NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent.", - ), - ), - ] diff --git a/passbook/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py b/passbook/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py deleted file mode 100644 index aac48851..00000000 --- a/passbook/sources/saml/migrations/0006_samlsource_allow_idp_initiated.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-11 22:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_saml", "0005_samlsource_name_id_policy"), - ] - - operations = [ - migrations.AddField( - model_name="samlsource", - name="allow_idp_initiated", - field=models.BooleanField( - default=False, - help_text="Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done.", - ), - ), - ] diff --git a/passbook/sources/saml/migrations/0007_auto_20201112_1055.py b/passbook/sources/saml/migrations/0007_auto_20201112_1055.py deleted file mode 100644 index 44569190..00000000 --- a/passbook/sources/saml/migrations/0007_auto_20201112_1055.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-12 10:55 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_crypto", "0002_create_self_signed_kp"), - ("passbook_sources_saml", "0006_samlsource_allow_idp_initiated"), - ] - - operations = [ - migrations.AddField( - model_name="samlsource", - name="digest_algorithm", - field=models.CharField( - choices=[("sha1", "SHA1"), ("sha256", "SHA256")], - default="sha256", - max_length=50, - ), - ), - migrations.AddField( - model_name="samlsource", - name="signature_algorithm", - field=models.CharField( - choices=[ - ("rsa-sha1", "RSA-SHA1"), - ("rsa-sha256", "RSA-SHA256"), - ("ecdsa-sha256", "ECDSA-SHA256"), - ("dsa-sha1", "DSA-SHA1"), - ], - default="rsa-sha256", - max_length=50, - ), - ), - migrations.AlterField( - model_name="samlsource", - name="signing_kp", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Keypair which is used to sign outgoing requests. Leave empty to disable signing.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - to="passbook_crypto.certificatekeypair", - verbose_name="Singing Keypair", - ), - ), - ] diff --git a/passbook/sources/saml/migrations/0008_auto_20201112_2016.py b/passbook/sources/saml/migrations/0008_auto_20201112_2016.py deleted file mode 100644 index b8d39d12..00000000 --- a/passbook/sources/saml/migrations/0008_auto_20201112_2016.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-12 20:16 - -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.sources.saml.processors import constants - - -def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - SAMLSource = apps.get_model("passbook_sources_saml", "SAMLSource") - signature_translation_map = { - "rsa-sha1": constants.RSA_SHA1, - "rsa-sha256": constants.RSA_SHA256, - "ecdsa-sha256": constants.RSA_SHA256, - "dsa-sha1": constants.DSA_SHA1, - } - digest_translation_map = { - "sha1": constants.SHA1, - "sha256": constants.SHA256, - } - - for source in SAMLSource.objects.all(): - source.signature_algorithm = signature_translation_map.get( - source.signature_algorithm, constants.RSA_SHA256 - ) - source.digest_algorithm = digest_translation_map.get( - source.digest_algorithm, constants.SHA256 - ) - source.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_sources_saml", "0007_auto_20201112_1055"), - ] - - operations = [ - migrations.AlterField( - model_name="samlsource", - name="signature_algorithm", - field=models.CharField( - choices=[ - (constants.RSA_SHA1, "RSA-SHA1"), - (constants.RSA_SHA256, "RSA-SHA256"), - (constants.RSA_SHA384, "RSA-SHA384"), - (constants.RSA_SHA512, "RSA-SHA512"), - (constants.DSA_SHA1, "DSA-SHA1"), - ], - default=constants.RSA_SHA256, - max_length=50, - ), - ), - migrations.AlterField( - model_name="samlsource", - name="digest_algorithm", - field=models.CharField( - choices=[ - (constants.SHA1, "SHA1"), - (constants.SHA256, "SHA256"), - (constants.SHA384, "SHA384"), - (constants.SHA512, "SHA512"), - ], - default=constants.SHA256, - max_length=50, - ), - ), - migrations.RunPython(update_algorithms), - ] diff --git a/passbook/sources/saml/models.py b/passbook/sources/saml/models.py deleted file mode 100644 index d0d80a29..00000000 --- a/passbook/sources/saml/models.py +++ /dev/null @@ -1,181 +0,0 @@ -"""saml sp models""" -from typing import Type - -from django.db import models -from django.forms import ModelForm -from django.http import HttpRequest -from django.shortcuts import reverse -from django.urls import reverse_lazy -from django.utils.translation import gettext_lazy as _ - -from passbook.core.models import Source -from passbook.core.types import UILoginButton -from passbook.crypto.models import CertificateKeyPair -from passbook.lib.utils.time import timedelta_string_validator -from passbook.sources.saml.processors.constants import ( - DSA_SHA1, - RSA_SHA1, - RSA_SHA256, - RSA_SHA384, - RSA_SHA512, - SAML_NAME_ID_FORMAT_EMAIL, - SAML_NAME_ID_FORMAT_PERSISTENT, - SAML_NAME_ID_FORMAT_TRANSIENT, - SAML_NAME_ID_FORMAT_WINDOWS, - SAML_NAME_ID_FORMAT_X509, - SHA1, - SHA256, - SHA384, - SHA512, -) - - -class SAMLBindingTypes(models.TextChoices): - """SAML Binding types""" - - Redirect = "REDIRECT", _("Redirect Binding") - POST = "POST", _("POST Binding") - POST_AUTO = "POST_AUTO", _("POST Binding with auto-confirmation") - - -class SAMLNameIDPolicy(models.TextChoices): - """SAML NameID Policies""" - - EMAIL = SAML_NAME_ID_FORMAT_EMAIL - PERSISTENT = SAML_NAME_ID_FORMAT_PERSISTENT - X509 = SAML_NAME_ID_FORMAT_X509 - WINDOWS = SAML_NAME_ID_FORMAT_WINDOWS - TRANSIENT = SAML_NAME_ID_FORMAT_TRANSIENT - - -class SAMLSource(Source): - """Authenticate using an external SAML Identity Provider.""" - - issuer = models.TextField( - blank=True, - default=None, - verbose_name=_("Issuer"), - help_text=_("Also known as Entity ID. Defaults the Metadata URL."), - ) - - sso_url = models.URLField( - verbose_name=_("SSO URL"), - help_text=_("URL that the initial Login request is sent to."), - ) - slo_url = models.URLField( - default=None, - blank=True, - null=True, - verbose_name=_("SLO URL"), - help_text=_("Optional URL if your IDP supports Single-Logout."), - ) - - allow_idp_initiated = models.BooleanField( - default=False, - help_text=_( - "Allows authentication flows initiated by the IdP. This can be a security risk, " - "as no validation of the request ID is done." - ), - ) - name_id_policy = models.TextField( - choices=SAMLNameIDPolicy.choices, - default=SAMLNameIDPolicy.TRANSIENT, - help_text=_( - "NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent." - ), - ) - binding_type = models.CharField( - max_length=100, - choices=SAMLBindingTypes.choices, - default=SAMLBindingTypes.Redirect, - ) - - temporary_user_delete_after = models.TextField( - default="days=1", - verbose_name=_("Delete temporary users after"), - validators=[timedelta_string_validator], - help_text=_( - ( - "Time offset when temporary users should be deleted. This only applies if your IDP " - "uses the NameID Format 'transient', and the user doesn't log out manually. " - "(Format: hours=1;minutes=2;seconds=3)." - ) - ), - ) - - signing_kp = models.ForeignKey( - CertificateKeyPair, - default=None, - blank=True, - null=True, - verbose_name=_("Singing Keypair"), - help_text=_( - "Keypair which is used to sign outgoing requests. Leave empty to disable signing." - ), - on_delete=models.SET_DEFAULT, - ) - - digest_algorithm = models.CharField( - max_length=50, - choices=( - (SHA1, _("SHA1")), - (SHA256, _("SHA256")), - (SHA384, _("SHA384")), - (SHA512, _("SHA512")), - ), - default=SHA256, - ) - signature_algorithm = models.CharField( - max_length=50, - choices=( - (RSA_SHA1, _("RSA-SHA1")), - (RSA_SHA256, _("RSA-SHA256")), - (RSA_SHA384, _("RSA-SHA384")), - (RSA_SHA512, _("RSA-SHA512")), - (DSA_SHA1, _("DSA-SHA1")), - ), - default=RSA_SHA256, - ) - - @property - def form(self) -> Type[ModelForm]: - from passbook.sources.saml.forms import SAMLSourceForm - - return SAMLSourceForm - - def get_issuer(self, request: HttpRequest) -> str: - """Get Source's Issuer, falling back to our Metadata URL if none is set""" - if self.issuer is None: - return self.build_full_url(request, view="metadata") - return self.issuer - - def build_full_url(self, request: HttpRequest, view: str = "acs") -> str: - """Build Full ACS URL to be used in IDP""" - return request.build_absolute_uri( - reverse(f"passbook_sources_saml:{view}", kwargs={"source_slug": self.slug}) - ) - - @property - def ui_login_button(self) -> UILoginButton: - return UILoginButton( - name=self.name, - url=reverse_lazy( - "passbook_sources_saml:login", kwargs={"source_slug": self.slug} - ), - icon_path="", - ) - - @property - def ui_additional_info(self) -> str: - metadata_url = reverse_lazy( - "passbook_sources_saml:metadata", kwargs={"source_slug": self.slug} - ) - return f'Metadata Download' - - def __str__(self): - return f"SAML Source {self.name}" - - class Meta: - - verbose_name = _("SAML Source") - verbose_name_plural = _("SAML Sources") diff --git a/passbook/sources/saml/processors/metadata.py b/passbook/sources/saml/processors/metadata.py deleted file mode 100644 index d1314b89..00000000 --- a/passbook/sources/saml/processors/metadata.py +++ /dev/null @@ -1,93 +0,0 @@ -"""SAML Service Provider Metadata Processor""" -from typing import Iterator, Optional - -from django.http import HttpRequest -from lxml.etree import Element, SubElement, tostring # nosec - -from passbook.providers.saml.utils.encoding import strip_pem_header -from passbook.sources.saml.models import SAMLSource -from passbook.sources.saml.processors.constants import ( - NS_MAP, - NS_SAML_METADATA, - NS_SIGNATURE, - SAML_BINDING_POST, - SAML_NAME_ID_FORMAT_EMAIL, - SAML_NAME_ID_FORMAT_PERSISTENT, - SAML_NAME_ID_FORMAT_TRANSIENT, - SAML_NAME_ID_FORMAT_WINDOWS, - SAML_NAME_ID_FORMAT_X509, -) - - -class MetadataProcessor: - """SAML Service Provider Metadata Processor""" - - source: SAMLSource - http_request: HttpRequest - - def __init__(self, source: SAMLSource, request: HttpRequest): - self.source = source - self.http_request = request - - def get_signing_key_descriptor(self) -> Optional[Element]: - """Get Singing KeyDescriptor, if enabled for the source""" - if self.source.signing_kp: - key_descriptor = Element(f"{{{NS_SAML_METADATA}}}KeyDescriptor") - key_descriptor.attrib["use"] = "signing" - key_info = SubElement(key_descriptor, f"{{{NS_SIGNATURE}}}KeyInfo") - x509_data = SubElement(key_info, f"{{{NS_SIGNATURE}}}X509Data") - x509_certificate = SubElement( - x509_data, f"{{{NS_SIGNATURE}}}X509Certificate" - ) - x509_certificate.text = strip_pem_header( - self.source.signing_kp.certificate_data.replace("\r", "") - ).replace("\n", "") - return key_descriptor - return None - - def get_name_id_formats(self) -> Iterator[Element]: - """Get compatible NameID Formats""" - formats = [ - SAML_NAME_ID_FORMAT_EMAIL, - SAML_NAME_ID_FORMAT_PERSISTENT, - SAML_NAME_ID_FORMAT_X509, - SAML_NAME_ID_FORMAT_WINDOWS, - SAML_NAME_ID_FORMAT_TRANSIENT, - ] - for name_id_format in formats: - element = Element(f"{{{NS_SAML_METADATA}}}NameIDFormat") - element.text = name_id_format - yield element - - def build_entity_descriptor(self) -> str: - """Build full EntityDescriptor""" - entity_descriptor = Element( - f"{{{NS_SAML_METADATA}}}EntityDescriptor", nsmap=NS_MAP - ) - entity_descriptor.attrib["entityID"] = self.source.get_issuer(self.http_request) - - sp_sso_descriptor = SubElement( - entity_descriptor, f"{{{NS_SAML_METADATA}}}SPSSODescriptor" - ) - sp_sso_descriptor.attrib[ - "protocolSupportEnumeration" - ] = "urn:oasis:names:tc:SAML:2.0:protocol" - - signing_descriptor = self.get_signing_key_descriptor() - if signing_descriptor is not None: - sp_sso_descriptor.append(signing_descriptor) - - for name_id_format in self.get_name_id_formats(): - sp_sso_descriptor.append(name_id_format) - - assertion_consumer_service = SubElement( - sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}AssertionConsumerService" - ) - assertion_consumer_service.attrib["isDefault"] = "true" - assertion_consumer_service.attrib["index"] = "0" - assertion_consumer_service.attrib["Binding"] = SAML_BINDING_POST - assertion_consumer_service.attrib["Location"] = self.source.build_full_url( - self.http_request - ) - - return tostring(entity_descriptor).decode() diff --git a/passbook/sources/saml/processors/request.py b/passbook/sources/saml/processors/request.py deleted file mode 100644 index 243f15ec..00000000 --- a/passbook/sources/saml/processors/request.py +++ /dev/null @@ -1,172 +0,0 @@ -"""SAML AuthnRequest Processor""" -from base64 import b64encode -from typing import Dict -from urllib.parse import quote_plus - -import xmlsec -from django.http import HttpRequest -from lxml import etree # nosec -from lxml.etree import Element # nosec - -from passbook.providers.saml.utils import get_random_id -from passbook.providers.saml.utils.encoding import deflate_and_base64_encode -from passbook.providers.saml.utils.time import get_time_string -from passbook.sources.saml.models import SAMLSource -from passbook.sources.saml.processors.constants import ( - DIGEST_ALGORITHM_TRANSLATION_MAP, - NS_MAP, - NS_SAML_ASSERTION, - NS_SAML_PROTOCOL, - SIGN_ALGORITHM_TRANSFORM_MAP, -) - -SESSION_REQUEST_ID = "passbook_source_saml_request_id" - - -class RequestProcessor: - """SAML AuthnRequest Processor""" - - source: SAMLSource - http_request: HttpRequest - - relay_state: str - - request_id: str - issue_instant: str - - def __init__(self, source: SAMLSource, request: HttpRequest, relay_state: str): - self.source = source - self.http_request = request - self.relay_state = relay_state - self.request_id = get_random_id() - self.http_request.session[SESSION_REQUEST_ID] = self.request_id - self.issue_instant = get_time_string() - - def get_issuer(self) -> Element: - """Get Issuer Element""" - issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer") - issuer.text = self.source.get_issuer(self.http_request) - return issuer - - def get_name_id_policy(self) -> Element: - """Get NameID Policy Element""" - name_id_policy = Element(f"{{{NS_SAML_PROTOCOL}}}NameIDPolicy") - name_id_policy.attrib["Format"] = self.source.name_id_policy - return name_id_policy - - def get_auth_n(self) -> Element: - """Get full AuthnRequest""" - auth_n_request = Element(f"{{{NS_SAML_PROTOCOL}}}AuthnRequest", nsmap=NS_MAP) - auth_n_request.attrib[ - "AssertionConsumerServiceURL" - ] = self.source.build_full_url(self.http_request) - auth_n_request.attrib["Destination"] = self.source.sso_url - auth_n_request.attrib["ID"] = self.request_id - auth_n_request.attrib["IssueInstant"] = self.issue_instant - auth_n_request.attrib["ProtocolBinding"] = self.source.binding_type - auth_n_request.attrib["Version"] = "2.0" - # Create issuer object - auth_n_request.append(self.get_issuer()) - - if self.source.signing_kp: - sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get( - self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1 - ) - signature = xmlsec.template.create( - auth_n_request, - xmlsec.constants.TransformExclC14N, - sign_algorithm_transform, - ns="ds", # type: ignore - ) - auth_n_request.append(signature) - - # Create NameID Policy Object - auth_n_request.append(self.get_name_id_policy()) - return auth_n_request - - def build_auth_n(self) -> str: - """Get Signed string representation of AuthN Request - (used for POST Bindings)""" - auth_n_request = self.get_auth_n() - - if self.source.signing_kp: - xmlsec.tree.add_ids(auth_n_request, ["ID"]) - - ctx = xmlsec.SignatureContext() - - key = xmlsec.Key.from_memory( - self.source.signing_kp.key_data, xmlsec.constants.KeyDataFormatPem, None - ) - key.load_cert_from_memory( - self.source.signing_kp.certificate_data, - xmlsec.constants.KeyDataFormatCertPem, - ) - ctx.key = key - - digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get( - self.source.digest_algorithm, xmlsec.constants.TransformSha1 - ) - - signature_node = xmlsec.tree.find_node( - auth_n_request, xmlsec.constants.NodeSignature - ) - - ref = xmlsec.template.add_reference( - signature_node, - digest_algorithm_transform, - uri="#" + auth_n_request.attrib["ID"], - ) - xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped) - xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N) - key_info = xmlsec.template.ensure_key_info(signature_node) - xmlsec.template.add_x509_data(key_info) - - ctx.sign(signature_node) - - return etree.tostring(auth_n_request).decode() - - def build_auth_n_detached(self) -> Dict[str, str]: - """Get Dict AuthN Request for Redirect bindings, with detached - Signature. See https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf""" - auth_n_request = self.get_auth_n() - - saml_request = deflate_and_base64_encode( - etree.tostring(auth_n_request).decode() - ) - - response_dict = { - "SAMLRequest": saml_request, - } - - if self.relay_state != "": - response_dict["RelayState"] = self.relay_state - - if self.source.signing_kp: - sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get( - self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1 - ) - - # Create the full querystring in the correct order to be signed - querystring = f"SAMLRequest={quote_plus(saml_request)}&" - if "RelayState" in response_dict: - querystring += f"RelayState={quote_plus(response_dict['RelayState'])}&" - querystring += f"SigAlg={quote_plus(self.source.signature_algorithm)}" - - ctx = xmlsec.SignatureContext() - - key = xmlsec.Key.from_memory( - self.source.signing_kp.key_data, xmlsec.constants.KeyDataFormatPem, None - ) - key.load_cert_from_memory( - self.source.signing_kp.certificate_data, - xmlsec.constants.KeyDataFormatPem, - ) - ctx.key = key - - signature = ctx.sign_binary( - querystring.encode("utf-8"), sign_algorithm_transform - ) - response_dict["Signature"] = b64encode(signature).decode() - response_dict["SigAlg"] = self.source.signature_algorithm - - return response_dict diff --git a/passbook/sources/saml/processors/response.py b/passbook/sources/saml/processors/response.py deleted file mode 100644 index eda3a8ee..00000000 --- a/passbook/sources/saml/processors/response.py +++ /dev/null @@ -1,215 +0,0 @@ -"""passbook saml source processor""" -from base64 import b64decode -from typing import TYPE_CHECKING, Any, Dict - -import xmlsec -from defusedxml.lxml import fromstring -from django.core.cache import cache -from django.core.exceptions import SuspiciousOperation -from django.http import HttpRequest, HttpResponse -from structlog import get_logger - -from passbook.core.models import User -from passbook.flows.models import Flow -from passbook.flows.planner import ( - PLAN_CONTEXT_PENDING_USER, - PLAN_CONTEXT_SSO, - FlowPlanner, -) -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.lib.utils.urls import redirect_with_qs -from passbook.policies.utils import delete_none_keys -from passbook.sources.saml.exceptions import ( - InvalidSignature, - MismatchedRequestID, - MissingSAMLResponse, - UnsupportedNameIDFormat, -) -from passbook.sources.saml.models import SAMLSource -from passbook.sources.saml.processors.constants import ( - NS_MAP, - SAML_NAME_ID_FORMAT_EMAIL, - SAML_NAME_ID_FORMAT_PERSISTENT, - SAML_NAME_ID_FORMAT_TRANSIENT, - SAML_NAME_ID_FORMAT_WINDOWS, - SAML_NAME_ID_FORMAT_X509, -) -from passbook.sources.saml.processors.request import SESSION_REQUEST_ID -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT - -LOGGER = get_logger() -if TYPE_CHECKING: - from xml.etree.ElementTree import Element # nosec - -CACHE_SEEN_REQUEST_ID = "passbook_saml_seen_ids_%s" -DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend" - - -class ResponseProcessor: - """SAML Response Processor""" - - _source: SAMLSource - - _root: Any - _root_xml: str - - def __init__(self, source: SAMLSource): - self._source = source - - def parse(self, request: HttpRequest): - """Check if `request` contains SAML Response data, parse and validate it.""" - # First off, check if we have any SAML Data at all. - raw_response = request.POST.get("SAMLResponse", None) - if not raw_response: - raise MissingSAMLResponse("Request does not contain 'SAMLResponse'") - # Check if response is compressed, b64 decode it - self._root_xml = b64decode(raw_response.encode()).decode() - self._root = fromstring(self._root_xml) - - if self._source.signing_kp: - self._verify_signed() - self._verify_request_id(request) - - def _verify_signed(self): - """Verify SAML Response's Signature""" - signature_nodes = self._root.xpath( - "/samlp:Response/saml:Assertion/ds:Signature", namespaces=NS_MAP - ) - if len(signature_nodes) != 1: - raise InvalidSignature() - signature_node = signature_nodes[0] - xmlsec.tree.add_ids(self._root, ["ID"]) - - ctx = xmlsec.SignatureContext() - key = xmlsec.Key.from_memory( - self._source.signing_kp.certificate_data, - xmlsec.constants.KeyDataFormatCertPem, - ) - ctx.key = key - - ctx.set_enabled_key_data([xmlsec.constants.KeyDataX509]) - try: - ctx.verify(signature_node) - except (xmlsec.InternalError, xmlsec.VerificationError) as exc: - raise InvalidSignature from exc - LOGGER.debug("Successfully verified signautre") - - def _verify_request_id(self, request: HttpRequest): - if self._source.allow_idp_initiated: - # If IdP-initiated SSO flows are enabled, we want to cache the Response ID - # somewhat mitigate replay attacks - seen_ids = cache.get(CACHE_SEEN_REQUEST_ID % self._source.pk, []) - if self._root.attrib["ID"] in seen_ids: - raise SuspiciousOperation("Replay attack detected") - seen_ids.append(self._root.attrib["ID"]) - cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids) - return - if ( - SESSION_REQUEST_ID not in request.session - or "InResponseTo" not in self._root.attrib - ): - raise MismatchedRequestID( - "Missing InResponseTo and IdP-initiated Logins are not allowed" - ) - if request.session[SESSION_REQUEST_ID] != self._root.attrib["InResponseTo"]: - raise MismatchedRequestID("Mismatched request ID") - - def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse: - """Handle a NameID with the Format of Transient. This is a bit more complex than other - formats, as we need to create a temporary User that is used in the session. This - user has an attribute that refers to our Source for cleanup. The user is also deleted - on logout and periodically.""" - # Create a temporary User - name_id = self._get_name_id().text - user: User = User.objects.create( - username=name_id, - attributes={ - "saml": {"source": self._source.pk.hex, "delete_on_logout": True} - }, - ) - LOGGER.debug("Created temporary user for NameID Transient", username=name_id) - user.set_unusable_password() - user.save() - return self._flow_response( - request, - self._source.authentication_flow, - **{ - PLAN_CONTEXT_PENDING_USER: user, - PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND, - }, - ) - - def _get_name_id(self) -> "Element": - """Get NameID Element""" - assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion") - subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject") - name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID") - if name_id is None: - raise ValueError("NameID Element not found!") - return name_id - - def _get_name_id_filter(self) -> Dict[str, str]: - """Returns the subject's NameID as a Filter for the `User`""" - name_id_el = self._get_name_id() - name_id = name_id_el.text - if not name_id: - raise UnsupportedNameIDFormat("Subject's NameID is empty.") - _format = name_id_el.attrib["Format"] - if _format == SAML_NAME_ID_FORMAT_EMAIL: - return {"email": name_id} - if _format == SAML_NAME_ID_FORMAT_PERSISTENT: - return {"username": name_id} - if _format == SAML_NAME_ID_FORMAT_X509: - # This attribute is statically set by the LDAP source - return {"attributes__distinguishedName": name_id} - if _format == SAML_NAME_ID_FORMAT_WINDOWS: - if "\\" in name_id: - name_id = name_id.split("\\")[1] - return {"username": name_id} - raise UnsupportedNameIDFormat( - f"Assertion contains NameID with unsupported format {_format}." - ) - - def prepare_flow(self, request: HttpRequest) -> HttpResponse: - """Prepare flow plan depending on whether or not the user exists""" - name_id = self._get_name_id() - # Sanity check, show a warning if NameIDPolicy doesn't match what we go - if self._source.name_id_policy != name_id.attrib["Format"]: - LOGGER.warning( - "NameID from IdP doesn't match our policy", - expected=self._source.name_id_policy, - got=name_id.attrib["Format"], - ) - # transient NameIDs are handeled seperately as they don't have to go through flows. - if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: - return self._handle_name_id_transient(request) - - name_id_filter = self._get_name_id_filter() - matching_users = User.objects.filter(**name_id_filter) - if matching_users.exists(): - # User exists already, switch to authentication flow - return self._flow_response( - request, - self._source.authentication_flow, - **{ - PLAN_CONTEXT_PENDING_USER: matching_users.first(), - PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND, - }, - ) - return self._flow_response( - request, - self._source.enrollment_flow, - **{PLAN_CONTEXT_PROMPT: delete_none_keys(name_id_filter)}, - ) - - def _flow_response( - self, request: HttpRequest, flow: Flow, **kwargs - ) -> HttpResponse: - kwargs[PLAN_CONTEXT_SSO] = True - request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs) - return redirect_with_qs( - "passbook_flows:flow-executor-shell", - request.GET, - flow_slug=flow.slug, - ) diff --git a/passbook/sources/saml/settings.py b/passbook/sources/saml/settings.py deleted file mode 100644 index fcfda168..00000000 --- a/passbook/sources/saml/settings.py +++ /dev/null @@ -1,10 +0,0 @@ -"""saml source settings""" -from celery.schedules import crontab - -CELERY_BEAT_SCHEDULE = { - "saml_source_cleanup": { - "task": "passbook.sources.saml.tasks.clean_temporary_users", - "schedule": crontab(minute="*/5"), - "options": {"queue": "passbook_scheduled"}, - } -} diff --git a/passbook/sources/saml/signals.py b/passbook/sources/saml/signals.py deleted file mode 100644 index 888560f8..00000000 --- a/passbook/sources/saml/signals.py +++ /dev/null @@ -1,22 +0,0 @@ -"""passbook saml source signal listener""" -from django.contrib.auth.signals import user_logged_out -from django.dispatch import receiver -from django.http import HttpRequest -from structlog import get_logger - -from passbook.core.models import User - -LOGGER = get_logger() - - -@receiver(user_logged_out) -# pylint: disable=unused-argument -def on_user_logged_out(sender, request: HttpRequest, user: User, **_): - """Delete temporary user if the `delete_on_logout` flag is enabled""" - if not user: - return - if "saml" in user.attributes: - if "delete_on_logout" in user.attributes["saml"]: - if user.attributes["saml"]["delete_on_logout"]: - LOGGER.debug("Deleted temporary user", user=user) - user.delete() diff --git a/passbook/sources/saml/tasks.py b/passbook/sources/saml/tasks.py deleted file mode 100644 index 3fc426f9..00000000 --- a/passbook/sources/saml/tasks.py +++ /dev/null @@ -1,42 +0,0 @@ -"""passbook saml source tasks""" -from django.utils.timezone import now -from structlog import get_logger - -from passbook.core.models import User -from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus -from passbook.lib.utils.time import timedelta_from_string -from passbook.root.celery import CELERY_APP -from passbook.sources.saml.models import SAMLSource - -LOGGER = get_logger() - - -@CELERY_APP.task(bind=True, base=MonitoredTask) -def clean_temporary_users(self: MonitoredTask): - """Remove temporary users created by SAML Sources""" - _now = now() - messages = [] - deleted_users = 0 - for user in User.objects.filter(attributes__saml__isnull=False): - sources = SAMLSource.objects.filter( - pk=user.attributes.get("saml", {}).get("source", "") - ) - if not sources.exists(): - LOGGER.warning( - "User has an invalid SAML Source and won't be deleted!", user=user - ) - messages.append( - f"User {user} has an invalid SAML Source and won't be deleted!" - ) - continue - source = sources.first() - source_delta = timedelta_from_string(source.temporary_user_delete_after) - if _now - user.last_login >= source_delta: - LOGGER.debug( - "User is expired and will be deleted.", user=user, delta=source_delta - ) - # TODO: Check if user is signed in anywhere? - user.delete() - deleted_users += 1 - messages.append(f"Successfully deleted {deleted_users} users.") - self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) diff --git a/passbook/sources/saml/templates/saml/sp/login.html b/passbook/sources/saml/templates/saml/sp/login.html deleted file mode 100644 index 9edaf9d4..00000000 --- a/passbook/sources/saml/templates/saml/sp/login.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "login/base_full.html" %} - -{% load passbook_utils %} -{% load i18n %} - -{% block title %} -{% trans 'Authorize Application' %} -{% endblock %} - -{% block card %} -
- {% csrf_token %} - - - -
- -
-
-{% endblock %} diff --git a/passbook/sources/saml/tests.py b/passbook/sources/saml/tests.py deleted file mode 100644 index c5bc7b28..00000000 --- a/passbook/sources/saml/tests.py +++ /dev/null @@ -1,26 +0,0 @@ -"""SAML Source tests""" -from defusedxml import ElementTree -from django.test import RequestFactory, TestCase - -from passbook.crypto.models import CertificateKeyPair -from passbook.sources.saml.models import SAMLSource -from passbook.sources.saml.processors.metadata import MetadataProcessor - - -class TestMetadataProcessor(TestCase): - """Test MetadataProcessor""" - - def setUp(self): - self.source = SAMLSource.objects.create( - slug="provider", - issuer="passbook", - signing_kp=CertificateKeyPair.objects.first(), - ) - self.factory = RequestFactory() - - def test_metadata(self): - """Test Metadata generation being valid""" - request = self.factory.get("/") - xml = MetadataProcessor(self.source, request).build_entity_descriptor() - metadata = ElementTree.fromstring(xml) - self.assertEqual(metadata.attrib["entityID"], "passbook") diff --git a/passbook/sources/saml/urls.py b/passbook/sources/saml/urls.py deleted file mode 100644 index 22b0a8fd..00000000 --- a/passbook/sources/saml/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""saml sp urls""" -from django.urls import path - -from passbook.sources.saml.views import ACSView, InitiateView, MetadataView, SLOView - -urlpatterns = [ - path("/", InitiateView.as_view(), name="login"), - path("/acs/", ACSView.as_view(), name="acs"), - path("/slo/", SLOView.as_view(), name="slo"), - path("/metadata/", MetadataView.as_view(), name="metadata"), -] diff --git a/passbook/sources/saml/views.py b/passbook/sources/saml/views.py deleted file mode 100644 index 424423a7..00000000 --- a/passbook/sources/saml/views.py +++ /dev/null @@ -1,108 +0,0 @@ -"""saml sp views""" -from django.contrib.auth import logout -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import Http404, HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.utils.decorators import method_decorator -from django.utils.http import urlencode -from django.utils.translation import gettext_lazy as _ -from django.views import View -from django.views.decorators.csrf import csrf_exempt -from xmlsec import VerificationError - -from passbook.lib.views import bad_request_message -from passbook.providers.saml.utils.encoding import nice64 -from passbook.sources.saml.exceptions import ( - MissingSAMLResponse, - UnsupportedNameIDFormat, -) -from passbook.sources.saml.models import SAMLBindingTypes, SAMLSource -from passbook.sources.saml.processors.metadata import MetadataProcessor -from passbook.sources.saml.processors.request import RequestProcessor -from passbook.sources.saml.processors.response import ResponseProcessor - - -class InitiateView(View): - """Get the Form with SAML Request, which sends us to the IDP""" - - def get(self, request: HttpRequest, source_slug: str) -> HttpResponse: - """Replies with an XHTML SSO Request.""" - source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) - if not source.enabled: - raise Http404 - relay_state = request.GET.get("next", "") - auth_n_req = RequestProcessor(source, request, relay_state) - # If the source is configured for Redirect bindings, we can just redirect there - if source.binding_type == SAMLBindingTypes.Redirect: - url_args = urlencode(auth_n_req.build_auth_n_detached()) - return redirect(f"{source.sso_url}?{url_args}") - # As POST Binding we show a form - saml_request = nice64(auth_n_req.build_auth_n()) - if source.binding_type == SAMLBindingTypes.POST: - return render( - request, - "saml/sp/login.html", - { - "request_url": source.sso_url, - "request": saml_request, - "relay_state": relay_state, - "source": source, - }, - ) - # Or an auto-submit form - if source.binding_type == SAMLBindingTypes.POST_AUTO: - return render( - request, - "generic/autosubmit_form_full.html", - { - "title": _("Redirecting to %(app)s..." % {"app": source.name}), - "attrs": {"SAMLRequest": saml_request, "RelayState": relay_state}, - "url": source.sso_url, - }, - ) - raise Http404 - - -@method_decorator(csrf_exempt, name="dispatch") -class ACSView(View): - """AssertionConsumerService, consume assertion and log user in""" - - def post(self, request: HttpRequest, source_slug: str) -> HttpResponse: - """Handles a POSTed SSO Assertion and logs the user in.""" - source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) - if not source.enabled: - raise Http404 - processor = ResponseProcessor(source) - try: - processor.parse(request) - except MissingSAMLResponse as exc: - return bad_request_message(request, str(exc)) - except VerificationError as exc: - return bad_request_message(request, str(exc)) - - try: - return processor.prepare_flow(request) - except UnsupportedNameIDFormat as exc: - return bad_request_message(request, str(exc)) - - -class SLOView(LoginRequiredMixin, View): - """Single-Logout-View""" - - def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse: - """Log user out and redirect them to the IdP's SLO URL.""" - source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) - if not source.enabled: - raise Http404 - logout(request) - return redirect(source.slo_url) - - -class MetadataView(View): - """Return XML Metadata for IDP""" - - def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse: - """Replies with the XML Metadata SPSSODescriptor.""" - source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) - metadata = MetadataProcessor(source, request).build_entity_descriptor() - return HttpResponse(metadata, content_type="text/xml") diff --git a/passbook/stages/captcha/api.py b/passbook/stages/captcha/api.py deleted file mode 100644 index 6357fc96..00000000 --- a/passbook/stages/captcha/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""CaptchaStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.captcha.models import CaptchaStage - - -class CaptchaStageSerializer(ModelSerializer): - """CaptchaStage Serializer""" - - class Meta: - - model = CaptchaStage - fields = ["pk", "name", "public_key", "private_key"] - - -class CaptchaStageViewSet(ModelViewSet): - """CaptchaStage Viewset""" - - queryset = CaptchaStage.objects.all() - serializer_class = CaptchaStageSerializer diff --git a/passbook/stages/captcha/apps.py b/passbook/stages/captcha/apps.py deleted file mode 100644 index ba5c4ba7..00000000 --- a/passbook/stages/captcha/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook captcha app""" -from django.apps import AppConfig - - -class PassbookStageCaptchaConfig(AppConfig): - """passbook captcha app""" - - name = "passbook.stages.captcha" - label = "passbook_stages_captcha" - verbose_name = "passbook Stages.Captcha" diff --git a/passbook/stages/captcha/forms.py b/passbook/stages/captcha/forms.py deleted file mode 100644 index 892942a4..00000000 --- a/passbook/stages/captcha/forms.py +++ /dev/null @@ -1,25 +0,0 @@ -"""passbook captcha stage forms""" -from captcha.fields import ReCaptchaField -from django import forms - -from passbook.stages.captcha.models import CaptchaStage - - -class CaptchaForm(forms.Form): - """passbook captcha stage form""" - - captcha = ReCaptchaField() - - -class CaptchaStageForm(forms.ModelForm): - """Form to edit CaptchaStage Instance""" - - class Meta: - - model = CaptchaStage - fields = ["name", "public_key", "private_key"] - widgets = { - "name": forms.TextInput(), - "public_key": forms.TextInput(), - "private_key": forms.TextInput(), - } diff --git a/passbook/stages/captcha/migrations/0001_initial.py b/passbook/stages/captcha/migrations/0001_initial.py deleted file mode 100644 index 4e566adf..00000000 --- a/passbook/stages/captcha/migrations/0001_initial.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="CaptchaStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ( - "public_key", - models.TextField( - help_text="Public key, acquired from https://www.google.com/recaptcha/intro/v3.html" - ), - ), - ( - "private_key", - models.TextField( - help_text="Private key, acquired from https://www.google.com/recaptcha/intro/v3.html" - ), - ), - ], - options={ - "verbose_name": "Captcha Stage", - "verbose_name_plural": "Captcha Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/captcha/models.py b/passbook/stages/captcha/models.py deleted file mode 100644 index cbe56e26..00000000 --- a/passbook/stages/captcha/models.py +++ /dev/null @@ -1,51 +0,0 @@ -"""passbook captcha 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 passbook.flows.models import Stage - - -class CaptchaStage(Stage): - """Verify the user is human using Google's reCaptcha.""" - - public_key = models.TextField( - help_text=_( - "Public key, acquired from https://www.google.com/recaptcha/intro/v3.html" - ) - ) - private_key = models.TextField( - help_text=_( - "Private key, acquired from https://www.google.com/recaptcha/intro/v3.html" - ) - ) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.captcha.api import CaptchaStageSerializer - - return CaptchaStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.captcha.stage import CaptchaStageView - - return CaptchaStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.captcha.forms import CaptchaStageForm - - return CaptchaStageForm - - def __str__(self): - return f"Captcha Stage {self.name}" - - class Meta: - - verbose_name = _("Captcha Stage") - verbose_name_plural = _("Captcha Stages") diff --git a/passbook/stages/captcha/settings.py b/passbook/stages/captcha/settings.py deleted file mode 100644 index 33a65906..00000000 --- a/passbook/stages/captcha/settings.py +++ /dev/null @@ -1,9 +0,0 @@ -"""passbook captcha stage settings""" -# https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do -RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" -RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" - -NOCAPTCHA = True -INSTALLED_APPS = ["captcha"] - -SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"] diff --git a/passbook/stages/captcha/stage.py b/passbook/stages/captcha/stage.py deleted file mode 100644 index f758506e..00000000 --- a/passbook/stages/captcha/stage.py +++ /dev/null @@ -1,24 +0,0 @@ -"""passbook captcha stage""" - -from django.views.generic import FormView - -from passbook.flows.stage import StageView -from passbook.stages.captcha.forms import CaptchaForm - - -class CaptchaStageView(FormView, StageView): - """Simple captcha checker, logic is handeled in django-captcha module""" - - form_class = CaptchaForm - - def form_valid(self, form): - return self.executor.stage_ok() - - def get_form(self, form_class=None): - form = CaptchaForm(**self.get_form_kwargs()) - form.fields["captcha"].public_key = self.executor.current_stage.public_key - form.fields["captcha"].private_key = self.executor.current_stage.private_key - form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[ - "captcha" - ].public_key - return form diff --git a/passbook/stages/captcha/tests.py b/passbook/stages/captcha/tests.py deleted file mode 100644 index 1eeec13d..00000000 --- a/passbook/stages/captcha/tests.py +++ /dev/null @@ -1,55 +0,0 @@ -"""captcha tests""" -from django.conf import settings -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import FlowPlan -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.stages.captcha.models import CaptchaStage - - -class TestCaptchaStage(TestCase): - """Captcha tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create_user( - username="unittest", email="test@beryju.org" - ) - self.client = Client() - - self.flow = Flow.objects.create( - name="test-captcha", - slug="test-captcha", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = CaptchaStage.objects.create( - name="captcha", - public_key=settings.RECAPTCHA_PUBLIC_KEY, - private_key=settings.RECAPTCHA_PRIVATE_KEY, - ) - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_valid(self): - """Test valid captcha""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - {"g-recaptcha-response": "PASSED"}, - ) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) diff --git a/passbook/stages/consent/api.py b/passbook/stages/consent/api.py deleted file mode 100644 index f0623dd7..00000000 --- a/passbook/stages/consent/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""ConsentStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.consent.models import ConsentStage - - -class ConsentStageSerializer(ModelSerializer): - """ConsentStage Serializer""" - - class Meta: - - model = ConsentStage - fields = ["pk", "name", "mode", "consent_expire_in"] - - -class ConsentStageViewSet(ModelViewSet): - """ConsentStage Viewset""" - - queryset = ConsentStage.objects.all() - serializer_class = ConsentStageSerializer diff --git a/passbook/stages/consent/apps.py b/passbook/stages/consent/apps.py deleted file mode 100644 index cba755dc..00000000 --- a/passbook/stages/consent/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook consent app""" -from django.apps import AppConfig - - -class PassbookStageConsentConfig(AppConfig): - """passbook consent app""" - - name = "passbook.stages.consent" - label = "passbook_stages_consent" - verbose_name = "passbook Stages.Consent" diff --git a/passbook/stages/consent/forms.py b/passbook/stages/consent/forms.py deleted file mode 100644 index d716490a..00000000 --- a/passbook/stages/consent/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -"""passbook consent stage forms""" -from django import forms - -from passbook.stages.consent.models import ConsentStage - - -class ConsentForm(forms.Form): - """passbook consent stage form""" - - -class ConsentStageForm(forms.ModelForm): - """Form to edit ConsentStage Instance""" - - class Meta: - - model = ConsentStage - fields = ["name", "mode", "consent_expire_in"] - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/consent/migrations/0001_initial.py b/passbook/stages/consent/migrations/0001_initial.py deleted file mode 100644 index 9813f901..00000000 --- a/passbook/stages/consent/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-24 11:46 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0007_auto_20200703_2059"), - ] - - operations = [ - migrations.CreateModel( - name="ConsentStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ], - options={ - "verbose_name": "Consent Stage", - "verbose_name_plural": "Consent Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/consent/migrations/0002_auto_20200720_0941.py b/passbook/stages/consent/migrations/0002_auto_20200720_0941.py deleted file mode 100644 index 4849be78..00000000 --- a/passbook/stages/consent/migrations/0002_auto_20200720_0941.py +++ /dev/null @@ -1,83 +0,0 @@ -# Generated by Django 3.0.8 on 2020-07-20 09:41 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -import passbook.core.models -import passbook.lib.utils.time - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_core", "0006_auto_20200709_1608"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("passbook_stages_consent", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="consentstage", - name="consent_expire_in", - field=models.TextField( - default="weeks=4", - help_text="Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3).", - validators=[passbook.lib.utils.time.timedelta_string_validator], - verbose_name="Consent expires in", - ), - ), - migrations.AddField( - model_name="consentstage", - name="mode", - field=models.TextField( - choices=[ - ("always_require", "Always Require"), - ("permanent", "Permanent"), - ("expiring", "Expiring"), - ], - default="always_require", - ), - ), - migrations.CreateModel( - name="UserConsent", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "expires", - models.DateTimeField( - default=passbook.core.models.default_token_duration - ), - ), - ("expiring", models.BooleanField(default=True)), - ( - "application", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="passbook_core.Application", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="pb_consent", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "User Consent", - "verbose_name_plural": "User Consents", - "unique_together": {("user", "application")}, - }, - ), - ] diff --git a/passbook/stages/consent/migrations/0003_auto_20200924_1403.py b/passbook/stages/consent/migrations/0003_auto_20200924_1403.py deleted file mode 100644 index 6ce477a7..00000000 --- a/passbook/stages/consent/migrations/0003_auto_20200924_1403.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-24 14:03 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("passbook_stages_consent", "0002_auto_20200720_0941"), - ] - - operations = [ - migrations.AlterField( - model_name="userconsent", - name="user", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL - ), - ), - ] diff --git a/passbook/stages/consent/models.py b/passbook/stages/consent/models.py deleted file mode 100644 index b74d5b43..00000000 --- a/passbook/stages/consent/models.py +++ /dev/null @@ -1,81 +0,0 @@ -"""passbook consent 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 passbook.core.models import Application, ExpiringModel, User -from passbook.flows.models import Stage -from passbook.lib.utils.time import timedelta_string_validator - - -class ConsentMode(models.TextChoices): - """Modes a Consent Stage can operate in""" - - ALWAYS_REQUIRE = "always_require" - PERMANENT = "permanent" - EXPIRING = "expiring" - - -class ConsentStage(Stage): - """Prompt the user for confirmation.""" - - mode = models.TextField( - choices=ConsentMode.choices, default=ConsentMode.ALWAYS_REQUIRE - ) - consent_expire_in = models.TextField( - validators=[timedelta_string_validator], - default="weeks=4", - verbose_name="Consent expires in", - help_text=_( - ( - "Offset after which consent expires. " - "(Format: hours=1;minutes=2;seconds=3)." - ) - ), - ) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.consent.api import ConsentStageSerializer - - return ConsentStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.consent.stage import ConsentStageView - - return ConsentStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.consent.forms import ConsentStageForm - - return ConsentStageForm - - def __str__(self): - return f"Consent Stage {self.name}" - - class Meta: - - verbose_name = _("Consent Stage") - verbose_name_plural = _("Consent Stages") - - -class UserConsent(ExpiringModel): - """Consent given by a user for an application""" - - user = models.ForeignKey(User, on_delete=models.CASCADE) - application = models.ForeignKey(Application, on_delete=models.CASCADE) - - def __str__(self): - return f"User Consent {self.application} by {self.user}" - - class Meta: - - unique_together = (("user", "application"),) - verbose_name = _("User Consent") - verbose_name_plural = _("User Consents") diff --git a/passbook/stages/consent/stage.py b/passbook/stages/consent/stage.py deleted file mode 100644 index 160ab1a8..00000000 --- a/passbook/stages/consent/stage.py +++ /dev/null @@ -1,73 +0,0 @@ -"""passbook consent stage""" -from typing import Any, Dict, List - -from django.http import HttpRequest, HttpResponse -from django.utils.timezone import now -from django.views.generic import FormView - -from passbook.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.lib.utils.time import timedelta_from_string -from passbook.stages.consent.forms import ConsentForm -from passbook.stages.consent.models import ConsentMode, ConsentStage, UserConsent - -PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template" - - -class ConsentStageView(FormView, StageView): - """Simple consent checker.""" - - form_class = ConsentForm - - def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - kwargs["current_stage"] = self.executor.current_stage - kwargs["context"] = self.executor.plan.context - return kwargs - - def get_template_names(self) -> List[str]: - # PLAN_CONTEXT_CONSENT_TEMPLATE has to be set by a template that calls this stage - if PLAN_CONTEXT_CONSENT_TEMPLATE in self.executor.plan.context: - template_name = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TEMPLATE] - return [template_name] - return ["stages/consent/fallback.html"] - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - current_stage: ConsentStage = self.executor.current_stage - # For always require, we always show the form - if current_stage.mode == ConsentMode.ALWAYS_REQUIRE: - return super().get(request, *args, **kwargs) - # at this point we need to check consent from database - if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: - # No application in this plan, hence we can't check DB and require user consent - return super().get(request, *args, **kwargs) - - application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] - - user = self.request.user - if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: - user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - - if UserConsent.filter_not_expired(user=user, application=application).exists(): - return self.executor.stage_ok() - - # No consent found, show form - return super().get(request, *args, **kwargs) - - def form_valid(self, form: ConsentForm) -> HttpResponse: - current_stage: ConsentStage = self.executor.current_stage - if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: - return self.executor.stage_ok() - application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] - # Since we only get here when no consent exists, we can create it without update - if current_stage.mode == ConsentMode.PERMANENT: - UserConsent.objects.create( - user=self.request.user, application=application, expiring=False - ) - if current_stage.mode == ConsentMode.EXPIRING: - UserConsent.objects.create( - user=self.request.user, - application=application, - expires=now() + timedelta_from_string(current_stage.consent_expire_in), - ) - return self.executor.stage_ok() diff --git a/passbook/stages/consent/tests.py b/passbook/stages/consent/tests.py deleted file mode 100644 index 33d0eb3c..00000000 --- a/passbook/stages/consent/tests.py +++ /dev/null @@ -1,135 +0,0 @@ -"""consent tests""" -from time import sleep - -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import Application, User -from passbook.core.tasks import clean_expired_models -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlan -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.stages.consent.models import ConsentMode, ConsentStage, UserConsent - - -class TestConsentStage(TestCase): - """Consent tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create_user( - username="unittest", email="test@beryju.org" - ) - self.application = Application.objects.create( - name="test-application", - slug="test-application", - ) - self.client = Client() - - def test_always_required(self): - """Test always required consent""" - flow = Flow.objects.create( - name="test-consent", - slug="test-consent", - designation=FlowDesignation.AUTHENTICATION, - ) - stage = ConsentStage.objects.create( - name="consent", mode=ConsentMode.ALWAYS_REQUIRE - ) - FlowStageBinding.objects.create(target=flow, stage=stage, order=2) - - plan = FlowPlan(flow_pk=flow.pk.hex, stages=[stage], markers=[StageMarker()]) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - response = self.client.post( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, - ) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) - - def test_permanent(self): - """Test permanent consent from user""" - self.client.force_login(self.user) - flow = Flow.objects.create( - name="test-consent", - slug="test-consent", - designation=FlowDesignation.AUTHENTICATION, - ) - stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) - FlowStageBinding.objects.create(target=flow, stage=stage, order=2) - - plan = FlowPlan( - flow_pk=flow.pk.hex, - stages=[stage], - markers=[StageMarker()], - context={PLAN_CONTEXT_APPLICATION: self.application}, - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - response = self.client.post( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, - ) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - self.assertTrue( - UserConsent.objects.filter( - user=self.user, application=self.application - ).exists() - ) - - def test_expire(self): - """Test expiring consent from user""" - self.client.force_login(self.user) - flow = Flow.objects.create( - name="test-consent", - slug="test-consent", - designation=FlowDesignation.AUTHENTICATION, - ) - stage = ConsentStage.objects.create( - name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" - ) - FlowStageBinding.objects.create(target=flow, stage=stage, order=2) - - plan = FlowPlan( - flow_pk=flow.pk.hex, - stages=[stage], - markers=[StageMarker()], - context={PLAN_CONTEXT_APPLICATION: self.application}, - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - response = self.client.post( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, - ) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - self.assertTrue( - UserConsent.objects.filter( - user=self.user, application=self.application - ).exists() - ) - sleep(1) - clean_expired_models.delay().get() - self.assertFalse( - UserConsent.objects.filter( - user=self.user, application=self.application - ).exists() - ) diff --git a/passbook/stages/dummy/api.py b/passbook/stages/dummy/api.py deleted file mode 100644 index 53235a3b..00000000 --- a/passbook/stages/dummy/api.py +++ /dev/null @@ -1,21 +0,0 @@ -"""DummyStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.dummy.models import DummyStage - - -class DummyStageSerializer(ModelSerializer): - """DummyStage Serializer""" - - class Meta: - - model = DummyStage - fields = ["pk", "name"] - - -class DummyStageViewSet(ModelViewSet): - """DummyStage Viewset""" - - queryset = DummyStage.objects.all() - serializer_class = DummyStageSerializer diff --git a/passbook/stages/dummy/apps.py b/passbook/stages/dummy/apps.py deleted file mode 100644 index 5b68b945..00000000 --- a/passbook/stages/dummy/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook dummy stage config""" - -from django.apps import AppConfig - - -class PassbookStageDummyConfig(AppConfig): - """passbook dummy stage config""" - - name = "passbook.stages.dummy" - label = "passbook_stages_dummy" - verbose_name = "passbook Stages.Dummy" diff --git a/passbook/stages/dummy/forms.py b/passbook/stages/dummy/forms.py deleted file mode 100644 index f611611f..00000000 --- a/passbook/stages/dummy/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""passbook administration forms""" -from django import forms - -from passbook.stages.dummy.models import DummyStage - - -class DummyStageForm(forms.ModelForm): - """Form to create/edit Dummy Stage""" - - class Meta: - - model = DummyStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/dummy/migrations/0001_initial.py b/passbook/stages/dummy/migrations/0001_initial.py deleted file mode 100644 index c4ee2d38..00000000 --- a/passbook/stages/dummy/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="DummyStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ], - options={ - "verbose_name": "Dummy Stage", - "verbose_name_plural": "Dummy Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/dummy/models.py b/passbook/stages/dummy/models.py deleted file mode 100644 index 724bd29d..00000000 --- a/passbook/stages/dummy/models.py +++ /dev/null @@ -1,41 +0,0 @@ -"""dummy stage models""" -from typing import Type - -from django.forms import ModelForm -from django.utils.translation import gettext as _ -from django.views import View -from rest_framework.serializers import BaseSerializer - -from passbook.flows.models import Stage - - -class DummyStage(Stage): - """Used for debugging.""" - - __debug_only__ = True - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.dummy.api import DummyStageSerializer - - return DummyStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.dummy.stage import DummyStageView - - return DummyStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.dummy.forms import DummyStageForm - - return DummyStageForm - - def __str__(self): - return f"Dummy Stage {self.name}" - - class Meta: - - verbose_name = _("Dummy Stage") - verbose_name_plural = _("Dummy Stages") diff --git a/passbook/stages/dummy/stage.py b/passbook/stages/dummy/stage.py deleted file mode 100644 index bb0620ce..00000000 --- a/passbook/stages/dummy/stage.py +++ /dev/null @@ -1,19 +0,0 @@ -"""passbook multi-stage authentication engine""" -from typing import Any, Dict - -from django.http import HttpRequest - -from passbook.flows.stage import StageView - - -class DummyStageView(StageView): - """Dummy stage for testing with multiple stages""" - - def post(self, request: HttpRequest): - """Just redirect to next stage""" - return self.executor.stage_ok() - - def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: - kwargs = super().get_context_data(**kwargs) - kwargs["title"] = self.executor.current_stage.name - return kwargs diff --git a/passbook/stages/dummy/tests.py b/passbook/stages/dummy/tests.py deleted file mode 100644 index c87123e6..00000000 --- a/passbook/stages/dummy/tests.py +++ /dev/null @@ -1,58 +0,0 @@ -"""dummy tests""" -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.stages.dummy.forms import DummyStageForm -from passbook.stages.dummy.models import DummyStage - - -class TestDummyStage(TestCase): - """Dummy tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create(username="unittest", email="test@beryju.org") - self.client = Client() - - self.flow = Flow.objects.create( - name="test-dummy", - slug="test-dummy", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = DummyStage.objects.create( - name="dummy", - ) - FlowStageBinding.objects.create( - target=self.flow, - stage=self.stage, - order=0, - ) - - def test_valid_render(self): - """Test that View renders correctly""" - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 200) - - def test_post(self): - """Test with valid email, check that URL redirects back to itself""" - url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - response = self.client.post(url, {}) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(DummyStageForm(data).is_valid(), True) diff --git a/passbook/stages/email/api.py b/passbook/stages/email/api.py deleted file mode 100644 index 22dc15ca..00000000 --- a/passbook/stages/email/api.py +++ /dev/null @@ -1,36 +0,0 @@ -"""EmailStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.email.models import EmailStage - - -class EmailStageSerializer(ModelSerializer): - """EmailStage Serializer""" - - class Meta: - - model = EmailStage - fields = [ - "pk", - "name", - "host", - "port", - "username", - "password", - "use_tls", - "use_ssl", - "timeout", - "from_address", - "token_expiry", - "subject", - "template", - ] - extra_kwargs = {"password": {"write_only": True}} - - -class EmailStageViewSet(ModelViewSet): - """EmailStage Viewset""" - - queryset = EmailStage.objects.all() - serializer_class = EmailStageSerializer diff --git a/passbook/stages/email/apps.py b/passbook/stages/email/apps.py deleted file mode 100644 index 241904ee..00000000 --- a/passbook/stages/email/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -"""passbook email stage config""" -from importlib import import_module - -from django.apps import AppConfig - - -class PassbookStageEmailConfig(AppConfig): - """passbook email stage config""" - - name = "passbook.stages.email" - label = "passbook_stages_email" - verbose_name = "passbook Stages.Email" - - def ready(self): - import_module("passbook.stages.email.tasks") diff --git a/passbook/stages/email/forms.py b/passbook/stages/email/forms.py deleted file mode 100644 index 03db70f4..00000000 --- a/passbook/stages/email/forms.py +++ /dev/null @@ -1,44 +0,0 @@ -"""passbook administration forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.stages.email.models import EmailStage - - -class EmailStageSendForm(forms.Form): - """Form used when sending the email to prevent multiple emails being sent""" - - invalid = forms.CharField(widget=forms.HiddenInput, required=True) - - -class EmailStageForm(forms.ModelForm): - """Form to create/edit Email Stage""" - - class Meta: - - model = EmailStage - fields = [ - "name", - "host", - "port", - "username", - "password", - "use_tls", - "use_ssl", - "timeout", - "from_address", - "token_expiry", - "subject", - "template", - ] - widgets = { - "name": forms.TextInput(), - "host": forms.TextInput(), - "subject": forms.TextInput(), - "username": forms.TextInput(), - "password": forms.TextInput(), - } - labels = { - "use_tls": _("Use TLS"), - "use_ssl": _("Use SSL"), - } diff --git a/passbook/stages/email/migrations/0001_initial.py b/passbook/stages/email/migrations/0001_initial.py deleted file mode 100644 index 65e1ec5d..00000000 --- a/passbook/stages/email/migrations/0001_initial.py +++ /dev/null @@ -1,71 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="EmailStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ("host", models.TextField(default="localhost")), - ("port", models.IntegerField(default=25)), - ("username", models.TextField(blank=True, default="")), - ("password", models.TextField(blank=True, default="")), - ("use_tls", models.BooleanField(default=False)), - ("use_ssl", models.BooleanField(default=False)), - ("timeout", models.IntegerField(default=10)), - ( - "from_address", - models.EmailField(default="system@passbook.local", max_length=254), - ), - ( - "token_expiry", - models.IntegerField( - default=30, help_text="Time in minutes the token sent is valid." - ), - ), - ("subject", models.TextField(default="passbook")), - ( - "template", - models.TextField( - choices=[ - ( - "stages/email/for_email/password_reset.html", - "Password Reset", - ), - ( - "stages/email/for_email/account_confirmation.html", - "Account Confirmation", - ), - ], - default="stages/email/for_email/password_reset.html", - ), - ), - ], - options={ - "verbose_name": "Email Stage", - "verbose_name_plural": "Email Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/email/models.py b/passbook/stages/email/models.py deleted file mode 100644 index 54d55e9b..00000000 --- a/passbook/stages/email/models.py +++ /dev/null @@ -1,85 +0,0 @@ -"""email stage models""" -from typing import Type - -from django.core.mail import get_connection -from django.core.mail.backends.base import BaseEmailBackend -from django.db import models -from django.forms import ModelForm -from django.utils.translation import gettext as _ -from django.views import View -from rest_framework.serializers import BaseSerializer - -from passbook.flows.models import Stage - - -class EmailTemplates(models.TextChoices): - """Templates used for rendering the Email""" - - PASSWORD_RESET = ( - "stages/email/for_email/password_reset.html", - _("Password Reset"), - ) # nosec - ACCOUNT_CONFIRM = ( - "stages/email/for_email/account_confirmation.html", - _("Account Confirmation"), - ) - - -class EmailStage(Stage): - """Sends an Email to the user with a token to confirm their Email address.""" - - host = models.TextField(default="localhost") - port = models.IntegerField(default=25) - username = models.TextField(default="", blank=True) - password = models.TextField(default="", blank=True) - use_tls = models.BooleanField(default=False) - use_ssl = models.BooleanField(default=False) - timeout = models.IntegerField(default=10) - from_address = models.EmailField(default="system@passbook.local") - - token_expiry = models.IntegerField( - default=30, help_text=_("Time in minutes the token sent is valid.") - ) - subject = models.TextField(default="passbook") - template = models.TextField( - choices=EmailTemplates.choices, default=EmailTemplates.PASSWORD_RESET - ) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.email.api import EmailStageSerializer - - return EmailStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.email.stage import EmailStageView - - return EmailStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.email.forms import EmailStageForm - - return EmailStageForm - - @property - def backend(self) -> BaseEmailBackend: - """Get fully configured EMail Backend instance""" - return get_connection( - host=self.host, - port=self.port, - username=self.username, - password=self.password, - use_tls=self.use_tls, - use_ssl=self.use_ssl, - timeout=self.timeout, - ) - - def __str__(self): - return f"Email Stage {self.name}" - - class Meta: - - verbose_name = _("Email Stage") - verbose_name_plural = _("Email Stages") diff --git a/passbook/stages/email/stage.py b/passbook/stages/email/stage.py deleted file mode 100644 index 58f2ab03..00000000 --- a/passbook/stages/email/stage.py +++ /dev/null @@ -1,90 +0,0 @@ -"""passbook multi-stage authentication engine""" -from datetime import timedelta - -from django.contrib import messages -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, reverse -from django.utils.http import urlencode -from django.utils.timezone import now -from django.utils.translation import gettext as _ -from django.views.generic import FormView -from structlog import get_logger - -from passbook.core.models import Token -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.flows.views import SESSION_KEY_GET -from passbook.stages.email.forms import EmailStageSendForm -from passbook.stages.email.models import EmailStage -from passbook.stages.email.tasks import send_mails -from passbook.stages.email.utils import TemplateEmailMessage - -LOGGER = get_logger() -QS_KEY_TOKEN = "token" -PLAN_CONTEXT_EMAIL_SENT = "email_sent" - - -class EmailStageView(FormView, StageView): - """Email stage which sends Email for verification""" - - form_class = EmailStageSendForm - template_name = "stages/email/waiting_message.html" - - def get_full_url(self, **kwargs) -> str: - """Get full URL to be used in template""" - base_url = reverse( - "passbook_flows:flow-executor-shell", - kwargs={"flow_slug": self.executor.flow.slug}, - ) - relative_url = f"{base_url}?{urlencode(kwargs)}" - return self.request.build_absolute_uri(relative_url) - - def send_email(self): - """Helper function that sends the actual email. Implies that you've - already checked that there is a pending user.""" - pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - current_stage: EmailStage = self.executor.current_stage - valid_delta = timedelta( - minutes=current_stage.token_expiry + 1 - ) # + 1 because django timesince always rounds down - token = Token.objects.create(user=pending_user, expires=now() + valid_delta) - # Send mail to user - message = TemplateEmailMessage( - subject=_(current_stage.subject), - template_name=current_stage.template, - to=[pending_user.email], - template_context={ - "url": self.get_full_url(**{QS_KEY_TOKEN: token.pk.hex}), - "user": pending_user, - "expires": token.expires, - }, - ) - send_mails(current_stage, message) - - def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - # Check if the user came back from the email link to verify - if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}): - token = get_object_or_404( - Token, pk=request.session[SESSION_KEY_GET][QS_KEY_TOKEN] - ) - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user - token.delete() - messages.success(request, _("Successfully verified Email.")) - return self.executor.stage_ok() - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: - messages.error(self.request, _("No pending user.")) - return self.executor.stage_invalid() - # Check if we've already sent the initial e-mail - if PLAN_CONTEXT_EMAIL_SENT not in self.executor.plan.context: - self.send_email() - self.executor.plan.context[PLAN_CONTEXT_EMAIL_SENT] = True - return super().get(request, *args, **kwargs) - - def form_invalid(self, form: EmailStageSendForm) -> HttpResponse: - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: - messages.error(self.request, _("No pending user.")) - return super().form_invalid(form) - self.send_email() - # We can't call stage_ok yet, as we're still waiting - # for the user to click the link in the email - return super().form_invalid(form) diff --git a/passbook/stages/email/tasks.py b/passbook/stages/email/tasks.py deleted file mode 100644 index 43a914cd..00000000 --- a/passbook/stages/email/tasks.py +++ /dev/null @@ -1,65 +0,0 @@ -"""email stage tasks""" -from email.utils import make_msgid -from smtplib import SMTPException -from typing import Any, Dict, List - -from celery import group -from django.core.mail import EmailMultiAlternatives -from django.core.mail.utils import DNS_NAME -from structlog import get_logger - -from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus -from passbook.root.celery import CELERY_APP -from passbook.stages.email.models import EmailStage - -LOGGER = get_logger() - - -def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]): - """Wrapper to convert EmailMessage to dict and send it from worker""" - tasks = [] - for message in messages: - tasks.append(send_mail.s(stage.pk, message.__dict__)) - lazy_group = group(*tasks) - promise = lazy_group() - return promise - - -@CELERY_APP.task( - bind=True, - autoretry_for=( - SMTPException, - ConnectionError, - ), - retry_backoff=True, - base=MonitoredTask, -) -def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]): - """Send Email for Email Stage. Retries are scheduled automatically.""" - self.save_on_success = False - message_id = make_msgid(domain=DNS_NAME) - self.set_uid(message_id) - try: - stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk) - backend = stage.backend - backend.open() - # Since django's EmailMessage objects are not JSON serialisable, - # we need to rebuild them from a dict - message_object = EmailMultiAlternatives() - for key, value in message.items(): - setattr(message_object, key, value) - message_object.from_email = stage.from_address - # Because we use the Message-ID as UID for the task, manually assign it - message_object.extra_headers["Message-ID"] = message_id - - LOGGER.debug("Sending mail", to=message_object.to) - stage.backend.send_messages([message_object]) - self.set_status( - TaskResult( - TaskResultStatus.SUCCESSFUL, - messages=["Successfully sent Mail."], - ) - ) - except (SMTPException, ConnectionError) as exc: - self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) - raise exc diff --git a/passbook/stages/email/templates/stages/email/for_email/account_confirmation.html b/passbook/stages/email/templates/stages/email/for_email/account_confirmation.html deleted file mode 100644 index b384a8cd..00000000 --- a/passbook/stages/email/templates/stages/email/for_email/account_confirmation.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'stages/email/for_email/base.html' %} - -{% load passbook_stages_email %} -{% load i18n %} - -{% block content %} - -

- {% trans 'Welcome!' %} -

-

- {% trans "We're excited to have you get started. First, you need to confirm your account. Just press the button below."%} -

- - - - - - - -

- {% blocktrans with url=url %} - If that doesn't work, copy and paste the following link in your browser: {{ url }} - {% endblocktrans %} -

-

- {% trans "If you have any questions, just reply to this email—we're always happy to help out." %} -

- -{% endblock %} diff --git a/passbook/stages/email/templates/stages/email/for_email/base.html b/passbook/stages/email/templates/stages/email/for_email/base.html deleted file mode 100644 index 08d2e991..00000000 --- a/passbook/stages/email/templates/stages/email/for_email/base.html +++ /dev/null @@ -1,65 +0,0 @@ -{% load passbook_stages_email %} -{% load passbook_utils %} -{% load static %} -{% load i18n %} - - - - - - - - - - - - - - {% block pre_header %} - {% endblock %} - - - - - - - - - - - diff --git a/passbook/stages/email/templates/stages/email/for_email/password_reset.html b/passbook/stages/email/templates/stages/email/for_email/password_reset.html deleted file mode 100644 index c023c308..00000000 --- a/passbook/stages/email/templates/stages/email/for_email/password_reset.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "stages/email/for_email/base.html" %} - -{% load passbook_utils %} -{% load i18n %} -{% load humanize %} - -{% block content %} - -

- {% blocktrans with username=user.username %} - Hi {{ username }}, - {% endblocktrans %} -

-

- {% blocktrans %} - You recently requested to change your password for you passbook account. Use the button below to set a new password. - {% endblocktrans %} -

- - - - - - - -

- {% blocktrans with expires=expires|naturaltime %} - If you did not request a password change, please ignore this Email. The link above is valid for {{ expires }}. - {% endblocktrans %} -

- -{% endblock %} diff --git a/passbook/stages/email/templatetags/passbook_stages_email.py b/passbook/stages/email/templatetags/passbook_stages_email.py deleted file mode 100644 index 81d60690..00000000 --- a/passbook/stages/email/templatetags/passbook_stages_email.py +++ /dev/null @@ -1,31 +0,0 @@ -"""passbook core inlining template tags""" -from base64 import b64encode -from pathlib import Path - -from django import template -from django.contrib.staticfiles import finders - -register = template.Library() - - -@register.simple_tag() -def inline_static_ascii(path: str) -> str: - """Inline static asset. Doesn't check file contents, plain text is assumed. - If no file could be found, original path is returned""" - result = Path(finders.find(path)) - if result: - with open(result) as _file: - return _file.read() - return path - - -@register.simple_tag() -def inline_static_binary(path: str) -> str: - """Inline static asset. Uses file extension for base64 block. If no file could be found, - path is returned.""" - result = Path(finders.find(path)) - if result and result.is_file(): - with open(result) as _file: - b64content = b64encode(_file.read().encode()) - return f"data:image/{result.suffix};base64,{b64content.decode('utf-8')}" - return path diff --git a/passbook/stages/email/tests.py b/passbook/stages/email/tests.py deleted file mode 100644 index 0bb2ca4d..00000000 --- a/passbook/stages/email/tests.py +++ /dev/null @@ -1,125 +0,0 @@ -"""email tests""" -from unittest.mock import MagicMock, patch - -from django.core import mail -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import Token, User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.stages.email.models import EmailStage -from passbook.stages.email.stage import QS_KEY_TOKEN - - -class TestEmailStage(TestCase): - """Email tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create_user( - username="unittest", email="test@beryju.org" - ) - self.client = Client() - - self.flow = Flow.objects.create( - name="test-email", - slug="test-email", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = EmailStage.objects.create( - name="email", - ) - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_rendering(self): - """Test with pending user""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_without_user(self): - """Test without pending user""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_pending_user(self): - """Test with pending user""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - with self.settings( - EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend" - ): - response = self.client.post(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].subject, "passbook") - - def test_token(self): - """Test with token""" - # Make sure token exists - self.test_pending_user() - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - with patch("passbook.flows.views.FlowExecutorView.cancel", MagicMock()): - # Call the executor shell to preseed the session - url = reverse( - "passbook_flows:flow-executor-shell", - kwargs={"flow_slug": self.flow.slug}, - ) - token = Token.objects.get(user=self.user) - url += f"?{QS_KEY_TOKEN}={token.pk.hex}" - self.client.get(url) - # Call the actual executor to get the JSON Response - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - session = self.client.session - plan: FlowPlan = session[SESSION_KEY_PLAN] - self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER], self.user) diff --git a/passbook/stages/identification/api.py b/passbook/stages/identification/api.py deleted file mode 100644 index 07f43249..00000000 --- a/passbook/stages/identification/api.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Identification Stage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.identification.models import IdentificationStage - - -class IdentificationStageSerializer(ModelSerializer): - """IdentificationStage Serializer""" - - class Meta: - - model = IdentificationStage - fields = [ - "pk", - "name", - "user_fields", - "case_insensitive_matching", - "template", - "enrollment_flow", - "recovery_flow", - ] - - -class IdentificationStageViewSet(ModelViewSet): - """IdentificationStage Viewset""" - - queryset = IdentificationStage.objects.all() - serializer_class = IdentificationStageSerializer diff --git a/passbook/stages/identification/apps.py b/passbook/stages/identification/apps.py deleted file mode 100644 index 71472172..00000000 --- a/passbook/stages/identification/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook identification stage app config""" -from django.apps import AppConfig - - -class PassbookStageIdentificationConfig(AppConfig): - """passbook identification stage config""" - - name = "passbook.stages.identification" - label = "passbook_stages_identification" - verbose_name = "passbook Stages.Identification" diff --git a/passbook/stages/identification/forms.py b/passbook/stages/identification/forms.py deleted file mode 100644 index 70bf71eb..00000000 --- a/passbook/stages/identification/forms.py +++ /dev/null @@ -1,73 +0,0 @@ -"""passbook flows identification forms""" -from django import forms -from django.core.validators import validate_email -from django.utils.translation import gettext_lazy as _ -from structlog import get_logger - -from passbook.admin.fields import ArrayFieldSelectMultiple -from passbook.flows.models import Flow, FlowDesignation -from passbook.lib.utils.ui import human_list -from passbook.stages.identification.models import IdentificationStage, UserFields - -LOGGER = get_logger() - - -class IdentificationStageForm(forms.ModelForm): - """Form to create/edit IdentificationStage instances""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["enrollment_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.ENROLLMENT - ) - self.fields["recovery_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.RECOVERY - ) - - class Meta: - - model = IdentificationStage - fields = [ - "name", - "user_fields", - "case_insensitive_matching", - "template", - "enrollment_flow", - "recovery_flow", - ] - widgets = { - "name": forms.TextInput(), - "user_fields": ArrayFieldSelectMultiple(choices=UserFields.choices), - } - - -class IdentificationForm(forms.Form): - """Allow users to login""" - - stage: IdentificationStage - - title = _("Log in to your account") - uid_field = forms.CharField(label=_("")) - - def __init__(self, *args, **kwargs): - self.stage = kwargs.pop("stage") - super().__init__(*args, **kwargs) - if self.stage.user_fields == [UserFields.E_MAIL]: - self.fields["uid_field"] = forms.EmailField() - label = human_list([x.title() for x in self.stage.user_fields]) - self.fields["uid_field"].label = label - self.fields["uid_field"].widget.attrs.update( - { - "placeholder": _(label), - "autofocus": "autofocus", - # Autocomplete according to - # https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands - "autocomplete": "username", - } - ) - - def clean_uid_field(self): - """Validate uid_field after EmailValidator if 'email' is the only selected uid_fields""" - if self.stage.user_fields == [UserFields.E_MAIL]: - validate_email(self.cleaned_data.get("uid_field")) - return self.cleaned_data.get("uid_field") diff --git a/passbook/stages/identification/migrations/0001_initial.py b/passbook/stages/identification/migrations/0001_initial.py deleted file mode 100644 index 9036ce9a..00000000 --- a/passbook/stages/identification/migrations/0001_initial.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.contrib.postgres.fields -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="IdentificationStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ( - "user_fields", - django.contrib.postgres.fields.ArrayField( - base_field=models.CharField( - choices=[("email", "E Mail"), ("username", "Username")], - max_length=100, - ), - help_text="Fields of the user object to match against.", - size=None, - ), - ), - ( - "template", - models.TextField( - choices=[ - ("stages/identification/login.html", "Default Login"), - ("stages/identification/recovery.html", "Default Recovery"), - ] - ), - ), - ], - options={ - "verbose_name": "Identification Stage", - "verbose_name_plural": "Identification Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/identification/migrations/0002_auto_20200530_2204.py b/passbook/stages/identification/migrations/0002_auto_20200530_2204.py deleted file mode 100644 index bedf7b55..00000000 --- a/passbook/stages/identification/migrations/0002_auto_20200530_2204.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-30 22:04 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0003_auto_20200523_1133"), - ("passbook_stages_identification", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="identificationstage", - name="enrollment_flow", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Optional enrollment flow, which is linked at the bottom of the page.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - related_name="+", - to="passbook_flows.Flow", - ), - ), - migrations.AddField( - model_name="identificationstage", - name="recovery_flow", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Optional enrollment flow, which is linked at the bottom of the page.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - related_name="+", - to="passbook_flows.Flow", - ), - ), - ] diff --git a/passbook/stages/identification/migrations/0003_auto_20200615_1641.py b/passbook/stages/identification/migrations/0003_auto_20200615_1641.py deleted file mode 100644 index bed87640..00000000 --- a/passbook/stages/identification/migrations/0003_auto_20200615_1641.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-15 16:41 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0007_auto_20200703_2059"), - ("passbook_stages_identification", "0002_auto_20200530_2204"), - ] - - operations = [ - migrations.AlterField( - model_name="identificationstage", - name="recovery_flow", - field=models.ForeignKey( - blank=True, - default=None, - help_text="Optional recovery flow, which is linked at the bottom of the page.", - null=True, - on_delete=django.db.models.deletion.SET_DEFAULT, - related_name="+", - to="passbook_flows.Flow", - ), - ), - ] diff --git a/passbook/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py b/passbook/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py deleted file mode 100644 index 43f6d591..00000000 --- a/passbook/stages/identification/migrations/0004_identificationstage_case_insensitive_matching.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-30 21:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_identification", "0003_auto_20200615_1641"), - ] - - operations = [ - migrations.AddField( - model_name="identificationstage", - name="case_insensitive_matching", - field=models.BooleanField( - default=True, - help_text="When enabled, user fields are matched regardless of their casing.", - ), - ), - ] diff --git a/passbook/stages/identification/migrations/0005_auto_20201003_1734.py b/passbook/stages/identification/migrations/0005_auto_20201003_1734.py deleted file mode 100644 index 30ead927..00000000 --- a/passbook/stages/identification/migrations/0005_auto_20201003_1734.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-03 17:34 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "passbook_stages_identification", - "0004_identificationstage_case_insensitive_matching", - ), - ] - - operations = [ - migrations.AlterField( - model_name="identificationstage", - name="user_fields", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField( - choices=[("email", "E Mail"), ("username", "Username")], - max_length=100, - ), - help_text="Fields of the user object to match against. (Hold shift to select multiple options)", - size=None, - ), - ), - ] diff --git a/passbook/stages/identification/models.py b/passbook/stages/identification/models.py deleted file mode 100644 index 27f25dd8..00000000 --- a/passbook/stages/identification/models.py +++ /dev/null @@ -1,96 +0,0 @@ -"""identification stage models""" -from typing import Type - -from django.contrib.postgres.fields 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 passbook.flows.models import Flow, Stage - - -class UserFields(models.TextChoices): - """Fields which the user can identify themselves with""" - - E_MAIL = "email" - USERNAME = "username" - - -class Templates(models.TextChoices): - """Templates to be used for the stage""" - - DEFAULT_LOGIN = "stages/identification/login.html" - DEFAULT_RECOVERY = "stages/identification/recovery.html" - - -class IdentificationStage(Stage): - """Allows the user to identify themselves for authentication.""" - - user_fields = ArrayField( - models.CharField(max_length=100, choices=UserFields.choices), - help_text=_( - ( - "Fields of the user object to match against. " - "(Hold shift to select multiple options)" - ) - ), - ) - template = models.TextField(choices=Templates.choices) - - case_insensitive_matching = models.BooleanField( - default=True, - help_text=_( - "When enabled, user fields are matched regardless of their casing." - ), - ) - - enrollment_flow = models.ForeignKey( - Flow, - on_delete=models.SET_DEFAULT, - null=True, - blank=True, - related_name="+", - default=None, - help_text=_( - "Optional enrollment flow, which is linked at the bottom of the page." - ), - ) - recovery_flow = models.ForeignKey( - Flow, - on_delete=models.SET_DEFAULT, - null=True, - blank=True, - related_name="+", - default=None, - help_text=_( - "Optional recovery flow, which is linked at the bottom of the page." - ), - ) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.identification.api import IdentificationStageSerializer - - return IdentificationStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.identification.stage import IdentificationStageView - - return IdentificationStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.identification.forms import IdentificationStageForm - - return IdentificationStageForm - - def __str__(self): - return f"Identification Stage {self.name}" - - class Meta: - - verbose_name = _("Identification Stage") - verbose_name_plural = _("Identification Stages") diff --git a/passbook/stages/identification/stage.py b/passbook/stages/identification/stage.py deleted file mode 100644 index 2617e5bc..00000000 --- a/passbook/stages/identification/stage.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Identification stage logic""" -from typing import List, Optional - -from django.contrib import messages -from django.db.models import Q -from django.http import HttpResponse -from django.shortcuts import reverse -from django.utils.translation import gettext as _ -from django.views.generic import FormView -from structlog import get_logger - -from passbook.core.models import Source, User -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.flows.views import SESSION_KEY_APPLICATION_PRE -from passbook.stages.identification.forms import IdentificationForm -from passbook.stages.identification.models import IdentificationStage - -LOGGER = get_logger() - - -class IdentificationStageView(FormView, StageView): - """Form to identify the user""" - - form_class = IdentificationForm - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["stage"] = self.executor.current_stage - return kwargs - - def get_template_names(self) -> List[str]: - current_stage: IdentificationStage = self.executor.current_stage - return [current_stage.template] - - def get_context_data(self, **kwargs): - current_stage: IdentificationStage = self.executor.current_stage - # If the user has been redirected to us whilst trying to access an - # application, SESSION_KEY_APPLICATION_PRE is set in the session - if SESSION_KEY_APPLICATION_PRE in self.request.session: - kwargs["application_pre"] = self.request.session[ - SESSION_KEY_APPLICATION_PRE - ] - # Check for related enrollment and recovery flow, add URL to view - if current_stage.enrollment_flow: - kwargs["enroll_url"] = reverse( - "passbook_flows:flow-executor-shell", - kwargs={"flow_slug": current_stage.enrollment_flow.slug}, - ) - if current_stage.recovery_flow: - kwargs["recovery_url"] = reverse( - "passbook_flows:flow-executor-shell", - kwargs={"flow_slug": current_stage.recovery_flow.slug}, - ) - kwargs["primary_action"] = _("Log in") - - # Check all enabled source, add them if they have a UI Login button. - kwargs["sources"] = [] - sources: List[Source] = ( - Source.objects.filter(enabled=True).order_by("name").select_subclasses() - ) - for source in sources: - ui_login_button = source.ui_login_button - if ui_login_button: - kwargs["sources"].append(ui_login_button) - return super().get_context_data(**kwargs) - - def get_user(self, uid_value: str) -> Optional[User]: - """Find user instance. Returns None if no user was found.""" - current_stage: IdentificationStage = self.executor.current_stage - query = Q() - for search_field in current_stage.user_fields: - model_field = search_field - if current_stage.case_insensitive_matching: - model_field += "__iexact" - else: - model_field += "__exact" - query |= Q(**{model_field: uid_value}) - users = User.objects.filter(query) - if users.exists(): - LOGGER.debug("Found user", user=users.first(), query=query) - return users.first() - return None - - def form_valid(self, form: IdentificationForm) -> HttpResponse: - """Form data is valid""" - pre_user = self.get_user(form.cleaned_data.get("uid_field")) - if not pre_user: - LOGGER.debug("invalid_login") - messages.error(self.request, _("Failed to authenticate.")) - return self.form_invalid(form) - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = pre_user - return self.executor.stage_ok() diff --git a/passbook/stages/identification/tests.py b/passbook/stages/identification/tests.py deleted file mode 100644 index 792cf8d5..00000000 --- a/passbook/stages/identification/tests.py +++ /dev/null @@ -1,131 +0,0 @@ -"""identification tests""" -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.sources.oauth.models import OAuthSource -from passbook.stages.identification.models import ( - IdentificationStage, - Templates, - UserFields, -) - - -class TestIdentificationStage(TestCase): - """Identification tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create(username="unittest", email="test@beryju.org") - self.client = Client() - - self.flow = Flow.objects.create( - name="test-identification", - slug="test-identification", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = IdentificationStage.objects.create( - name="identification", - user_fields=[UserFields.E_MAIL], - template=Templates.DEFAULT_LOGIN, - ) - FlowStageBinding.objects.create( - target=self.flow, - stage=self.stage, - order=0, - ) - - # OAuthSource for the login view - OAuthSource.objects.create(name="test", slug="test") - - def test_valid_render(self): - """Test that View renders correctly""" - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 200) - - def test_valid_with_email(self): - """Test with valid email, check that URL redirects back to itself""" - form_data = {"uid_field": self.user.email} - url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - response = self.client.post(url, form_data) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - def test_invalid_with_username(self): - """Test invalid with username (user exists but stage only allows email)""" - form_data = {"uid_field": self.user.username} - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - form_data, - ) - self.assertEqual(response.status_code, 200) - - def test_invalid_with_invalid_email(self): - """Test with invalid email (user doesn't exist) -> Will return to login form""" - form_data = {"uid_field": self.user.email + "test"} - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - form_data, - ) - self.assertEqual(response.status_code, 200) - - def test_enrollment_flow(self): - """Test that enrollment flow is linked correctly""" - flow = Flow.objects.create( - name="enroll-test", - slug="unique-enrollment-string", - designation=FlowDesignation.ENROLLMENT, - ) - self.stage.enrollment_flow = flow - self.stage.save() - FlowStageBinding.objects.create( - target=flow, - stage=self.stage, - order=0, - ) - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - ) - self.assertEqual(response.status_code, 200) - self.assertIn(flow.slug, force_str(response.content)) - - def test_recovery_flow(self): - """Test that recovery flow is linked correctly""" - flow = Flow.objects.create( - name="recovery-test", - slug="unique-recovery-string", - designation=FlowDesignation.RECOVERY, - ) - self.stage.recovery_flow = flow - self.stage.save() - FlowStageBinding.objects.create( - target=flow, - stage=self.stage, - order=0, - ) - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - ) - self.assertEqual(response.status_code, 200) - self.assertIn(flow.slug, force_str(response.content)) diff --git a/passbook/stages/invitation/api.py b/passbook/stages/invitation/api.py deleted file mode 100644 index 33da32b6..00000000 --- a/passbook/stages/invitation/api.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Invitation Stage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.invitation.models import Invitation, InvitationStage - - -class InvitationStageSerializer(ModelSerializer): - """InvitationStage Serializer""" - - class Meta: - - model = InvitationStage - fields = [ - "pk", - "name", - "continue_flow_without_invitation", - ] - - -class InvitationStageViewSet(ModelViewSet): - """InvitationStage Viewset""" - - queryset = InvitationStage.objects.all() - serializer_class = InvitationStageSerializer - - -class InvitationSerializer(ModelSerializer): - """Invitation Serializer""" - - class Meta: - - model = Invitation - fields = [ - "pk", - "expires", - "fixed_data", - ] - - -class InvitationViewSet(ModelViewSet): - """Invitation Viewset""" - - queryset = Invitation.objects.all() - serializer_class = InvitationSerializer diff --git a/passbook/stages/invitation/apps.py b/passbook/stages/invitation/apps.py deleted file mode 100644 index 0b0eddd0..00000000 --- a/passbook/stages/invitation/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook invitation stage app config""" -from django.apps import AppConfig - - -class PassbookStageUserInvitationConfig(AppConfig): - """passbook invitation stage config""" - - name = "passbook.stages.invitation" - label = "passbook_stages_invitation" - verbose_name = "passbook Stages.User Invitation" diff --git a/passbook/stages/invitation/forms.py b/passbook/stages/invitation/forms.py deleted file mode 100644 index ad0515c6..00000000 --- a/passbook/stages/invitation/forms.py +++ /dev/null @@ -1,32 +0,0 @@ -"""passbook flows invitation forms""" -from django import forms -from django.utils.translation import gettext as _ - -from passbook.admin.fields import CodeMirrorWidget, YAMLField -from passbook.stages.invitation.models import Invitation, InvitationStage - - -class InvitationStageForm(forms.ModelForm): - """Form to create/edit InvitationStage instances""" - - class Meta: - - model = InvitationStage - fields = ["name", "continue_flow_without_invitation"] - widgets = { - "name": forms.TextInput(), - } - - -class InvitationForm(forms.ModelForm): - """InvitationForm""" - - class Meta: - - model = Invitation - fields = ["expires", "fixed_data"] - labels = { - "fixed_data": _("Optional fixed data to enforce on user enrollment."), - } - widgets = {"fixed_data": CodeMirrorWidget()} - field_classes = {"fixed_data": YAMLField} diff --git a/passbook/stages/invitation/migrations/0001_initial.py b/passbook/stages/invitation/migrations/0001_initial.py deleted file mode 100644 index 83784d93..00000000 --- a/passbook/stages/invitation/migrations/0001_initial.py +++ /dev/null @@ -1,78 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import uuid - -import django.db.models.deletion -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), - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="InvitationStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ( - "continue_flow_without_invitation", - models.BooleanField( - default=False, - help_text="If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given.", - ), - ), - ], - options={ - "verbose_name": "Invitation Stage", - "verbose_name_plural": "Invitation Stages", - }, - bases=("passbook_flows.stage",), - ), - migrations.CreateModel( - name="Invitation", - fields=[ - ( - "invite_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ("expires", models.DateTimeField(blank=True, default=None, null=True)), - ( - "fixed_data", - models.JSONField(default=dict), - ), - ( - "created_by", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Invitation", - "verbose_name_plural": "Invitations", - }, - ), - ] diff --git a/passbook/stages/invitation/models.py b/passbook/stages/invitation/models.py deleted file mode 100644 index 7cd08c88..00000000 --- a/passbook/stages/invitation/models.py +++ /dev/null @@ -1,72 +0,0 @@ -"""invitation stage models""" -from typing import Type -from uuid import uuid4 - -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 passbook.core.models import User -from passbook.flows.models import Stage - - -class InvitationStage(Stage): - """Simplify enrollment; allow users to use a single - link to create their user with pre-defined parameters.""" - - continue_flow_without_invitation = models.BooleanField( - default=False, - help_text=_( - ( - "If this flag is set, this Stage will jump to the next Stage when " - "no Invitation is given. By default this Stage will cancel the " - "Flow when no invitation is given." - ) - ), - ) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.invitation.api import InvitationStageSerializer - - return InvitationStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.invitation.stage import InvitationStageView - - return InvitationStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.invitation.forms import InvitationStageForm - - return InvitationStageForm - - def __str__(self): - return f"Invitation Stage {self.name}" - - class Meta: - - verbose_name = _("Invitation Stage") - verbose_name_plural = _("Invitation Stages") - - -class Invitation(models.Model): - """Single-use invitation link""" - - invite_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - created_by = models.ForeignKey(User, on_delete=models.CASCADE) - expires = models.DateTimeField(default=None, blank=True, null=True) - fixed_data = models.JSONField(default=dict) - - def __str__(self): - return f"Invitation {self.invite_uuid.hex} created by {self.created_by}" - - class Meta: - - verbose_name = _("Invitation") - verbose_name_plural = _("Invitations") diff --git a/passbook/stages/invitation/signals.py b/passbook/stages/invitation/signals.py deleted file mode 100644 index 34b4614a..00000000 --- a/passbook/stages/invitation/signals.py +++ /dev/null @@ -1,7 +0,0 @@ -"""passbook invitation signals""" -from django.core.signals import Signal - -# Arguments: request: HttpRequest, invitation: Invitation -invitation_created = Signal() -# Arguments: request: HttpRequest, invitation: Invitation -invitation_used = Signal() diff --git a/passbook/stages/invitation/stage.py b/passbook/stages/invitation/stage.py deleted file mode 100644 index bd62f0a2..00000000 --- a/passbook/stages/invitation/stage.py +++ /dev/null @@ -1,30 +0,0 @@ -"""invitation stage logic""" -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404 - -from passbook.flows.stage import StageView -from passbook.stages.invitation.models import Invitation, InvitationStage -from passbook.stages.invitation.signals import invitation_used -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT - -INVITATION_TOKEN_KEY = "token" -INVITATION_IN_EFFECT = "invitation_in_effect" - - -class InvitationStageView(StageView): - """Finalise Authentication flow by logging the user in""" - - def get(self, request: HttpRequest) -> HttpResponse: - stage: InvitationStage = self.executor.current_stage - if INVITATION_TOKEN_KEY not in request.GET: - # No Invitation was given, raise error or continue - if stage.continue_flow_without_invitation: - return self.executor.stage_ok() - return self.executor.stage_invalid() - - token = request.GET[INVITATION_TOKEN_KEY] - invite: Invitation = get_object_or_404(Invitation, pk=token) - self.executor.plan.context[PLAN_CONTEXT_PROMPT] = invite.fixed_data - self.executor.plan.context[INVITATION_IN_EFFECT] = True - invitation_used.send(sender=self, request=request, invitation=invite) - return self.executor.stage_ok() diff --git a/passbook/stages/invitation/tests.py b/passbook/stages/invitation/tests.py deleted file mode 100644 index 91009077..00000000 --- a/passbook/stages/invitation/tests.py +++ /dev/null @@ -1,132 +0,0 @@ -"""invitation tests""" -from unittest.mock import MagicMock, patch - -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str -from guardian.shortcuts import get_anonymous_user - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.policies.http import AccessDeniedResponse -from passbook.stages.invitation.forms import InvitationStageForm -from passbook.stages.invitation.models import Invitation, InvitationStage -from passbook.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND - - -class TestUserLoginStage(TestCase): - """Login tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create(username="unittest", email="test@beryju.org") - self.client = Client() - - self.flow = Flow.objects.create( - name="test-invitation", - slug="test-invitation", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = InvitationStage.objects.create(name="invitation") - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(InvitationStageForm(data).is_valid(), True) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - def test_without_invitation_fail(self): - """Test without any invitation, continue_flow_without_invitation not set.""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[ - PLAN_CONTEXT_AUTHENTICATION_BACKEND - ] = "django.contrib.auth.backends.ModelBackend" - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - - def test_without_invitation_continue(self): - """Test without any invitation, continue_flow_without_invitation is set.""" - self.stage.continue_flow_without_invitation = True - self.stage.save() - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[ - PLAN_CONTEXT_AUTHENTICATION_BACKEND - ] = "django.contrib.auth.backends.ModelBackend" - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - self.stage.continue_flow_without_invitation = False - self.stage.save() - - def test_with_invitation(self): - """Test with invitation, check data in session""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[ - PLAN_CONTEXT_AUTHENTICATION_BACKEND - ] = "django.contrib.auth.backends.ModelBackend" - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - data = {"foo": "bar"} - invite = Invitation.objects.create( - created_by=get_anonymous_user(), fixed_data=data - ) - - with patch("passbook.flows.views.FlowExecutorView.cancel", MagicMock()): - base_url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - response = self.client.get( - base_url + f"?{INVITATION_TOKEN_KEY}={invite.pk.hex}" - ) - - session = self.client.session - plan: FlowPlan = session[SESSION_KEY_PLAN] - self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) diff --git a/passbook/stages/otp_static/api.py b/passbook/stages/otp_static/api.py deleted file mode 100644 index b00d39a2..00000000 --- a/passbook/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 passbook.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/passbook/stages/otp_static/apps.py b/passbook/stages/otp_static/apps.py deleted file mode 100644 index 2bd71c7d..00000000 --- a/passbook/stages/otp_static/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OTP Static stage""" -from django.apps import AppConfig - - -class PassbookStageOTPStaticConfig(AppConfig): - """OTP Static stage""" - - name = "passbook.stages.otp_static" - label = "passbook_stages_otp_static" - verbose_name = "passbook OTP.Static" - mountpoint = "-/user/otp/static/" diff --git a/passbook/stages/otp_static/forms.py b/passbook/stages/otp_static/forms.py deleted file mode 100644 index 684e2592..00000000 --- a/passbook/stages/otp_static/forms.py +++ /dev/null @@ -1,39 +0,0 @@ -"""OTP Static forms""" -from django import forms -from django.utils.safestring import mark_safe - -from passbook.stages.otp_static.models import OTPStaticStage - - -class StaticTokenWidget(forms.widgets.Widget): - """Widget to render tokens as multiple labels""" - - def render(self, name, value, attrs=None, renderer=None): - final_string = '
    ' - for token in value: - final_string += f"
  • {token.token}
  • " - final_string += "
" - return mark_safe(final_string) # nosec - - -class SetupForm(forms.Form): - """Form to setup Static OTP""" - - tokens = forms.CharField(widget=StaticTokenWidget, disabled=True, required=False) - - def __init__(self, tokens, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["tokens"].initial = tokens - - -class OTPStaticStageForm(forms.ModelForm): - """OTP Static Stage setup form""" - - class Meta: - - model = OTPStaticStage - fields = ["name", "configure_flow", "token_count"] - - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/otp_static/migrations/0001_initial.py b/passbook/stages/otp_static/migrations/0001_initial.py deleted file mode 100644 index 93c26b3e..00000000 --- a/passbook/stages/otp_static/migrations/0001_initial.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-30 11:43 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0006_auto_20200629_0857"), - ] - - operations = [ - migrations.CreateModel( - name="OTPStaticStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ("token_count", models.IntegerField(default=6)), - ], - options={ - "verbose_name": "OTP Static Setup Stage", - "verbose_name_plural": "OTP Static Setup Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py b/passbook/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py deleted file mode 100644 index 60d9b0e7..00000000 --- a/passbook/stages/otp_static/migrations/0002_otpstaticstage_configure_flow.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-24 20:51 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0013_auto_20200924_1605"), - ("passbook_stages_otp_static", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="otpstaticstage", - name="configure_flow", - field=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="passbook_flows.flow", - ), - ), - ] diff --git a/passbook/stages/otp_static/migrations/0003_default_setup_flow.py b/passbook/stages/otp_static/migrations/0003_default_setup_flow.py deleted file mode 100644 index 773b686f..00000000 --- a/passbook/stages/otp_static/migrations/0003_default_setup_flow.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-25 14:32 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.flows.models import FlowDesignation - - -def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - - OTPStaticStage = apps.get_model("passbook_stages_otp_static", "OTPStaticStage") - - db_alias = schema_editor.connection.alias - - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-otp-static-configure", - designation=FlowDesignation.STAGE_CONFIGURATION, - defaults={ - "name": "default-otp-static-configure", - "title": "Setup Static OTP Tokens", - }, - ) - - stage, _ = OTPStaticStage.objects.using(db_alias).update_or_create( - name="default-otp-static-configure", defaults={"token_count": 6} - ) - - 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): - stage.configure_flow = flow - stage.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_otp_static", "0002_otpstaticstage_configure_flow"), - ] - - operations = [ - migrations.RunPython(create_default_setup_flow), - ] diff --git a/passbook/stages/otp_static/models.py b/passbook/stages/otp_static/models.py deleted file mode 100644 index 6cd0f259..00000000 --- a/passbook/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 passbook.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 passbook.stages.otp_static.api import OTPStaticStageSerializer - - return OTPStaticStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.otp_static.stage import OTPStaticStageView - - return OTPStaticStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.otp_static.forms import OTPStaticStageForm - - return OTPStaticStageForm - - @property - def ui_user_settings(self) -> Optional[str]: - return reverse( - "passbook_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/passbook/stages/otp_static/stage.py b/passbook/stages/otp_static/stage.py deleted file mode 100644 index a37de0df..00000000 --- a/passbook/stages/otp_static/stage.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Static OTP Setup stage""" -from typing import Any, Dict - -from django.http import HttpRequest, HttpResponse -from django.views.generic import FormView -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken -from structlog import get_logger - -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.stages.otp_static.forms import SetupForm -from passbook.stages.otp_static.models import OTPStaticStage - -LOGGER = get_logger() -SESSION_STATIC_DEVICE = "static_device" -SESSION_STATIC_TOKENS = "static_device_tokens" - - -class OTPStaticStageView(FormView, StageView): - """Static OTP Setup stage""" - - form_class = SetupForm - - def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: - kwargs = super().get_form_kwargs(**kwargs) - tokens = self.request.session[SESSION_STATIC_TOKENS] - kwargs["tokens"] = tokens - return kwargs - - def get(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() - - # Currently, this stage only supports one device per user. If the user already - # has a device, just skip to the next stage - if StaticDevice.objects.filter(user=user).exists(): - return self.executor.stage_ok() - - stage: OTPStaticStage = self.executor.current_stage - - if SESSION_STATIC_DEVICE not in self.request.session: - device = StaticDevice(user=user, confirmed=True) - tokens = [] - for _ in range(0, stage.token_count): - tokens.append( - StaticToken(device=device, token=StaticToken.random_token()) - ) - self.request.session[SESSION_STATIC_DEVICE] = device - self.request.session[SESSION_STATIC_TOKENS] = tokens - return super().get(request, *args, **kwargs) - - def form_valid(self, form: SetupForm) -> HttpResponse: - """Verify OTP Token""" - device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE] - device.save() - for token in self.request.session[SESSION_STATIC_TOKENS]: - token.save() - del self.request.session[SESSION_STATIC_DEVICE] - del self.request.session[SESSION_STATIC_TOKENS] - return self.executor.stage_ok() diff --git a/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html b/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html deleted file mode 100644 index d77adbb7..00000000 --- a/passbook/stages/otp_static/templates/stages/otp_static/user_settings.html +++ /dev/null @@ -1,31 +0,0 @@ -{% load i18n %} - -
-
- {% trans "Static One-Time Passwords" %} -
-
-

- {% blocktrans with state=state|yesno:"Enabled,Disabled" %} - Status: {{ state }} - {% endblocktrans %} - {% if state %} - - {% else %} - - {% endif %} -

-
    - {% for token in tokens %} -
  • {{ token.token }}
  • - {% endfor %} -
- {% if not state %} - {% if stage.configure_flow %} - {% trans "Enable Static Tokens" %} - {% endif %} - {% else %} - {% trans "Disable Static Tokens" %} - {% endif %} -
-
diff --git a/passbook/stages/otp_static/urls.py b/passbook/stages/otp_static/urls.py deleted file mode 100644 index 38db39c1..00000000 --- a/passbook/stages/otp_static/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OTP static urls""" -from django.urls import path - -from passbook.stages.otp_static.views import DisableView, UserSettingsView - -urlpatterns = [ - path( - "/settings/", UserSettingsView.as_view(), name="user-settings" - ), - path("/disable/", DisableView.as_view(), name="disable"), -] diff --git a/passbook/stages/otp_static/views.py b/passbook/stages/otp_static/views.py deleted file mode 100644 index 8b95e45d..00000000 --- a/passbook/stages/otp_static/views.py +++ /dev/null @@ -1,44 +0,0 @@ -"""otp Static view Tokens""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.views import View -from django.views.generic import TemplateView -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken - -from passbook.audit.models import Event -from passbook.stages.otp_static.models import OTPStaticStage - - -class UserSettingsView(LoginRequiredMixin, TemplateView): - """View for user settings to control OTP""" - - template_name = "stages/otp_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"]) - kwargs["stage"] = stage - static_devices = StaticDevice.objects.filter( - user=self.request.user, confirmed=True - ) - kwargs["state"] = static_devices.exists() - if static_devices.exists(): - kwargs["tokens"] = StaticToken.objects.filter(device=static_devices.first()) - return kwargs - - -class DisableView(LoginRequiredMixin, View): - """Disable Static Tokens for user""" - - def get(self, request: HttpRequest) -> HttpResponse: - """Delete all the devices for user""" - devices = StaticDevice.objects.filter(user=request.user, confirmed=True) - devices.delete() - messages.success(request, "Successfully disabled Static OTP Tokens") - # Create event with email notification - Event.new( - "static_otp_disable", message="User disabled Static OTP Tokens." - ).from_http(request) - return redirect("passbook_stages_otp:otp-user-settings") diff --git a/passbook/stages/otp_time/api.py b/passbook/stages/otp_time/api.py deleted file mode 100644 index f7998b3d..00000000 --- a/passbook/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 passbook.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/passbook/stages/otp_time/apps.py b/passbook/stages/otp_time/apps.py deleted file mode 100644 index c4421055..00000000 --- a/passbook/stages/otp_time/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OTP Time""" -from django.apps import AppConfig - - -class PassbookStageOTPTimeConfig(AppConfig): - """OTP time App config""" - - name = "passbook.stages.otp_time" - label = "passbook_stages_otp_time" - verbose_name = "passbook OTP.Time" - mountpoint = "-/user/otp/time/" diff --git a/passbook/stages/otp_time/forms.py b/passbook/stages/otp_time/forms.py deleted file mode 100644 index 3a6e71f3..00000000 --- a/passbook/stages/otp_time/forms.py +++ /dev/null @@ -1,62 +0,0 @@ -"""OTP Time forms""" -from django import forms -from django.utils.safestring import mark_safe -from django.utils.translation import gettext_lazy as _ -from django_otp.models import Device - -from passbook.stages.otp_time.models import OTPTimeStage - - -class PictureWidget(forms.widgets.Widget): - """Widget to render value as img-tag""" - - def render(self, name, value, attrs=None, renderer=None): - return mark_safe(f"
{value}") # nosec - - -class SetupForm(forms.Form): - """Form to setup Time-based OTP""" - - device: Device = None - - qr_code = forms.CharField( - widget=PictureWidget, - disabled=True, - required=False, - label=_("Scan this Code with your OTP App."), - ) - code = forms.CharField( - label=_("Please enter the Token on your device."), - widget=forms.TextInput( - attrs={ - "autocomplete": "off", - "placeholder": "Code", - "autofocus": "autofocus", - } - ), - ) - - def __init__(self, device, qr_code, *args, **kwargs): - super().__init__(*args, **kwargs) - self.device = device - self.fields["qr_code"].initial = qr_code - - def clean_code(self): - """Check code with new otp device""" - if self.device is not None: - if not self.device.verify_token(self.cleaned_data.get("code")): - raise forms.ValidationError(_("OTP Code does not match")) - return self.cleaned_data.get("code") - - -class OTPTimeStageForm(forms.ModelForm): - """OTP Time-based Stage setup form""" - - class Meta: - - model = OTPTimeStage - fields = ["name", "configure_flow", "digits"] - - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/otp_time/migrations/0001_initial.py b/passbook/stages/otp_time/migrations/0001_initial.py deleted file mode 100644 index f2ad1cb5..00000000 --- a/passbook/stages/otp_time/migrations/0001_initial.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-13 15:28 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0007_auto_20200703_2059"), - ] - - operations = [ - migrations.CreateModel( - name="OTPTimeStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ("digits", models.IntegerField(choices=[(6, "Six"), (8, "Eight")])), - ], - options={ - "verbose_name": "OTP Time (TOTP) Setup Stage", - "verbose_name_plural": "OTP Time (TOTP) Setup Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/otp_time/migrations/0002_auto_20200701_1900.py b/passbook/stages/otp_time/migrations/0002_auto_20200701_1900.py deleted file mode 100644 index ab7bbb25..00000000 --- a/passbook/stages/otp_time/migrations/0002_auto_20200701_1900.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.0.7 on 2020-07-01 19:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_otp_time", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="otptimestage", - name="digits", - field=models.IntegerField( - choices=[ - (6, "6 digits, widely compatible"), - (8, "8 digits, not compatible with apps like Google Authenticator"), - ] - ), - ), - ] diff --git a/passbook/stages/otp_time/migrations/0003_otptimestage_configure_flow.py b/passbook/stages/otp_time/migrations/0003_otptimestage_configure_flow.py deleted file mode 100644 index d27bc41f..00000000 --- a/passbook/stages/otp_time/migrations/0003_otptimestage_configure_flow.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-25 10:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0013_auto_20200924_1605"), - ("passbook_stages_otp_time", "0002_auto_20200701_1900"), - ] - - operations = [ - migrations.AddField( - model_name="otptimestage", - name="configure_flow", - field=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="passbook_flows.flow", - ), - ), - ] diff --git a/passbook/stages/otp_time/migrations/0004_default_setup_flow.py b/passbook/stages/otp_time/migrations/0004_default_setup_flow.py deleted file mode 100644 index 2c38df4a..00000000 --- a/passbook/stages/otp_time/migrations/0004_default_setup_flow.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-25 15:36 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.flows.models import FlowDesignation -from passbook.stages.otp_time.models import TOTPDigits - - -def create_default_setup_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - - OTPTimeStage = apps.get_model("passbook_stages_otp_time", "OTPTimeStage") - - db_alias = schema_editor.connection.alias - - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-otp-time-configure", - designation=FlowDesignation.STAGE_CONFIGURATION, - defaults={ - "name": "default-otp-time-configure", - "title": "Setup Two-Factor authentication", - }, - ) - - stage, _ = OTPTimeStage.objects.using(db_alias).update_or_create( - name="default-otp-time-configure", 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): - stage.configure_flow = flow - stage.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_otp_time", "0003_otptimestage_configure_flow"), - ] - - operations = [ - migrations.RunPython(create_default_setup_flow), - ] diff --git a/passbook/stages/otp_time/models.py b/passbook/stages/otp_time/models.py deleted file mode 100644 index e7603eca..00000000 --- a/passbook/stages/otp_time/models.py +++ /dev/null @@ -1,57 +0,0 @@ -"""OTP Time-based 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 passbook.flows.models import ConfigurableStage, Stage - - -class TOTPDigits(models.IntegerChoices): - """OTP Time Digits""" - - SIX = 6, _("6 digits, widely compatible") - EIGHT = 8, _("8 digits, not compatible with apps like Google Authenticator") - - -class OTPTimeStage(ConfigurableStage, Stage): - """Enroll a user's device into Time-based OTP.""" - - digits = models.IntegerField(choices=TOTPDigits.choices) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.otp_time.api import OTPTimeStageSerializer - - return OTPTimeStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.otp_time.stage import OTPTimeStageView - - return OTPTimeStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.otp_time.forms import OTPTimeStageForm - - return OTPTimeStageForm - - @property - def ui_user_settings(self) -> Optional[str]: - return reverse( - "passbook_stages_otp_time:user-settings", - kwargs={"stage_uuid": self.stage_uuid}, - ) - - def __str__(self) -> str: - return f"OTP Time (TOTP) Stage {self.name}" - - class Meta: - - verbose_name = _("OTP Time (TOTP) Setup Stage") - verbose_name_plural = _("OTP Time (TOTP) Setup Stages") diff --git a/passbook/stages/otp_time/settings.py b/passbook/stages/otp_time/settings.py deleted file mode 100644 index 5392069f..00000000 --- a/passbook/stages/otp_time/settings.py +++ /dev/null @@ -1,6 +0,0 @@ -"""OTP Time""" - -INSTALLED_APPS = [ - "django_otp.plugins.otp_totp", -] -OTP_TOTP_ISSUER = "passbook" diff --git a/passbook/stages/otp_time/stage.py b/passbook/stages/otp_time/stage.py deleted file mode 100644 index 25102764..00000000 --- a/passbook/stages/otp_time/stage.py +++ /dev/null @@ -1,66 +0,0 @@ -"""TOTP Setup stage""" -from typing import Any, Dict - -from django.http import HttpRequest, HttpResponse -from django.utils.encoding import force_str -from django.views.generic import FormView -from django_otp.plugins.otp_totp.models import TOTPDevice -from lxml.etree import tostring # nosec -from qrcode import QRCode -from qrcode.image.svg import SvgFillImage -from structlog import get_logger - -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.stages.otp_time.forms import SetupForm -from passbook.stages.otp_time.models import OTPTimeStage - -LOGGER = get_logger() -SESSION_TOTP_DEVICE = "totp_device" - - -class OTPTimeStageView(FormView, StageView): - """OTP totp Setup stage""" - - form_class = SetupForm - - def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: - kwargs = super().get_form_kwargs(**kwargs) - device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] - kwargs["device"] = device - kwargs["qr_code"] = self._get_qr_code(device) - return kwargs - - def _get_qr_code(self, device: TOTPDevice) -> str: - """Get QR Code SVG as string based on `device`""" - qr_code = QRCode(image_factory=SvgFillImage) - qr_code.add_data(device.config_url) - svg_image = tostring(qr_code.make_image().get_image()) - sr_wrapper = f'
{force_str(svg_image)}
' - return sr_wrapper - - 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() - - # Currently, this stage only supports one device per user. If the user already - # has a device, just skip to the next stage - if TOTPDevice.objects.filter(user=user).exists(): - return self.executor.stage_ok() - - stage: OTPTimeStage = self.executor.current_stage - - if SESSION_TOTP_DEVICE not in self.request.session: - device = TOTPDevice(user=user, confirmed=True, digits=stage.digits) - - self.request.session[SESSION_TOTP_DEVICE] = device - return super().get(request, *args, **kwargs) - - def form_valid(self, form: SetupForm) -> HttpResponse: - """Verify OTP Token""" - device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] - device.save() - del self.request.session[SESSION_TOTP_DEVICE] - return self.executor.stage_ok() diff --git a/passbook/stages/otp_time/templates/stages/otp_time/user_settings.html b/passbook/stages/otp_time/templates/stages/otp_time/user_settings.html deleted file mode 100644 index 96b27e60..00000000 --- a/passbook/stages/otp_time/templates/stages/otp_time/user_settings.html +++ /dev/null @@ -1,28 +0,0 @@ -{% load i18n %} - -
-
- {% trans "Time-based One-Time Passwords" %} -
-
-

- {% blocktrans with state=state|yesno:"Enabled,Disabled" %} - Status: {{ state }} - {% endblocktrans %} - {% if state %} - - {% else %} - - {% endif %} -

-

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

-
-
diff --git a/passbook/stages/otp_time/urls.py b/passbook/stages/otp_time/urls.py deleted file mode 100644 index a4a81ac9..00000000 --- a/passbook/stages/otp_time/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OTP Time urls""" -from django.urls import path - -from passbook.stages.otp_time.views import DisableView, UserSettingsView - -urlpatterns = [ - path( - "/settings/", UserSettingsView.as_view(), name="user-settings" - ), - path("/disable/", DisableView.as_view(), name="disable"), -] diff --git a/passbook/stages/otp_time/views.py b/passbook/stages/otp_time/views.py deleted file mode 100644 index 2b5cbba9..00000000 --- a/passbook/stages/otp_time/views.py +++ /dev/null @@ -1,41 +0,0 @@ -"""otp time-based view""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.views import View -from django.views.generic import TemplateView -from django_otp.plugins.otp_totp.models import TOTPDevice - -from passbook.audit.models import Event -from passbook.stages.otp_time.models import OTPTimeStage - - -class UserSettingsView(LoginRequiredMixin, TemplateView): - """View for user settings to control OTP""" - - template_name = "stages/otp_time/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"]) - kwargs["stage"] = stage - - totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True) - kwargs["state"] = totp_devices.exists() - return kwargs - - -class DisableView(LoginRequiredMixin, View): - """Disable TOTP for user""" - - def get(self, request: HttpRequest) -> HttpResponse: - """Delete all the devices for user""" - totp = TOTPDevice.objects.filter(user=request.user, confirmed=True) - totp.delete() - messages.success(request, "Successfully disabled Time-based OTP") - # Create event with email notification - Event.new("totp_disable", message="User disabled Time-based OTP.").from_http( - request - ) - return redirect("passbook_stages_otp:otp-user-settings") diff --git a/passbook/stages/otp_validate/api.py b/passbook/stages/otp_validate/api.py deleted file mode 100644 index 5f6ccbb5..00000000 --- a/passbook/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 passbook.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/passbook/stages/otp_validate/apps.py b/passbook/stages/otp_validate/apps.py deleted file mode 100644 index 761d24ec..00000000 --- a/passbook/stages/otp_validate/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""OTP Validation Stage""" -from django.apps import AppConfig - - -class PassbookStageOTPValidateConfig(AppConfig): - """OTP Validation Stage""" - - name = "passbook.stages.otp_validate" - label = "passbook_stages_otp_validate" - verbose_name = "passbook OTP.Validate" diff --git a/passbook/stages/otp_validate/forms.py b/passbook/stages/otp_validate/forms.py deleted file mode 100644 index 2886120a..00000000 --- a/passbook/stages/otp_validate/forms.py +++ /dev/null @@ -1,49 +0,0 @@ -"""OTP Validate stage forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ -from django_otp import match_token - -from passbook.core.models import User -from passbook.stages.otp_validate.models import OTPValidateStage - - -class ValidationForm(forms.Form): - """OTP Validate stage forms""" - - user: User - - code = forms.CharField( - label=_("Please enter the token from your device."), - widget=forms.TextInput( - attrs={ - "autocomplete": "off", - "placeholder": "123456", - "autofocus": "autofocus", - } - ), - ) - - def __init__(self, user, *args, **kwargs): - super().__init__(*args, **kwargs) - self.user = user - - def clean_code(self): - """Validate code against all confirmed devices""" - code = self.cleaned_data.get("code") - device = match_token(self.user, code) - if not device: - raise forms.ValidationError(_("Invalid Token")) - return code - - -class OTPValidateStageForm(forms.ModelForm): - """OTP Validate stage forms""" - - class Meta: - - model = OTPValidateStage - fields = ["name"] - - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/otp_validate/migrations/0001_initial.py b/passbook/stages/otp_validate/migrations/0001_initial.py deleted file mode 100644 index e3966375..00000000 --- a/passbook/stages/otp_validate/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-13 15:28 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0007_auto_20200703_2059"), - ] - - operations = [ - migrations.CreateModel( - name="OTPValidateStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ( - "not_configured_action", - models.TextField(choices=[("skip", "Skip")], default="skip"), - ), - ], - options={ - "verbose_name": "OTP Validation Stage", - "verbose_name_plural": "OTP Validation Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/otp_validate/models.py b/passbook/stages/otp_validate/models.py deleted file mode 100644 index 145c79f0..00000000 --- a/passbook/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 passbook.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 passbook.stages.otp_validate.api import OTPValidateStageSerializer - - return OTPValidateStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.otp_validate.stage import OTPValidateStageView - - return OTPValidateStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.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/passbook/stages/otp_validate/stage.py b/passbook/stages/otp_validate/stage.py deleted file mode 100644 index 5d378e4c..00000000 --- a/passbook/stages/otp_validate/stage.py +++ /dev/null @@ -1,46 +0,0 @@ -"""OTP Validation""" -from typing import Any, Dict - -from django.http import HttpRequest, HttpResponse -from django.views.generic import FormView -from django_otp import user_has_device -from structlog import get_logger - -from passbook.flows.models import NotConfiguredAction -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.stages.otp_validate.forms import ValidationForm -from passbook.stages.otp_validate.models import OTPValidateStage - -LOGGER = get_logger() - - -class OTPValidateStageView(FormView, StageView): - """OTP Validation""" - - form_class = ValidationForm - - def get_form_kwargs(self, **kwargs) -> Dict[str, Any]: - kwargs = super().get_form_kwargs(**kwargs) - kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) - return kwargs - - 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() - has_devices = user_has_device(user) - stage: OTPValidateStage = self.executor.current_stage - - if not has_devices: - if stage.not_configured_action == NotConfiguredAction.SKIP: - LOGGER.debug("OTP not configured, skipping stage") - return self.executor.stage_ok() - return super().get(request, *args, **kwargs) - - def form_valid(self, form: ValidationForm) -> HttpResponse: - """Verify OTP Token""" - # Since we do token checking in the form, we know the token is valid here - # so we can just continue - return self.executor.stage_ok() diff --git a/passbook/stages/password/api.py b/passbook/stages/password/api.py deleted file mode 100644 index 9da54f9b..00000000 --- a/passbook/stages/password/api.py +++ /dev/null @@ -1,27 +0,0 @@ -"""PasswordStage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.password.models import PasswordStage - - -class PasswordStageSerializer(ModelSerializer): - """PasswordStage Serializer""" - - class Meta: - - model = PasswordStage - fields = [ - "pk", - "name", - "backends", - "configure_flow", - "failed_attempts_before_cancel", - ] - - -class PasswordStageViewSet(ModelViewSet): - """PasswordStage Viewset""" - - queryset = PasswordStage.objects.all() - serializer_class = PasswordStageSerializer diff --git a/passbook/stages/password/apps.py b/passbook/stages/password/apps.py deleted file mode 100644 index 4fbf70ad..00000000 --- a/passbook/stages/password/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""passbook core app config""" -from django.apps import AppConfig - - -class PassbookStagePasswordConfig(AppConfig): - """passbook password stage config""" - - name = "passbook.stages.password" - label = "passbook_stages_password" - verbose_name = "passbook Stages.Password" - mountpoint = "-/user/password/" diff --git a/passbook/stages/password/forms.py b/passbook/stages/password/forms.py deleted file mode 100644 index 6f03030a..00000000 --- a/passbook/stages/password/forms.py +++ /dev/null @@ -1,57 +0,0 @@ -"""passbook administration forms""" -from django import forms -from django.utils.translation import gettext_lazy as _ - -from passbook.flows.models import Flow, FlowDesignation -from passbook.stages.password.models import PasswordStage - - -def get_authentication_backends(): - """Return all available authentication backends as tuple set""" - return [ - ( - "django.contrib.auth.backends.ModelBackend", - _("passbook-internal Userdatabase"), - ), - ( - "passbook.sources.ldap.auth.LDAPBackend", - _("passbook LDAP"), - ), - ] - - -class PasswordForm(forms.Form): - """Password authentication form""" - - username = forms.CharField( - widget=forms.HiddenInput(attrs={"autocomplete": "username"}), required=False - ) - password = forms.CharField( - label=_("Please enter your password."), - widget=forms.PasswordInput( - attrs={ - "placeholder": _("Password"), - "autofocus": "autofocus", - "autocomplete": "current-password", - } - ), - ) - - -class PasswordStageForm(forms.ModelForm): - """Form to create/edit Password Stages""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["configure_flow"].queryset = Flow.objects.filter( - designation=FlowDesignation.STAGE_CONFIGURATION - ) - - class Meta: - - model = PasswordStage - fields = ["name", "backends", "configure_flow", "failed_attempts_before_cancel"] - widgets = { - "name": forms.TextInput(), - "backends": forms.SelectMultiple(get_authentication_backends()), - } diff --git a/passbook/stages/password/migrations/0001_initial.py b/passbook/stages/password/migrations/0001_initial.py deleted file mode 100644 index 52a8050c..00000000 --- a/passbook/stages/password/migrations/0001_initial.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.contrib.postgres.fields -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PasswordStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ( - "backends", - django.contrib.postgres.fields.ArrayField( - base_field=models.TextField(), - help_text="Selection of backends to test the password against.", - size=None, - ), - ), - ], - options={ - "verbose_name": "Password Stage", - "verbose_name_plural": "Password Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/password/migrations/0002_passwordstage_change_flow.py b/passbook/stages/password/migrations/0002_passwordstage_change_flow.py deleted file mode 100644 index 2d1fbb38..00000000 --- a/passbook/stages/password/migrations/0002_passwordstage_change_flow.py +++ /dev/null @@ -1,109 +0,0 @@ -# Generated by Django 3.0.7 on 2020-06-29 08:51 - -import django.db.models.deletion -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -from passbook.flows.models import FlowDesignation -from passbook.stages.prompt.models import FieldTypes - - -def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - Flow = apps.get_model("passbook_flows", "Flow") - FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") - - PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage") - Prompt = apps.get_model("passbook_stages_prompt", "Prompt") - - UserWriteStage = apps.get_model("passbook_stages_user_write", "UserWriteStage") - - db_alias = schema_editor.connection.alias - - flow, _ = Flow.objects.using(db_alias).update_or_create( - slug="default-password-change", - designation=FlowDesignation.STAGE_CONFIGURATION, - defaults={"name": "Change Password"}, - ) - - prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( - name="Change your password", - ) - password_prompt, _ = Prompt.objects.using(db_alias).update_or_create( - field_key="password", - defaults={ - "label": "Password", - "type": FieldTypes.PASSWORD, - "required": True, - "placeholder": "Password", - "order": 0, - }, - ) - password_rep_prompt, _ = Prompt.objects.using(db_alias).update_or_create( - field_key="password_repeat", - defaults={ - "label": "Password (repeat)", - "type": FieldTypes.PASSWORD, - "required": True, - "placeholder": "Password (repeat)", - "order": 1, - }, - ) - - prompt_stage.fields.add(password_prompt) - prompt_stage.fields.add(password_rep_prompt) - prompt_stage.save() - - user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( - name="default-password-change-write" - ) - - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, stage=prompt_stage, defaults={"order": 0} - ) - FlowStageBinding.objects.using(db_alias).update_or_create( - target=flow, stage=user_write, defaults={"order": 1} - ) - - -def update_default_stage_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage") - Flow = apps.get_model("passbook_flows", "Flow") - - flow = Flow.objects.get( - slug="default-password-change", - designation=FlowDesignation.STAGE_CONFIGURATION, - ) - - stages = PasswordStage.objects.filter(name="default-authentication-password") - if not stages.exists(): - return - stage = stages.first() - stage.change_flow = flow - stage.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0006_auto_20200629_0857"), - ("passbook_stages_password", "0001_initial"), - ("passbook_stages_prompt", "0001_initial"), - ("passbook_stages_user_write", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="passwordstage", - name="change_flow", - field=models.ForeignKey( - blank=True, - help_text="Flow used by an authenticated user to change their password. If empty, user will be unable to change their password.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="passbook_flows.Flow", - ), - ), - migrations.RunPython(create_default_password_change), - migrations.RunPython(update_default_stage_change), - ] diff --git a/passbook/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py b/passbook/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py deleted file mode 100644 index ef7f63a8..00000000 --- a/passbook/stages/password/migrations/0003_passwordstage_failed_attempts_before_cancel.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-18 23:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_password", "0002_passwordstage_change_flow"), - ] - - operations = [ - migrations.AddField( - model_name="passwordstage", - name="failed_attempts_before_cancel", - field=models.IntegerField( - default=5, - help_text="How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage.", - ), - ), - ] diff --git a/passbook/stages/password/migrations/0004_auto_20200925_1057.py b/passbook/stages/password/migrations/0004_auto_20200925_1057.py deleted file mode 100644 index 1557b4e3..00000000 --- a/passbook/stages/password/migrations/0004_auto_20200925_1057.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-25 10:57 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_flows", "0013_auto_20200924_1605"), - ( - "passbook_stages_password", - "0003_passwordstage_failed_attempts_before_cancel", - ), - ] - - operations = [ - migrations.RenameField( - model_name="passwordstage", - old_name="change_flow", - new_name="configure_flow", - ), - migrations.AlterField( - model_name="passwordstage", - name="configure_flow", - field=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="passbook_flows.flow", - ), - ), - ] diff --git a/passbook/stages/password/models.py b/passbook/stages/password/models.py deleted file mode 100644 index 7c07b5a5..00000000 --- a/passbook/stages/password/models.py +++ /dev/null @@ -1,64 +0,0 @@ -"""password stage models""" -from typing import Optional, Type - -from django.contrib.postgres.fields import ArrayField -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 passbook.flows.models import ConfigurableStage, Stage - - -class PasswordStage(ConfigurableStage, Stage): - """Prompts the user for their password, and validates it against the configured backends.""" - - backends = ArrayField( - models.TextField(), - help_text=_("Selection of backends to test the password against."), - ) - failed_attempts_before_cancel = models.IntegerField( - default=5, - help_text=_( - ( - "How many attempts a user has before the flow is canceled. " - "To lock the user out, use a reputation policy and a user_write stage." - ) - ), - ) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.password.api import PasswordStageSerializer - - return PasswordStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.password.stage import PasswordStageView - - return PasswordStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.password.forms import PasswordStageForm - - return PasswordStageForm - - @property - def ui_user_settings(self) -> Optional[str]: - if not self.configure_flow: - return None - return reverse( - "passbook_stages_password:user-settings", kwargs={"stage_uuid": self.pk} - ) - - def __str__(self): - return f"Password Stage {self.name}" - - class Meta: - - verbose_name = _("Password Stage") - verbose_name_plural = _("Password Stages") diff --git a/passbook/stages/password/stage.py b/passbook/stages/password/stage.py deleted file mode 100644 index 90c7c04e..00000000 --- a/passbook/stages/password/stage.py +++ /dev/null @@ -1,123 +0,0 @@ -"""passbook password stage""" -from typing import Any, Dict, List, Optional - -from django.contrib.auth import _clean_credentials -from django.contrib.auth.backends import BaseBackend -from django.contrib.auth.signals import user_login_failed -from django.core.exceptions import PermissionDenied -from django.forms.utils import ErrorList -from django.http import HttpRequest, HttpResponse -from django.utils.translation import gettext as _ -from django.views.generic import FormView -from structlog import get_logger - -from passbook.core.models import User -from passbook.flows.models import Flow, FlowDesignation -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.lib.utils.reflection import path_to_class -from passbook.stages.password.forms import PasswordForm -from passbook.stages.password.models import PasswordStage - -LOGGER = get_logger() -PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend" -SESSION_INVALID_TRIES = "user_invalid_tries" - - -def authenticate( - request: HttpRequest, backends: List[str], **credentials: Dict[str, Any] -) -> Optional[User]: - """If the given credentials are valid, return a User object. - - Customized version of django's authenticate, which accepts a list of backends""" - for backend_path in backends: - backend: BaseBackend = path_to_class(backend_path)() - LOGGER.debug("Attempting authentication...", backend=backend) - user = backend.authenticate(request, **credentials) - if user is None: - LOGGER.debug("Backend returned nothing, continuing") - continue - # Annotate the user object with the path of the backend. - user.backend = backend_path - LOGGER.debug("Successful authentication", user=user, backend=backend) - return user - - # The credentials supplied are invalid to all backends, fire signal - user_login_failed.send( - sender=__name__, credentials=_clean_credentials(credentials), request=request - ) - - -class PasswordStageView(FormView, StageView): - """Authentication stage which authenticates against django's AuthBackend""" - - form_class = PasswordForm - template_name = "stages/password/flow-form.html" - - def get_form(self, form_class=None) -> PasswordForm: - form = super().get_form(form_class=form_class) - - # If there's a pending user, update the `username` field - # this field is only used by password managers. - # If there's no user set, an error is raised later. - if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: - pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - form.fields["username"].initial = pending_user.username - - return form - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY) - if recovery_flow.exists(): - kwargs["recovery_flow"] = recovery_flow.first() - return kwargs - - def form_invalid(self, form: PasswordForm) -> HttpResponse: - if SESSION_INVALID_TRIES not in self.request.session: - self.request.session[SESSION_INVALID_TRIES] = 0 - self.request.session[SESSION_INVALID_TRIES] += 1 - current_stage: PasswordStage = self.executor.current_stage - if ( - self.request.session[SESSION_INVALID_TRIES] - > current_stage.failed_attempts_before_cancel - ): - LOGGER.debug("User has exceeded maximum tries") - del self.request.session[SESSION_INVALID_TRIES] - return self.executor.stage_invalid() - return super().form_invalid(form) - - def form_valid(self, form: PasswordForm) -> HttpResponse: - """Authenticate against django's authentication backend""" - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: - return self.executor.stage_invalid() - # Get the pending user's username, which is used as - # an Identifier by most authentication backends - pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - auth_kwargs = { - "password": form.cleaned_data.get("password", None), - "username": pending_user.username, - } - try: - user = authenticate( - self.request, self.executor.current_stage.backends, **auth_kwargs - ) - except PermissionDenied: - del auth_kwargs["password"] - # User was found, but permission was denied (i.e. user is not active) - LOGGER.debug("Denied access", **auth_kwargs) - return self.executor.stage_invalid() - else: - if not user: - # No user was found -> invalid credentials - LOGGER.debug("Invalid credentials") - # Manually inject error into form - errors = form._errors.setdefault("password", ErrorList()) - errors.append(_("Invalid password")) - return self.form_invalid(form) - # User instance returned from authenticate() has .backend property set - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user - self.executor.plan.context[ - PLAN_CONTEXT_AUTHENTICATION_BACKEND - ] = user.backend - return self.executor.stage_ok() diff --git a/passbook/stages/password/templates/stages/password/flow-form.html b/passbook/stages/password/templates/stages/password/flow-form.html deleted file mode 100644 index fc313d52..00000000 --- a/passbook/stages/password/templates/stages/password/flow-form.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends 'login/form_with_user.html' %} - -{% load i18n %} -{% load passbook_utils %} - -{% block beneath_form %} -{% if recovery_flow %} -{% trans 'Forgot password?' %} -{% endif %} -{% endblock %} diff --git a/passbook/stages/password/templates/stages/password/user-settings-card.html b/passbook/stages/password/templates/stages/password/user-settings-card.html deleted file mode 100644 index 35b257df..00000000 --- a/passbook/stages/password/templates/stages/password/user-settings-card.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base/page.html" %} - -{% load i18n %} -{% load passbook_utils %} - -{% block body %} -
-
- {% trans 'Reset your password' %} -
- -
-{% endblock %} diff --git a/passbook/stages/password/tests.py b/passbook/stages/password/tests.py deleted file mode 100644 index f616b16e..00000000 --- a/passbook/stages/password/tests.py +++ /dev/null @@ -1,194 +0,0 @@ -"""password tests""" -import string -from random import SystemRandom -from unittest.mock import MagicMock, patch - -from django.core.exceptions import PermissionDenied -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.policies.http import AccessDeniedResponse -from passbook.stages.password.models import PasswordStage - -MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test")) - - -class TestPasswordStage(TestCase): - """Password tests""" - - def setUp(self): - super().setUp() - self.password = "".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) - for _ in range(8) - ) - self.user = User.objects.create_user( - username="unittest", email="test@beryju.org", password=self.password - ) - self.client = Client() - - self.flow = Flow.objects.create( - name="test-password", - slug="test-password", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = PasswordStage.objects.create( - name="password", backends=["django.contrib.auth.backends.ModelBackend"] - ) - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - def test_without_user(self): - """Test without user""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - # Still have to send the password so the form is valid - {"password": self.password}, - ) - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - - def test_recovery_flow_link(self): - """Test link to the default recovery flow""" - flow = Flow.objects.create( - designation=FlowDesignation.RECOVERY, slug="qewrqerqr" - ) - - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - ) - self.assertEqual(response.status_code, 200) - self.assertIn(flow.slug, force_str(response.content)) - - def test_valid_password(self): - """Test with a valid pending user and valid password""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - # Form data - {"password": self.password}, - ) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - def test_invalid_password(self): - """Test with a valid pending user and invalid password""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - # Form data - {"password": self.password + "test"}, - ) - self.assertEqual(response.status_code, 200) - - def test_invalid_password_lockout(self): - """Test with a valid pending user and invalid password (trigger logout counter)""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - for _ in range(self.stage.failed_attempts_before_cancel): - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - # Form data - {"password": self.password + "test"}, - ) - self.assertEqual(response.status_code, 200) - - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - # Form data - {"password": self.password + "test"}, - ) - self.assertEqual(response.status_code, 200) - # To ensure the plan has been cancelled, check SESSION_KEY_PLAN - self.assertNotIn(SESSION_KEY_PLAN, self.client.session) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - @patch( - "django.contrib.auth.backends.ModelBackend.authenticate", - MOCK_BACKEND_AUTHENTICATE, - ) - def test_permission_denied(self): - """Test with a valid pending user and valid password. - Backend is patched to return PermissionError""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - # Form data - {"password": self.password + "test"}, - ) - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) diff --git a/passbook/stages/password/urls.py b/passbook/stages/password/urls.py deleted file mode 100644 index d8732fd6..00000000 --- a/passbook/stages/password/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Password stage urls""" -from django.urls import path - -from passbook.stages.password.views import UserSettingsCardView - -urlpatterns = [ - path( - "/change-card/", - UserSettingsCardView.as_view(), - name="user-settings", - ), -] diff --git a/passbook/stages/password/views.py b/passbook/stages/password/views.py deleted file mode 100644 index 4bfe376f..00000000 --- a/passbook/stages/password/views.py +++ /dev/null @@ -1,25 +0,0 @@ -"""password stage user settings card""" -from typing import Any - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import reverse -from django.utils.http import urlencode -from django.views.generic import TemplateView - -from passbook.flows.views import NEXT_ARG_NAME - - -class UserSettingsCardView(LoginRequiredMixin, TemplateView): - """Card shown on user settings page to allow user to change their password""" - - template_name = "stages/password/user-settings-card.html" - - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: - base_url = reverse( - "passbook_flows:configure", kwargs={"stage_uuid": self.kwargs["stage_uuid"]} - ) - args = urlencode({NEXT_ARG_NAME: reverse("passbook_core:user-settings")}) - - kwargs = super().get_context_data(**kwargs) - kwargs["url"] = f"{base_url}?{args}" - return kwargs diff --git a/passbook/stages/prompt/api.py b/passbook/stages/prompt/api.py deleted file mode 100644 index a27d92b6..00000000 --- a/passbook/stages/prompt/api.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Prompt Stage API Views""" -from rest_framework.serializers import CharField, ModelSerializer -from rest_framework.validators import UniqueValidator -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.prompt.models import Prompt, PromptStage - - -class PromptStageSerializer(ModelSerializer): - """PromptStage Serializer""" - - name = CharField(validators=[UniqueValidator(queryset=PromptStage.objects.all())]) - - class Meta: - - model = PromptStage - fields = [ - "pk", - "name", - "fields", - "validation_policies", - ] - - -class PromptStageViewSet(ModelViewSet): - """PromptStage Viewset""" - - queryset = PromptStage.objects.all() - serializer_class = PromptStageSerializer - - -class PromptSerializer(ModelSerializer): - """Prompt Serializer""" - - class Meta: - - model = Prompt - fields = [ - "pk", - "field_key", - "label", - "type", - "required", - "placeholder", - "order", - ] - - -class PromptViewSet(ModelViewSet): - """Prompt Viewset""" - - queryset = Prompt.objects.all() - serializer_class = PromptSerializer diff --git a/passbook/stages/prompt/apps.py b/passbook/stages/prompt/apps.py deleted file mode 100644 index 78382338..00000000 --- a/passbook/stages/prompt/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook prompt stage app config""" -from django.apps import AppConfig - - -class PassbookStagPromptConfig(AppConfig): - """passbook prompt stage config""" - - name = "passbook.stages.prompt" - label = "passbook_stages_prompt" - verbose_name = "passbook Stages.Prompt" diff --git a/passbook/stages/prompt/forms.py b/passbook/stages/prompt/forms.py deleted file mode 100644 index a2bfc3ca..00000000 --- a/passbook/stages/prompt/forms.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Prompt forms""" -from email.policy import Policy -from types import MethodType -from typing import Any, Callable, Iterator, List - -from django import forms -from django.db.models.query import QuerySet -from django.http import HttpRequest -from django.utils.translation import gettext_lazy as _ -from guardian.shortcuts import get_anonymous_user - -from passbook.core.models import User -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.policies.engine import PolicyEngine -from passbook.policies.models import PolicyBinding, PolicyBindingModel -from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage -from passbook.stages.prompt.signals import password_validate - - -class PromptStageForm(forms.ModelForm): - """Form to create/edit Prompt Stage instances""" - - class Meta: - - model = PromptStage - fields = ["name", "fields", "validation_policies"] - widgets = { - "name": forms.TextInput(), - } - - -class PromptAdminForm(forms.ModelForm): - """Form to edit Prompt instances for admins""" - - class Meta: - - model = Prompt - fields = [ - "field_key", - "label", - "type", - "required", - "placeholder", - "order", - ] - widgets = { - "label": forms.TextInput(), - "placeholder": forms.TextInput(), - } - - -class ListPolicyEngine(PolicyEngine): - """Slightly modified policy engine, which uses a list instead of a PolicyBindingModel""" - - __list: List[Policy] - - def __init__( - self, policies: List[Policy], user: User, request: HttpRequest = None - ) -> None: - super().__init__(PolicyBindingModel(), user, request) - self.__list = policies - self.use_cache = False - - def _iter_bindings(self) -> Iterator[PolicyBinding]: - for policy in self.__list: - yield PolicyBinding( - policy=policy, - ) - - -class PromptForm(forms.Form): - """Dynamically created form based on PromptStage""" - - stage: PromptStage - plan: FlowPlan - - def __init__(self, stage: PromptStage, plan: FlowPlan, *args, **kwargs): - self.stage = stage - self.plan = plan - super().__init__(*args, **kwargs) - # list() is called so we only load the fields once - fields = list(self.stage.fields.all()) - for field in fields: - field: Prompt - self.fields[field.field_key] = field.field - # Special handling for fields with username type - # these check for existing users with the same username - if field.type == FieldTypes.USERNAME: - setattr( - self, - f"clean_{field.field_key}", - MethodType(username_field_cleaner_factory(field), self), - ) - # Check if we have a password field, add a handler that sends a signal - # to validate it - if field.type == FieldTypes.PASSWORD: - setattr( - self, - f"clean_{field.field_key}", - MethodType(password_single_cleaner_factory(field), self), - ) - - self.field_order = sorted(fields, key=lambda x: x.order) - - def _clean_password_fields(self, *field_names): - """Check if the value of all password fields match by merging them into a set - and checking the length""" - all_passwords = {self.cleaned_data[x] for x in field_names} - if len(all_passwords) > 1: - raise forms.ValidationError(_("Passwords don't match.")) - - def clean(self): - cleaned_data = super().clean() - if cleaned_data == {}: - return {} - # Check if we have two password fields, and make sure they are the same - password_fields: QuerySet[Prompt] = self.stage.fields.filter( - type=FieldTypes.PASSWORD - ) - if password_fields.exists() and password_fields.count() == 2: - self._clean_password_fields(*[field.field_key for field in password_fields]) - - user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user()) - engine = ListPolicyEngine(self.stage.validation_policies.all(), user) - engine.request.context = cleaned_data - engine.build() - result = engine.result - if not result.passing: - raise forms.ValidationError(list(result.messages)) - return cleaned_data - - -def username_field_cleaner_factory(field: Prompt) -> Callable: - """Return a `clean_` method for `field`. Clean method checks if username is taken already.""" - - def username_field_cleaner(self: PromptForm) -> Any: - """Check for duplicate usernames""" - username = self.cleaned_data.get(field.field_key) - if User.objects.filter(username=username).exists(): - raise forms.ValidationError("Username is already taken.") - return username - - return username_field_cleaner - - -def password_single_cleaner_factory(field: Prompt) -> Callable[[PromptForm], Any]: - """Return a `clean_` method for `field`. Clean method checks if username is taken already.""" - - def password_single_clean(self: PromptForm) -> Any: - """Send password validation signals for e.g. LDAP Source""" - password = self.cleaned_data[field.field_key] - password_validate.send( - sender=self, password=password, plan_context=self.plan.context - ) - return password - - return password_single_clean diff --git a/passbook/stages/prompt/migrations/0001_initial.py b/passbook/stages/prompt/migrations/0001_initial.py deleted file mode 100644 index 9f4a56a3..00000000 --- a/passbook/stages/prompt/migrations/0001_initial.py +++ /dev/null @@ -1,98 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-09 08:40 - -import uuid - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0007_auto_20200703_2059"), - ("passbook_policies", "0003_auto_20200908_1542"), - ] - - operations = [ - migrations.CreateModel( - name="Prompt", - fields=[ - ( - "prompt_uuid", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "field_key", - models.SlugField( - help_text="Name of the form field, also used to store the value" - ), - ), - ("label", models.TextField()), - ( - "type", - models.CharField( - choices=[ - ("text", "Text: Simple Text input"), - ( - "username", - "Username: Same as Text input, but checks for and prevents duplicate usernames.", - ), - ("email", "Email: Text field with Email type."), - ("password", "Password"), - ("number", "Number"), - ("checkbox", "Checkbox"), - ("data", "Date"), - ("data-time", "Date Time"), - ("separator", "Separator: Static Separator Line"), - ( - "hidden", - "Hidden: Hidden field, can be used to insert data into form.", - ), - ("static", "Static: Static value, displayed as-is."), - ], - max_length=100, - ), - ), - ("required", models.BooleanField(default=True)), - ("placeholder", models.TextField(blank=True)), - ("order", models.IntegerField(default=0)), - ], - options={ - "verbose_name": "Prompt", - "verbose_name_plural": "Prompts", - }, - ), - migrations.CreateModel( - name="PromptStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.stage", - ), - ), - ("fields", models.ManyToManyField(to="passbook_stages_prompt.Prompt")), - ( - "validation_policies", - models.ManyToManyField(blank=True, to="passbook_policies.Policy"), - ), - ], - options={ - "verbose_name": "Prompt Stage", - "verbose_name_plural": "Prompt Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/prompt/migrations/0002_auto_20200920_1859.py b/passbook/stages/prompt/migrations/0002_auto_20200920_1859.py deleted file mode 100644 index 13b7fdd0..00000000 --- a/passbook/stages/prompt/migrations/0002_auto_20200920_1859.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-20 18:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_prompt", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="prompt", - name="type", - field=models.CharField( - choices=[ - ("text", "Text: Simple Text input"), - ( - "username", - "Username: Same as Text input, but checks for and prevents duplicate usernames.", - ), - ("email", "Email: Text field with Email type."), - ( - "password", - "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.", - ), - ("number", "Number"), - ("checkbox", "Checkbox"), - ("data", "Date"), - ("data-time", "Date Time"), - ("separator", "Separator: Static Separator Line"), - ( - "hidden", - "Hidden: Hidden field, can be used to insert data into form.", - ), - ("static", "Static: Static value, displayed as-is."), - ], - max_length=100, - ), - ), - ] diff --git a/passbook/stages/prompt/models.py b/passbook/stages/prompt/models.py deleted file mode 100644 index e19f3d44..00000000 --- a/passbook/stages/prompt/models.py +++ /dev/null @@ -1,166 +0,0 @@ -"""prompt models""" -from typing import Type -from uuid import uuid4 - -from django import forms -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 passbook.flows.models import Stage -from passbook.lib.models import SerializerModel -from passbook.policies.models import Policy -from passbook.stages.prompt.widgets import HorizontalRuleWidget, StaticTextWidget - - -class FieldTypes(models.TextChoices): - """Field types an Prompt can be""" - - # Simple text field - TEXT = "text", _("Text: Simple Text input") - # Same as text, but has autocomplete for password managers - USERNAME = ( - "username", - _( - ( - "Username: Same as Text input, but checks for " - "and prevents duplicate usernames." - ) - ), - ) - EMAIL = "email", _("Email: Text field with Email type.") - PASSWORD = ( - "password", # noqa # nosec - _( - ( - "Password: Masked input, password is validated against sources. Policies still " - "have to be applied to this Stage. If two of these are used in the same stage, " - "they are ensured to be identical." - ) - ), - ) - NUMBER = "number" - CHECKBOX = "checkbox" - DATE = "data" - DATE_TIME = "data-time" - - SEPARATOR = "separator", _("Separator: Static Separator Line") - HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.") - STATIC = "static", _("Static: Static value, displayed as-is.") - - -class Prompt(SerializerModel): - """Single Prompt, part of a prompt stage.""" - - prompt_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - - field_key = models.SlugField( - help_text=_("Name of the form field, also used to store the value") - ) - label = models.TextField() - type = models.CharField(max_length=100, choices=FieldTypes.choices) - required = models.BooleanField(default=True) - placeholder = models.TextField(blank=True) - - order = models.IntegerField(default=0) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.prompt.api import PromptSerializer - - return PromptSerializer - - @property - def field(self): - """Return instantiated form input field""" - attrs = {"placeholder": _(self.placeholder)} - field_class = forms.CharField - widget = forms.TextInput(attrs=attrs) - kwargs = { - "label": _(self.label), - "required": self.required, - } - if self.type == FieldTypes.EMAIL: - field_class = forms.EmailField - if self.type == FieldTypes.USERNAME: - attrs["autocomplete"] = "username" - if self.type == FieldTypes.PASSWORD: - widget = forms.PasswordInput(attrs=attrs) - attrs["autocomplete"] = "new-password" - if self.type == FieldTypes.NUMBER: - field_class = forms.IntegerField - widget = forms.NumberInput(attrs=attrs) - if self.type == FieldTypes.HIDDEN: - widget = forms.HiddenInput(attrs=attrs) - kwargs["required"] = False - kwargs["initial"] = self.placeholder - if self.type == FieldTypes.CHECKBOX: - field_class = forms.BooleanField - kwargs["required"] = False - if self.type == FieldTypes.DATE: - attrs["type"] = "date" - widget = forms.DateInput(attrs=attrs) - if self.type == FieldTypes.DATE_TIME: - attrs["type"] = "datetime-local" - widget = forms.DateTimeInput(attrs=attrs) - if self.type == FieldTypes.STATIC: - widget = StaticTextWidget(attrs=attrs) - kwargs["initial"] = self.placeholder - kwargs["required"] = False - kwargs["label"] = "" - if self.type == FieldTypes.SEPARATOR: - widget = HorizontalRuleWidget(attrs=attrs) - kwargs["required"] = False - kwargs["label"] = "" - - kwargs["widget"] = widget - return field_class(**kwargs) - - def save(self, *args, **kwargs): - if self.type not in FieldTypes: - raise ValueError - return super().save(*args, **kwargs) - - def __str__(self): - return f"Prompt '{self.field_key}' type={self.type}" - - class Meta: - - verbose_name = _("Prompt") - verbose_name_plural = _("Prompts") - - -class PromptStage(Stage): - """Define arbitrary prompts for the user.""" - - fields = models.ManyToManyField(Prompt) - - validation_policies = models.ManyToManyField(Policy, blank=True) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.prompt.api import PromptStageSerializer - - return PromptStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.prompt.stage import PromptStageView - - return PromptStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.prompt.forms import PromptStageForm - - return PromptStageForm - - def __str__(self): - return f"Prompt Stage {self.name}" - - class Meta: - - verbose_name = _("Prompt Stage") - verbose_name_plural = _("Prompt Stages") diff --git a/passbook/stages/prompt/signals.py b/passbook/stages/prompt/signals.py deleted file mode 100644 index 61cc43e1..00000000 --- a/passbook/stages/prompt/signals.py +++ /dev/null @@ -1,5 +0,0 @@ -"""passbook prompt stage signals""" -from django.core.signals import Signal - -# Arguments: password: str, plan_context: Dict[str, Any] -password_validate = Signal() diff --git a/passbook/stages/prompt/stage.py b/passbook/stages/prompt/stage.py deleted file mode 100644 index 8dba3e55..00000000 --- a/passbook/stages/prompt/stage.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Prompt Stage Logic""" -from django.http import HttpResponse -from django.utils.translation import gettext_lazy as _ -from django.views.generic import FormView -from structlog import get_logger - -from passbook.flows.stage import StageView -from passbook.stages.prompt.forms import PromptForm - -LOGGER = get_logger() -PLAN_CONTEXT_PROMPT = "prompt_data" - - -class PromptStageView(FormView, StageView): - """Prompt Stage, save form data in plan context.""" - - template_name = "login/form.html" - form_class = PromptForm - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx["title"] = _(self.executor.current_stage.name) - return ctx - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["stage"] = self.executor.current_stage - kwargs["plan"] = self.executor.plan - return kwargs - - def form_valid(self, form: PromptForm) -> HttpResponse: - """Form data is valid""" - if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: - self.executor.plan.context[PLAN_CONTEXT_PROMPT] = {} - self.executor.plan.context[PLAN_CONTEXT_PROMPT].update(form.cleaned_data) - return self.executor.stage_ok() diff --git a/passbook/stages/prompt/tests.py b/passbook/stages/prompt/tests.py deleted file mode 100644 index 2aeec29d..00000000 --- a/passbook/stages/prompt/tests.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Prompt tests""" -from unittest.mock import MagicMock, patch - -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import FlowPlan -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.policies.expression.models import ExpressionPolicy -from passbook.stages.prompt.forms import PromptForm -from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT - - -class TestPromptStage(TestCase): - """Prompt tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create(username="unittest", email="test@beryju.org") - self.client = Client() - - self.flow = Flow.objects.create( - name="test-prompt", - slug="test-prompt", - designation=FlowDesignation.AUTHENTICATION, - ) - text_prompt = Prompt.objects.create( - field_key="text_prompt", - label="TEXT_LABEL", - type=FieldTypes.TEXT, - required=True, - placeholder="TEXT_PLACEHOLDER", - ) - email_prompt = Prompt.objects.create( - field_key="email_prompt", - label="EMAIL_LABEL", - type=FieldTypes.EMAIL, - required=True, - placeholder="EMAIL_PLACEHOLDER", - ) - password_prompt = Prompt.objects.create( - field_key="password_prompt", - label="PASSWORD_LABEL", - type=FieldTypes.PASSWORD, - required=True, - placeholder="PASSWORD_PLACEHOLDER", - ) - password2_prompt = Prompt.objects.create( - field_key="password2_prompt", - label="PASSWORD_LABEL", - type=FieldTypes.PASSWORD, - required=True, - placeholder="PASSWORD_PLACEHOLDER", - ) - number_prompt = Prompt.objects.create( - field_key="number_prompt", - label="NUMBER_LABEL", - type=FieldTypes.NUMBER, - required=True, - placeholder="NUMBER_PLACEHOLDER", - ) - hidden_prompt = Prompt.objects.create( - field_key="hidden_prompt", - type=FieldTypes.HIDDEN, - required=True, - placeholder="HIDDEN_PLACEHOLDER", - ) - self.stage = PromptStage.objects.create(name="prompt-stage") - self.stage.fields.set( - [ - text_prompt, - email_prompt, - password_prompt, - password2_prompt, - number_prompt, - hidden_prompt, - ] - ) - self.stage.save() - - self.prompt_data = { - text_prompt.field_key: "test-input", - email_prompt.field_key: "test@test.test", - password_prompt.field_key: "test", - password2_prompt.field_key: "test", - number_prompt.field_key: 3, - hidden_prompt.field_key: hidden_prompt.placeholder, - } - - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_render(self): - """Test render of form, check if all prompts are rendered correctly""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 200) - for prompt in self.stage.fields.all(): - self.assertIn(prompt.field_key, force_str(response.content)) - self.assertIn(prompt.label, force_str(response.content)) - self.assertIn(prompt.placeholder, force_str(response.content)) - - def test_valid_form_with_policy(self) -> PromptForm: - """Test form validation""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - expr = "return request.context['password_prompt'] == request.context['password2_prompt']" - expr_policy = ExpressionPolicy.objects.create( - name="validate-form", expression=expr - ) - self.stage.validation_policies.set([expr_policy]) - self.stage.save() - form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data) - self.assertEqual(form.is_valid(), True) - return form - - def test_invalid_form(self) -> PromptForm: - """Test form validation""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - expr = "False" - expr_policy = ExpressionPolicy.objects.create( - name="validate-form", expression=expr - ) - self.stage.validation_policies.set([expr_policy]) - self.stage.save() - form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data) - self.assertEqual(form.is_valid(), False) - return form - - def test_valid_form_request(self): - """Test a request with valid form data""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - form = self.test_valid_form_with_policy() - - with patch("passbook.flows.views.FlowExecutorView.cancel", MagicMock()): - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - form.cleaned_data, - ) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - # Check that valid data has been saved - session = self.client.session - plan: FlowPlan = session[SESSION_KEY_PLAN] - data = plan.context[PLAN_CONTEXT_PROMPT] - for prompt in self.stage.fields.all(): - prompt: Prompt - self.assertEqual(data[prompt.field_key], self.prompt_data[prompt.field_key]) diff --git a/passbook/stages/user_delete/api.py b/passbook/stages/user_delete/api.py deleted file mode 100644 index 5b2b8494..00000000 --- a/passbook/stages/user_delete/api.py +++ /dev/null @@ -1,24 +0,0 @@ -"""User Delete Stage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.user_delete.models import UserDeleteStage - - -class UserDeleteStageSerializer(ModelSerializer): - """UserDeleteStage Serializer""" - - class Meta: - - model = UserDeleteStage - fields = [ - "pk", - "name", - ] - - -class UserDeleteStageViewSet(ModelViewSet): - """UserDeleteStage Viewset""" - - queryset = UserDeleteStage.objects.all() - serializer_class = UserDeleteStageSerializer diff --git a/passbook/stages/user_delete/apps.py b/passbook/stages/user_delete/apps.py deleted file mode 100644 index c9119d29..00000000 --- a/passbook/stages/user_delete/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook delete stage app config""" -from django.apps import AppConfig - - -class PassbookStageUserDeleteConfig(AppConfig): - """passbook delete stage config""" - - name = "passbook.stages.user_delete" - label = "passbook_stages_user_delete" - verbose_name = "passbook Stages.User Delete" diff --git a/passbook/stages/user_delete/forms.py b/passbook/stages/user_delete/forms.py deleted file mode 100644 index c226f1f4..00000000 --- a/passbook/stages/user_delete/forms.py +++ /dev/null @@ -1,20 +0,0 @@ -"""passbook flows delete forms""" -from django import forms - -from passbook.stages.user_delete.models import UserDeleteStage - - -class UserDeleteStageForm(forms.ModelForm): - """Form to delete/edit UserDeleteStage instances""" - - class Meta: - - model = UserDeleteStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } - - -class UserDeleteForm(forms.Form): - """Confirmation form to ensure user knows they are deleting their profile""" diff --git a/passbook/stages/user_delete/migrations/0001_initial.py b/passbook/stages/user_delete/migrations/0001_initial.py deleted file mode 100644 index 3872ed9d..00000000 --- a/passbook/stages/user_delete/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="UserDeleteStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ], - options={ - "verbose_name": "User Delete Stage", - "verbose_name_plural": "User Delete Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/user_delete/models.py b/passbook/stages/user_delete/models.py deleted file mode 100644 index b4388019..00000000 --- a/passbook/stages/user_delete/models.py +++ /dev/null @@ -1,40 +0,0 @@ -"""delete stage models""" -from typing import Type - -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 passbook.flows.models import Stage - - -class UserDeleteStage(Stage): - """Deletes the currently pending user without confirmation. - Use with caution.""" - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.user_delete.api import UserDeleteStageSerializer - - return UserDeleteStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.user_delete.stage import UserDeleteStageView - - return UserDeleteStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.user_delete.forms import UserDeleteStageForm - - return UserDeleteStageForm - - def __str__(self): - return f"User Delete Stage {self.name}" - - class Meta: - - verbose_name = _("User Delete Stage") - verbose_name_plural = _("User Delete Stages") diff --git a/passbook/stages/user_delete/stage.py b/passbook/stages/user_delete/stage.py deleted file mode 100644 index ecde61c3..00000000 --- a/passbook/stages/user_delete/stage.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Delete stage logic""" -from django.contrib import messages -from django.http import HttpRequest, HttpResponse -from django.utils.translation import gettext as _ -from django.views.generic import FormView -from structlog import get_logger - -from passbook.core.models import User -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.stages.user_delete.forms import UserDeleteForm - -LOGGER = get_logger() - - -class UserDeleteStageView(FormView, StageView): - """Finalise unenrollment flow by deleting the user object.""" - - form_class = UserDeleteForm - - def get(self, request: HttpRequest) -> HttpResponse: - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: - message = _("No Pending User.") - messages.error(request, message) - LOGGER.debug(message) - return self.executor.stage_invalid() - return super().get(request) - - def form_valid(self, form: UserDeleteForm) -> HttpResponse: - user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - user.delete() - LOGGER.debug("Deleted user", user=user) - del self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - return self.executor.stage_ok() diff --git a/passbook/stages/user_delete/tests.py b/passbook/stages/user_delete/tests.py deleted file mode 100644 index 3426a120..00000000 --- a/passbook/stages/user_delete/tests.py +++ /dev/null @@ -1,95 +0,0 @@ -"""delete tests""" -from unittest.mock import patch - -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.policies.http import AccessDeniedResponse -from passbook.stages.user_delete.models import UserDeleteStage - - -class TestUserDeleteStage(TestCase): - """Delete tests""" - - def setUp(self): - super().setUp() - self.username = "qerqwerqrwqwerwq" - self.user = User.objects.create(username=self.username, email="test@beryju.org") - self.client = Client() - - self.flow = Flow.objects.create( - name="test-delete", - slug="test-delete", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = UserDeleteStage.objects.create(name="delete") - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - def test_no_user(self): - """Test without user set""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - - def test_user_delete_get(self): - """Test Form render""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 200) - - def test_user_delete_post(self): - """Test User delete (actual)""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.post( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ), - {}, - ) - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - self.assertFalse(User.objects.filter(username=self.username).exists()) diff --git a/passbook/stages/user_login/api.py b/passbook/stages/user_login/api.py deleted file mode 100644 index 61f2115e..00000000 --- a/passbook/stages/user_login/api.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Login Stage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.user_login.models import UserLoginStage - - -class UserLoginStageSerializer(ModelSerializer): - """UserLoginStage Serializer""" - - class Meta: - - model = UserLoginStage - fields = [ - "pk", - "name", - "session_duration", - ] - - -class UserLoginStageViewSet(ModelViewSet): - """UserLoginStage Viewset""" - - queryset = UserLoginStage.objects.all() - serializer_class = UserLoginStageSerializer diff --git a/passbook/stages/user_login/apps.py b/passbook/stages/user_login/apps.py deleted file mode 100644 index a032dfd7..00000000 --- a/passbook/stages/user_login/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook login stage app config""" -from django.apps import AppConfig - - -class PassbookStageUserLoginConfig(AppConfig): - """passbook login stage config""" - - name = "passbook.stages.user_login" - label = "passbook_stages_user_login" - verbose_name = "passbook Stages.User Login" diff --git a/passbook/stages/user_login/forms.py b/passbook/stages/user_login/forms.py deleted file mode 100644 index 376ec1eb..00000000 --- a/passbook/stages/user_login/forms.py +++ /dev/null @@ -1,17 +0,0 @@ -"""passbook flows login forms""" -from django import forms - -from passbook.stages.user_login.models import UserLoginStage - - -class UserLoginStageForm(forms.ModelForm): - """Form to create/edit UserLoginStage instances""" - - class Meta: - - model = UserLoginStage - fields = ["name", "session_duration"] - widgets = { - "name": forms.TextInput(), - "session_duration": forms.TextInput(), - } diff --git a/passbook/stages/user_login/migrations/0001_initial.py b/passbook/stages/user_login/migrations/0001_initial.py deleted file mode 100644 index 611b42b5..00000000 --- a/passbook/stages/user_login/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="UserLoginStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ], - options={ - "verbose_name": "User Login Stage", - "verbose_name_plural": "User Login Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/user_login/migrations/0002_userloginstage_session_duration.py b/passbook/stages/user_login/migrations/0002_userloginstage_session_duration.py deleted file mode 100644 index 66f5d942..00000000 --- a/passbook/stages/user_login/migrations/0002_userloginstage_session_duration.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 3.0.7 on 2020-07-04 13:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_user_login", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="userloginstage", - name="session_duration", - field=models.PositiveIntegerField( - default=0, - help_text="Determines how long a session lasts, in seconds. Default of 0 means that the sessions lasts until the browser is closed.", - ), - ), - ] diff --git a/passbook/stages/user_login/migrations/0003_session_duration_delta.py b/passbook/stages/user_login/migrations/0003_session_duration_delta.py deleted file mode 100644 index 265cfbea..00000000 --- a/passbook/stages/user_login/migrations/0003_session_duration_delta.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 3.1.2 on 2020-10-26 20:21 - -from django.apps.registry import Apps -from django.db import migrations, models -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -import passbook.lib.utils.time - - -def update_duration(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - UserLoginStage = apps.get_model("passbook_stages_user_login", "userloginstage") - - db_alias = schema_editor.connection.alias - - for stage in UserLoginStage.objects.using(db_alias).all(): - if stage.session_duration.isdigit(): - stage.session_duration = f"seconds={stage.session_duration}" - stage.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_user_login", "0002_userloginstage_session_duration"), - ] - - operations = [ - migrations.AlterField( - model_name="userloginstage", - name="session_duration", - field=models.TextField( - default="seconds=0", - help_text="Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)", - validators=[passbook.lib.utils.time.timedelta_string_validator], - ), - ), - migrations.RunPython(update_duration), - ] diff --git a/passbook/stages/user_login/models.py b/passbook/stages/user_login/models.py deleted file mode 100644 index d70be4a8..00000000 --- a/passbook/stages/user_login/models.py +++ /dev/null @@ -1,51 +0,0 @@ -"""login stage models""" -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 passbook.flows.models import Stage -from passbook.lib.utils.time import timedelta_string_validator - - -class UserLoginStage(Stage): - """Attaches the currently pending user to the current session.""" - - session_duration = models.TextField( - default="seconds=0", - validators=[timedelta_string_validator], - help_text=_( - "Determines how long a session lasts. Default of 0 means " - "that the sessions lasts until the browser is closed. " - "(Format: hours=-1;minutes=-2;seconds=-3)" - ), - ) - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.user_login.api import UserLoginStageSerializer - - return UserLoginStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.user_login.stage import UserLoginStageView - - return UserLoginStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.user_login.forms import UserLoginStageForm - - return UserLoginStageForm - - def __str__(self): - return f"User Login Stage {self.name}" - - class Meta: - - verbose_name = _("User Login Stage") - verbose_name_plural = _("User Login Stages") diff --git a/passbook/stages/user_login/stage.py b/passbook/stages/user_login/stage.py deleted file mode 100644 index f2bd4c05..00000000 --- a/passbook/stages/user_login/stage.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Login stage logic""" -from django.contrib import messages -from django.contrib.auth import login -from django.http import HttpRequest, HttpResponse -from django.utils.translation import gettext as _ -from structlog import get_logger - -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.lib.utils.time import timedelta_from_string -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND - -LOGGER = get_logger() - - -class UserLoginStageView(StageView): - """Finalise Authentication flow by logging the user in""" - - def get(self, request: HttpRequest) -> HttpResponse: - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: - message = _("No Pending user to login.") - messages.error(request, message) - LOGGER.debug(message) - return self.executor.stage_invalid() - if PLAN_CONTEXT_AUTHENTICATION_BACKEND not in self.executor.plan.context: - message = _("Pending user has no backend.") - messages.error(request, message) - LOGGER.debug(message) - return self.executor.stage_invalid() - backend = self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] - login( - self.request, - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], - backend=backend, - ) - delta = timedelta_from_string(self.executor.current_stage.session_duration) - if delta.seconds == 0: - self.request.session.set_expiry(0) - else: - self.request.session.set_expiry(delta) - LOGGER.debug( - "Logged in", - user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], - flow_slug=self.executor.flow.slug, - session_duration=self.executor.current_stage.session_duration, - ) - messages.success(self.request, _("Successfully logged in!")) - return self.executor.stage_ok() diff --git a/passbook/stages/user_login/tests.py b/passbook/stages/user_login/tests.py deleted file mode 100644 index 0433121f..00000000 --- a/passbook/stages/user_login/tests.py +++ /dev/null @@ -1,111 +0,0 @@ -"""login tests""" -from unittest.mock import patch - -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.policies.http import AccessDeniedResponse -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from passbook.stages.user_login.forms import UserLoginStageForm -from passbook.stages.user_login.models import UserLoginStage - - -class TestUserLoginStage(TestCase): - """Login tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create(username="unittest", email="test@beryju.org") - self.client = Client() - - self.flow = Flow.objects.create( - name="test-login", - slug="test-login", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = UserLoginStage.objects.create(name="login") - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_valid_password(self): - """Test with a valid pending user and backend""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[ - PLAN_CONTEXT_AUTHENTICATION_BACKEND - ] = "django.contrib.auth.backends.ModelBackend" - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - def test_without_user(self): - """Test a plan without any pending user, resulting in a denied""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - def test_without_backend(self): - """Test a plan with pending user, without backend, resulting in a denied""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - - def test_form(self): - """Test Form""" - data = {"name": "test", "session_duration": "seconds=0"} - self.assertEqual(UserLoginStageForm(data).is_valid(), True) - data = {"name": "test", "session_duration": "123"} - self.assertEqual(UserLoginStageForm(data).is_valid(), False) diff --git a/passbook/stages/user_logout/api.py b/passbook/stages/user_logout/api.py deleted file mode 100644 index 4200a36d..00000000 --- a/passbook/stages/user_logout/api.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Logout Stage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.user_logout.models import UserLogoutStage - - -class UserLogoutStageSerializer(ModelSerializer): - """UserLogoutStage Serializer""" - - class Meta: - - model = UserLogoutStage - fields = [ - "pk", - "name", - ] - - -class UserLogoutStageViewSet(ModelViewSet): - """UserLogoutStage Viewset""" - - queryset = UserLogoutStage.objects.all() - serializer_class = UserLogoutStageSerializer diff --git a/passbook/stages/user_logout/apps.py b/passbook/stages/user_logout/apps.py deleted file mode 100644 index 7d7c0ee8..00000000 --- a/passbook/stages/user_logout/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook logout stage app config""" -from django.apps import AppConfig - - -class PassbookStageUserLogoutConfig(AppConfig): - """passbook logout stage config""" - - name = "passbook.stages.user_logout" - label = "passbook_stages_user_logout" - verbose_name = "passbook Stages.User Logout" diff --git a/passbook/stages/user_logout/forms.py b/passbook/stages/user_logout/forms.py deleted file mode 100644 index a2e87983..00000000 --- a/passbook/stages/user_logout/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""passbook flows logout forms""" -from django import forms - -from passbook.stages.user_logout.models import UserLogoutStage - - -class UserLogoutStageForm(forms.ModelForm): - """Form to create/edit UserLogoutStage instances""" - - class Meta: - - model = UserLogoutStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/user_logout/migrations/0001_initial.py b/passbook/stages/user_logout/migrations/0001_initial.py deleted file mode 100644 index 86a1c98d..00000000 --- a/passbook/stages/user_logout/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="UserLogoutStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ], - options={ - "verbose_name": "User Logout Stage", - "verbose_name_plural": "User Logout Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/user_logout/models.py b/passbook/stages/user_logout/models.py deleted file mode 100644 index 70df84d7..00000000 --- a/passbook/stages/user_logout/models.py +++ /dev/null @@ -1,39 +0,0 @@ -"""logout stage models""" -from typing import Type - -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 passbook.flows.models import Stage - - -class UserLogoutStage(Stage): - """Resets the users current session.""" - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.user_logout.api import UserLogoutStageSerializer - - return UserLogoutStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.user_logout.stage import UserLogoutStageView - - return UserLogoutStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.user_logout.forms import UserLogoutStageForm - - return UserLogoutStageForm - - def __str__(self): - return f"User Logout Stage {self.name}" - - class Meta: - - verbose_name = _("User Logout Stage") - verbose_name_plural = _("User Logout Stages") diff --git a/passbook/stages/user_logout/stage.py b/passbook/stages/user_logout/stage.py deleted file mode 100644 index cb111e76..00000000 --- a/passbook/stages/user_logout/stage.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Logout stage logic""" -from django.contrib.auth import logout -from django.http import HttpRequest, HttpResponse -from structlog import get_logger - -from passbook.flows.stage import StageView - -LOGGER = get_logger() - - -class UserLogoutStageView(StageView): - """Finalise Authentication flow by logging the user in""" - - def get(self, request: HttpRequest) -> HttpResponse: - LOGGER.debug( - "Logged out", - user=request.user, - flow_slug=self.executor.flow.slug, - ) - logout(self.request) - return self.executor.stage_ok() diff --git a/passbook/stages/user_logout/tests.py b/passbook/stages/user_logout/tests.py deleted file mode 100644 index 4f219c53..00000000 --- a/passbook/stages/user_logout/tests.py +++ /dev/null @@ -1,60 +0,0 @@ -"""logout tests""" -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from passbook.stages.user_logout.forms import UserLogoutStageForm -from passbook.stages.user_logout.models import UserLogoutStage - - -class TestUserLogoutStage(TestCase): - """Logout tests""" - - def setUp(self): - super().setUp() - self.user = User.objects.create(username="unittest", email="test@beryju.org") - self.client = Client() - - self.flow = Flow.objects.create( - name="test-logout", - slug="test-logout", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = UserLogoutStage.objects.create(name="logout") - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_valid_password(self): - """Test with a valid pending user and backend""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = self.user - plan.context[ - PLAN_CONTEXT_AUTHENTICATION_BACKEND - ] = "django.contrib.auth.backends.ModelBackend" - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(UserLogoutStageForm(data).is_valid(), True) diff --git a/passbook/stages/user_write/api.py b/passbook/stages/user_write/api.py deleted file mode 100644 index 8e32a36a..00000000 --- a/passbook/stages/user_write/api.py +++ /dev/null @@ -1,24 +0,0 @@ -"""User Write Stage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.user_write.models import UserWriteStage - - -class UserWriteStageSerializer(ModelSerializer): - """UserWriteStage Serializer""" - - class Meta: - - model = UserWriteStage - fields = [ - "pk", - "name", - ] - - -class UserWriteStageViewSet(ModelViewSet): - """UserWriteStage Viewset""" - - queryset = UserWriteStage.objects.all() - serializer_class = UserWriteStageSerializer diff --git a/passbook/stages/user_write/apps.py b/passbook/stages/user_write/apps.py deleted file mode 100644 index f2dceb57..00000000 --- a/passbook/stages/user_write/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook write stage app config""" -from django.apps import AppConfig - - -class PassbookStageUserWriteConfig(AppConfig): - """passbook write stage config""" - - name = "passbook.stages.user_write" - label = "passbook_stages_user_write" - verbose_name = "passbook Stages.User Write" diff --git a/passbook/stages/user_write/forms.py b/passbook/stages/user_write/forms.py deleted file mode 100644 index f4e00d8a..00000000 --- a/passbook/stages/user_write/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""passbook flows write forms""" -from django import forms - -from passbook.stages.user_write.models import UserWriteStage - - -class UserWriteStageForm(forms.ModelForm): - """Form to write/edit UserWriteStage instances""" - - class Meta: - - model = UserWriteStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/user_write/migrations/0001_initial.py b/passbook/stages/user_write/migrations/0001_initial.py deleted file mode 100644 index dbdc3d7a..00000000 --- a/passbook/stages/user_write/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.0.6 on 2020-05-19 22:08 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ("passbook_flows", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="UserWriteStage", - fields=[ - ( - "stage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="passbook_flows.Stage", - ), - ), - ], - options={ - "verbose_name": "User Write Stage", - "verbose_name_plural": "User Write Stages", - }, - bases=("passbook_flows.stage",), - ), - ] diff --git a/passbook/stages/user_write/migrations/0002_auto_20200918_1653.py b/passbook/stages/user_write/migrations/0002_auto_20200918_1653.py deleted file mode 100644 index 1be48e28..00000000 --- a/passbook/stages/user_write/migrations/0002_auto_20200918_1653.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.1.1 on 2020-09-18 16:53 - -from django.apps.registry import Apps -from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - - -def remove_unintended_attributes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - db_alias = schema_editor.connection.alias - User = apps.get_model("passbook_core", "User") - for user in User.objects.using(db_alias).all(): - if "password_repeat" in user.attributes: - del user.attributes["password_repeat"] - if "password" in user.attributes: - del user.attributes["password"] - user.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ("passbook_stages_user_write", "0001_initial"), - ] - - operations = [ - migrations.RunPython(remove_unintended_attributes), - ] diff --git a/passbook/stages/user_write/models.py b/passbook/stages/user_write/models.py deleted file mode 100644 index 6b73fa96..00000000 --- a/passbook/stages/user_write/models.py +++ /dev/null @@ -1,40 +0,0 @@ -"""write stage models""" -from typing import Type - -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 passbook.flows.models import Stage - - -class UserWriteStage(Stage): - """Writes currently pending data into the pending user, or if no user exists, - creates a new user with the data.""" - - @property - def serializer(self) -> BaseSerializer: - from passbook.stages.user_write.api import UserWriteStageSerializer - - return UserWriteStageSerializer - - @property - def type(self) -> Type[View]: - from passbook.stages.user_write.stage import UserWriteStageView - - return UserWriteStageView - - @property - def form(self) -> Type[ModelForm]: - from passbook.stages.user_write.forms import UserWriteStageForm - - return UserWriteStageForm - - def __str__(self): - return f"User Write Stage {self.name}" - - class Meta: - - verbose_name = _("User Write Stage") - verbose_name_plural = _("User Write Stages") diff --git a/passbook/stages/user_write/signals.py b/passbook/stages/user_write/signals.py deleted file mode 100644 index 043684ab..00000000 --- a/passbook/stages/user_write/signals.py +++ /dev/null @@ -1,5 +0,0 @@ -"""passbook user_write signals""" -from django.core.signals import Signal - -# Arguments: request: HttpRequest, user: User, data: Dict[str, Any], created: bool -user_write = Signal() diff --git a/passbook/stages/user_write/stage.py b/passbook/stages/user_write/stage.py deleted file mode 100644 index b59d9af9..00000000 --- a/passbook/stages/user_write/stage.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Write stage logic""" -from django.contrib import messages -from django.contrib.auth import update_session_auth_hash -from django.contrib.auth.backends import ModelBackend -from django.http import HttpRequest, HttpResponse -from django.utils.translation import gettext as _ -from structlog import get_logger - -from passbook.core.middleware import SESSION_IMPERSONATE_USER -from passbook.core.models import User -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import StageView -from passbook.lib.utils.reflection import class_to_path -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT -from passbook.stages.user_write.signals import user_write - -LOGGER = get_logger() - - -class UserWriteStageView(StageView): - """Finalise Enrollment flow by creating a user object.""" - - def get(self, request: HttpRequest) -> HttpResponse: - if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: - message = _("No Pending data.") - messages.error(request, message) - LOGGER.debug(message) - return self.executor.stage_invalid() - data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] - user_created = False - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User() - self.executor.plan.context[ - PLAN_CONTEXT_AUTHENTICATION_BACKEND - ] = class_to_path(ModelBackend) - LOGGER.debug( - "Created new user", - flow_slug=self.executor.flow.slug, - ) - user_created = True - user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - # Before we change anything, check if the user is the same as in the request - # and we're updating a password. In that case we need to update the session hash - # Also check that we're not currently impersonating, so we don't update the session - should_update_seesion = False - if ( - any(["password" in x for x in data.keys()]) - and self.request.user.pk == user.pk - and SESSION_IMPERSONATE_USER not in self.request.session - ): - should_update_seesion = True - for key, value in data.items(): - setter_name = f"set_{key}" - # Check if user has a setter for this key, like set_password - if hasattr(user, setter_name): - setter = getattr(user, setter_name) - if callable(setter): - setter(value) - # User has this key already - elif hasattr(user, key): - setattr(user, key, value) - # Otherwise we just save it as custom attribute, but only if the value is prefixed with - # `attribute_`, to prevent accidentally saving values - else: - if not key.startswith("attribute_"): - LOGGER.debug("discarding key", key=key) - continue - user.attributes[key.replace("attribute_", "", 1)] = value - user.save() - user_write.send( - sender=self, request=request, user=user, data=data, created=user_created - ) - # Check if the password has been updated, and update the session auth hash - if should_update_seesion: - update_session_auth_hash(self.request, user) - LOGGER.debug("Updated session hash", user=user) - LOGGER.debug( - "Updated existing user", - user=user, - flow_slug=self.executor.flow.slug, - ) - return self.executor.stage_ok() diff --git a/passbook/stages/user_write/tests.py b/passbook/stages/user_write/tests.py deleted file mode 100644 index ccb5e5ea..00000000 --- a/passbook/stages/user_write/tests.py +++ /dev/null @@ -1,138 +0,0 @@ -"""write tests""" -import string -from random import SystemRandom -from unittest.mock import patch - -from django.shortcuts import reverse -from django.test import Client, TestCase -from django.utils.encoding import force_str - -from passbook.core.models import User -from passbook.flows.markers import StageMarker -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan -from passbook.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.policies.http import AccessDeniedResponse -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT -from passbook.stages.user_write.forms import UserWriteStageForm -from passbook.stages.user_write.models import UserWriteStage - - -class TestUserWriteStage(TestCase): - """Write tests""" - - def setUp(self): - super().setUp() - self.client = Client() - - self.flow = Flow.objects.create( - name="test-write", - slug="test-write", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = UserWriteStage.objects.create(name="write") - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_user_create(self): - """Test creation of user""" - password = "".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) - for _ in range(8) - ) - - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PROMPT] = { - "username": "test-user", - "name": "name", - "email": "test@beryju.org", - "password": password, - } - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - user_qs = User.objects.filter( - username=plan.context[PLAN_CONTEXT_PROMPT]["username"] - ) - self.assertTrue(user_qs.exists()) - self.assertTrue(user_qs.first().check_password(password)) - - def test_user_update(self): - """Test update of existing user""" - new_password = "".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) - for _ in range(8) - ) - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create( - username="unittest", email="test@beryju.org" - ) - plan.context[PLAN_CONTEXT_PROMPT] = { - "username": "test-user-new", - "password": new_password, - "attribute_some-custom-attribute": "test", - } - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertJSONEqual( - force_str(response.content), - {"type": "redirect", "to": reverse("passbook_core:shell")}, - ) - user_qs = User.objects.filter( - username=plan.context[PLAN_CONTEXT_PROMPT]["username"] - ) - self.assertTrue(user_qs.exists()) - self.assertTrue(user_qs.first().check_password(new_password)) - self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test") - - @patch( - "passbook.flows.views.to_stage_response", - TO_STAGE_RESPONSE_MOCK, - ) - def test_without_data(self): - """Test without data results in error""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] - ) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - - self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - - def test_form(self): - """Test Form""" - data = {"name": "test"} - self.assertEqual(UserWriteStageForm(data).is_valid(), True) diff --git a/proxy/Dockerfile b/proxy/Dockerfile index ac306952..85c082d2 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -11,6 +11,6 @@ FROM gcr.io/distroless/base-debian10:debug COPY --from=builder /work/proxy / -HEALTHCHECK CMD [ "wget", "--spider", "http://localhost:4180/pbprox/ping" ] +HEALTHCHECK CMD [ "wget", "--spider", "http://localhost:4180/akprox/ping" ] ENTRYPOINT ["/proxy"] diff --git a/proxy/Makefile b/proxy/Makefile index 6f4bac0f..5b24cb1c 100644 --- a/proxy/Makefile +++ b/proxy/Makefile @@ -2,7 +2,7 @@ all: clean generate build generate: go get -u github.com/go-swagger/go-swagger/cmd/swagger - swagger generate client -f ../swagger.yaml -A passbook -t pkg/ + swagger generate client -f ../swagger.yaml -A authentik -t pkg/ run: go run -v . diff --git a/proxy/README.md b/proxy/README.md index 0b507c87..ac497c67 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -1,24 +1,24 @@ -# passbook Proxy +# authentik Proxy -[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/passbook/3?style=flat-square)](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=3) -![Docker pulls (proxy)](https://img.shields.io/docker/pulls/beryju/passbook-proxy.svg?style=flat-square) +[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/authentik/3?style=flat-square)](https://dev.azure.com/beryjuorg/authentik/_build?definitionId=3) +![Docker pulls (proxy)](https://img.shields.io/docker/pulls/beryju/authentik-proxy.svg?style=flat-square) -Reverse Proxy based on [oauth2_proxy](https://github.com/oauth2-proxy/oauth2-proxy), completely managed and monitored by passbook. +Reverse Proxy based on [oauth2_proxy](https://github.com/oauth2-proxy/oauth2-proxy), completely managed and monitored by authentik. ## Usage -passbook Proxy is built to be configured by passbook itself, hence the only options you can directly give it are connection params. +authentik Proxy is built to be configured by authentik itself, hence the only options you can directly give it are connection params. The following environment variable are implemented: -`PASSBOOK_HOST`: Full URL to the passbook instance with protocol, i.e. "https://passbook.company.tld" +`AUTHENTIK_HOST`: Full URL to the authentik instance with protocol, i.e. "https://authentik.company.tld" -`PASSBOOK_TOKEN`: Token used to authenticate against passbook. This is generated after an Outpost instance is created. +`AUTHENTIK_TOKEN`: Token used to authenticate against authentik. This is generated after an Outpost instance is created. -`PASSBOOK_INSECURE`: This environment variable can optionally be set to ignore the SSL Certificate of the passbook instance. Applies to both HTTP and WS connections. +`AUTHENTIK_INSECURE`: This environment variable can optionally be set to ignore the SSL Certificate of the authentik instance. Applies to both HTTP and WS connections. ## Development -passbook Proxy uses an auto-generated API Client to communicate with passbook. This client is not kept in git. To generate the client locally, run `make generate`. +authentik Proxy uses an auto-generated API Client to communicate with authentik. This client is not kept in git. To generate the client locally, run `make generate`. Afterwards you can build the proxy like any other Go project, using `go build`. diff --git a/proxy/azure-pipelines.yml b/proxy/azure-pipelines.yml index 14da08bd..71590480 100644 --- a/proxy/azure-pipelines.yml +++ b/proxy/azure-pipelines.yml @@ -26,7 +26,7 @@ stages: sudo apt update sudo apt install swagger mkdir -p $(go env GOPATH) - swagger generate client -f ../swagger.yaml -A passbook -t pkg/ + swagger generate client -f ../swagger.yaml -A authentik -t pkg/ workingDirectory: 'proxy/' - task: PublishPipelineArtifact@1 inputs: @@ -91,7 +91,7 @@ stages: - task: Docker@2 inputs: containerRegistry: 'dockerhub' - repository: 'beryju/passbook-proxy' + repository: 'beryju/authentik-proxy' command: 'buildAndPush' Dockerfile: 'proxy/Dockerfile' buildContext: 'proxy/' diff --git a/proxy/cmd/server.go b/proxy/cmd/server.go index 84a009b8..e251e9a8 100644 --- a/proxy/cmd/server.go +++ b/proxy/cmd/server.go @@ -8,27 +8,27 @@ import ( "os/signal" "time" - "github.com/BeryJu/passbook/proxy/pkg/server" + "github.com/BeryJu/authentik/proxy/pkg/server" ) -const helpMessage = `passbook proxy +const helpMessage = `authentik proxy Required environment variables: - - PASSBOOK_HOST: URL to connect to (format "http://passbook.company") - - PASSBOOK_TOKEN: Token to authenticate with - - PASSBOOK_INSECURE: Skip SSL Certificate verification` + - AUTHENTIK_HOST: URL to connect to (format "http://authentik.company") + - AUTHENTIK_TOKEN: Token to authenticate with + - AUTHENTIK_INSECURE: Skip SSL Certificate verification` // RunServer main entrypoint, runs the full server func RunServer() { - pbURL, found := os.LookupEnv("PASSBOOK_HOST") + pbURL, found := os.LookupEnv("AUTHENTIK_HOST") if !found { - fmt.Println("env PASSBOOK_HOST not set!") + fmt.Println("env AUTHENTIK_HOST not set!") fmt.Println(helpMessage) os.Exit(1) } - pbToken, found := os.LookupEnv("PASSBOOK_TOKEN") + pbToken, found := os.LookupEnv("AUTHENTIK_TOKEN") if !found { - fmt.Println("env PASSBOOK_TOKEN not set!") + fmt.Println("env AUTHENTIK_TOKEN not set!") fmt.Println(helpMessage) os.Exit(1) } diff --git a/proxy/go.mod b/proxy/go.mod index cd4bd596..5173d6cb 100644 --- a/proxy/go.mod +++ b/proxy/go.mod @@ -1,4 +1,4 @@ -module github.com/BeryJu/passbook/proxy +module github.com/BeryJu/authentik/proxy go 1.14 diff --git a/proxy/main.go b/proxy/main.go index 30a39706..4a505f88 100644 --- a/proxy/main.go +++ b/proxy/main.go @@ -1,7 +1,7 @@ package main import ( - "github.com/BeryJu/passbook/proxy/cmd" + "github.com/BeryJu/authentik/proxy/cmd" log "github.com/sirupsen/logrus" ) diff --git a/proxy/pkg/proxy/claims.go b/proxy/pkg/proxy/claims.go index 760c6018..6d8d06f5 100644 --- a/proxy/pkg/proxy/claims.go +++ b/proxy/pkg/proxy/claims.go @@ -9,7 +9,7 @@ import ( type Claims struct { Proxy struct { UserAttributes map[string]interface{} `json:"user_attributes"` - } `json:"pb_proxy"` + } `json:"ak_proxy"` } func (c *Claims) FromIDToken(idToken string) error { diff --git a/proxy/pkg/server/api.go b/proxy/pkg/server/api.go index cab289cf..4e26e561 100644 --- a/proxy/pkg/server/api.go +++ b/proxy/pkg/server/api.go @@ -11,9 +11,9 @@ import ( "strings" "time" - "github.com/BeryJu/passbook/proxy/pkg" - "github.com/BeryJu/passbook/proxy/pkg/client" - "github.com/BeryJu/passbook/proxy/pkg/client/outposts" + "github.com/BeryJu/authentik/proxy/pkg" + "github.com/BeryJu/authentik/proxy/pkg/client" + "github.com/BeryJu/authentik/proxy/pkg/client/outposts" "github.com/getsentry/sentry-go" "github.com/go-openapi/runtime" "github.com/recws-org/recws" @@ -28,9 +28,9 @@ const ConfigLogLevel = "log_level" const ConfigErrorReportingEnabled = "error_reporting_enabled" const ConfigErrorReportingEnvironment = "error_reporting_environment" -// APIController main controller which connects to the passbook api via http and ws +// APIController main controller which connects to the authentik api via http and ws type APIController struct { - client *client.Passbook + client *client.Authentik auth runtime.ClientAuthInfoWriter token string @@ -48,13 +48,13 @@ type APIController struct { func getCommonOptions() *options.Options { commonOpts := options.NewOptions() - commonOpts.Cookie.Name = "passbook_proxy" + commonOpts.Cookie.Name = "authentik_proxy" commonOpts.EmailDomains = []string{"*"} commonOpts.ProviderType = "oidc" - commonOpts.ProxyPrefix = "/pbprox" + commonOpts.ProxyPrefix = "/akprox" commonOpts.Logging.SilencePing = true commonOpts.SetAuthorization = false - commonOpts.Scope = "openid email profile pb_proxy" + commonOpts.Scope = "openid email profile ak_proxy" return commonOpts } @@ -71,11 +71,11 @@ func doGlobalSetup(config map[string]interface{}) { default: log.SetLevel(log.DebugLevel) } - log.WithField("version", pkg.VERSION).Info("Starting passbook proxy") + log.WithField("version", pkg.VERSION).Info("Starting authentik proxy") var dsn string if config[ConfigErrorReportingEnabled].(bool) { - dsn = "https://33cdbcb23f8b436dbe0ee06847410b67@sentry.beryju.org/3" + dsn = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8" log.Debug("Error reporting enabled") } @@ -91,7 +91,7 @@ func doGlobalSetup(config map[string]interface{}) { } func getTLSTransport() http.RoundTripper { - value, set := os.LookupEnv("PASSBOOK_INSECURE") + value, set := os.LookupEnv("AUTHENTIK_INSECURE") if !set { value = "false" } @@ -107,7 +107,7 @@ func getTLSTransport() http.RoundTripper { // NewAPIController initialise new API Controller instance from URL and API token func NewAPIController(pbURL url.URL, token string) *APIController { transport := httptransport.New(pbURL.Host, client.DefaultBasePath, []string{pbURL.Scheme}) - transport.Transport = SetUserAgent(getTLSTransport(), fmt.Sprintf("passbook-proxy@%s", pkg.VERSION)) + transport.Transport = SetUserAgent(getTLSTransport(), fmt.Sprintf("authentik-proxy@%s", pkg.VERSION)) // create the transport auth := httptransport.BasicAuth("", token) diff --git a/proxy/pkg/server/api_bundle.go b/proxy/pkg/server/api_bundle.go index d4421a98..331b6ea1 100644 --- a/proxy/pkg/server/api_bundle.go +++ b/proxy/pkg/server/api_bundle.go @@ -9,9 +9,9 @@ import ( "os" "strings" - "github.com/BeryJu/passbook/proxy/pkg/client/crypto" - "github.com/BeryJu/passbook/proxy/pkg/models" - "github.com/BeryJu/passbook/proxy/pkg/proxy" + "github.com/BeryJu/authentik/proxy/pkg/client/crypto" + "github.com/BeryJu/authentik/proxy/pkg/models" + "github.com/BeryJu/authentik/proxy/pkg/proxy" "github.com/jinzhu/copier" "github.com/justinas/alice" "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options" diff --git a/proxy/pkg/server/api_ws.go b/proxy/pkg/server/api_ws.go index 8e013638..d863b860 100644 --- a/proxy/pkg/server/api_ws.go +++ b/proxy/pkg/server/api_ws.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/BeryJu/passbook/proxy/pkg" + "github.com/BeryJu/authentik/proxy/pkg" "github.com/go-openapi/strfmt" "github.com/gorilla/websocket" "github.com/recws-org/recws" @@ -24,10 +24,10 @@ func (ac *APIController) initWS(pbURL url.URL, outpostUUID strfmt.UUID) { header := http.Header{ "Authorization": []string{authHeader}, - "User-Agent": []string{fmt.Sprintf("passbook-proxy@%s", pkg.VERSION)}, + "User-Agent": []string{fmt.Sprintf("authentik-proxy@%s", pkg.VERSION)}, } - value, set := os.LookupEnv("PASSBOOK_INSECURE") + value, set := os.LookupEnv("AUTHENTIK_INSECURE") if !set { value = "false" } @@ -40,7 +40,7 @@ func (ac *APIController) initWS(pbURL url.URL, outpostUUID strfmt.UUID) { } ws.Dial(fmt.Sprintf(pathTemplate, scheme, pbURL.Host, outpostUUID.String()), header) - ac.logger.WithField("component", "ws").WithField("outpost", outpostUUID.String()).Debug("connecting to passbook") + ac.logger.WithField("component", "ws").WithField("outpost", outpostUUID.String()).Debug("connecting to authentik") ac.wsConn = ws // Send hello message with our version @@ -52,7 +52,7 @@ func (ac *APIController) initWS(pbURL url.URL, outpostUUID strfmt.UUID) { } err := ws.WriteJSON(msg) if err != nil { - ac.logger.WithField("component", "ws").WithError(err).Warning("Failed to hello to passbook") + ac.logger.WithField("component", "ws").WithError(err).Warning("Failed to hello to authentik") } } diff --git a/proxy/pkg/server/cert.go b/proxy/pkg/server/cert.go index 96f144fa..08e1cf55 100644 --- a/proxy/pkg/server/cert.go +++ b/proxy/pkg/server/cert.go @@ -36,8 +36,8 @@ func generateSelfSignedCert() (tls.Certificate, error) { template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ - Organization: []string{"passbook"}, - CommonName: "passbook Proxy default certificate", + Organization: []string{"authentik"}, + CommonName: "authentik Proxy default certificate", }, NotBefore: notBefore, NotAfter: notAfter, diff --git a/proxy/pkg/server/server.go b/proxy/pkg/server/server.go index bd43346a..895c4b2e 100644 --- a/proxy/pkg/server/server.go +++ b/proxy/pkg/server/server.go @@ -80,7 +80,7 @@ func (s *Server) ServeHTTPS() { } func (s *Server) handler(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/pbprox/ping" { + if r.URL.Path == "/akprox/ping" { w.WriteHeader(204) return } diff --git a/pyproject.toml b/pyproject.toml index ced2eb09..12962af8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,4 +8,36 @@ include_trailing_comma = true force_grid_wrap = 0 use_parentheses = true line_length = 88 -src_paths = ["passbook", "tests", "lifecycle"] +src_paths = ["authentik", "tests", "lifecycle"] + +[tool.coverage.run] +source = ["authentik"] +relative_files = true +omit = [ + "*/asgi.py", + "manage.py", + "*/migrations/*", + "*/apps.py", + "website/", +] + +[tool.coverage.report] +sort = "Cover" +skip_covered = true +precision = 2 +exclude_lines = [ + "pragma: no cover", + # Don't complain about missing debug-only code: + "def __unicode__", + "def __str__", + "def __repr__", + "if self.debug", + "if TYPE_CHECKING", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", +] +show_missing = true diff --git a/pytest.ini b/pytest.ini index 55c93ff0..59ca91fc 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -DJANGO_SETTINGS_MODULE = passbook.root.settings +DJANGO_SETTINGS_MODULE = authentik.root.settings python_files = tests.py test_*.py *_tests.py junit_family = xunit2 addopts = -p no:celery --junitxml=unittest.xml diff --git a/scripts/ci.docker-compose.yml b/scripts/ci.docker-compose.yml index 1cbe9aa2..0129ac18 100644 --- a/scripts/ci.docker-compose.yml +++ b/scripts/ci.docker-compose.yml @@ -7,9 +7,9 @@ services: volumes: - db-data:/var/lib/postgresql/data environment: - POSTGRES_USER: passbook + POSTGRES_USER: authentik POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77" - POSTGRES_DB: passbook + POSTGRES_DB: authentik ports: - 5432:5432 restart: always diff --git a/scripts/docker-compose.yml b/scripts/docker-compose.yml index 3118c22c..373da5a5 100644 --- a/scripts/docker-compose.yml +++ b/scripts/docker-compose.yml @@ -8,7 +8,7 @@ services: - db-data:/var/lib/postgresql/data environment: POSTGRES_HOST_AUTH_METHOD: trust - POSTGRES_DB: passbook + POSTGRES_DB: authentik ports: - 5432:5432 restart: always diff --git a/swagger.yaml b/swagger.yaml index c503bd29..5b008e4a 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1,6 +1,6 @@ swagger: '2.0' info: - title: passbook API + title: authentik API contact: email: hello@beryju.org license: @@ -3712,7 +3712,7 @@ paths: parameters: [] responses: '200': - description: Serialize passbook Config into DRF Object + description: Serialize authentik Config into DRF Object schema: description: '' type: array @@ -6825,7 +6825,7 @@ definitions: designation: title: Designation description: Decides what this Flow is used for. For example, the Authentication - flow is redirect to when an un-authenticated user visits passbook. + flow is redirect to when an un-authenticated user visits authentik. type: string enum: - authentication @@ -6883,8 +6883,8 @@ definitions: uniqueItems: true service_connection: title: Service connection - description: Select Service-Connection passbook should use to manage this - outpost. Leave empty if passbook should not handle the deployment. + description: Select Service-Connection authentik should use to manage this + outpost. Leave empty if authentik should not handle the deployment. type: string format: uuid x-nullable: true @@ -7016,7 +7016,7 @@ definitions: basic_auth_enabled: title: Set HTTP-Basic Authentication description: Set a custom HTTP-Basic Authentication header based on values - from passbook. + from authentik. type: boolean basic_auth_password_attribute: title: HTTP-Basic Password Key @@ -7090,8 +7090,8 @@ definitions: type: boolean kubeconfig: title: Kubeconfig - description: Paste your kubeconfig here. passbook will automatically use the - currently selected context. + description: Paste your kubeconfig here. authentik will automatically use + the currently selected context. type: object Policy: description: Policy Serializer @@ -7599,7 +7599,7 @@ definitions: basic_auth_enabled: title: Set HTTP-Basic Authentication description: Set a custom HTTP-Basic Authentication header based on values - from passbook. + from authentik. type: boolean basic_auth_password_attribute: title: HTTP-Basic Password Key @@ -7698,7 +7698,7 @@ definitions: format: uuid x-nullable: true Config: - description: Serialize passbook Config into DRF Object + description: Serialize authentik Config into DRF Object type: object properties: branding_logo: @@ -7967,13 +7967,13 @@ definitions: minLength: 1 access_token_url: title: Access Token URL - description: URL used by passbook to retrive tokens. + description: URL used by authentik to retrive tokens. type: string maxLength: 255 minLength: 1 profile_url: title: Profile URL - description: URL used by passbook to get user information. + description: URL used by authentik to get user information. type: string maxLength: 255 minLength: 1 diff --git a/tests/e2e/test_flows_enroll.py b/tests/e2e/test_flows_enroll.py index 3c38d893..a760a580 100644 --- a/tests/e2e/test_flows_enroll.py +++ b/tests/e2e/test_flows_enroll.py @@ -8,13 +8,13 @@ from docker.types import Healthcheck from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec -from passbook.core.models import User -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.stages.email.models import EmailStage, EmailTemplates -from passbook.stages.identification.models import IdentificationStage -from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage -from passbook.stages.user_login.models import UserLoginStage -from passbook.stages.user_write.models import UserWriteStage +from authentik.core.models import User +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.stages.email.models import EmailStage, EmailTemplates +from authentik.stages.identification.models import IdentificationStage +from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage +from authentik.stages.user_login.models import UserLoginStage +from authentik.stages.user_write.models import UserWriteStage from tests.e2e.utils import USER, SeleniumTestCase, retry @@ -101,8 +101,8 @@ class TestFlowsEnroll(SeleniumTestCase): self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() - self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pb-sidebar"))) - self.driver.get(self.shell_url("passbook_core:user-settings")) + self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-sidebar"))) + self.driver.get(self.shell_url("authentik_core:user-settings")) user = User.objects.get(username="foo") self.assertEqual(user.username, "foo") @@ -196,7 +196,7 @@ class TestFlowsEnroll(SeleniumTestCase): self.driver.switch_to.window(self.driver.window_handles[0]) # We're now logged in - self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pb-sidebar"))) - self.driver.get(self.shell_url("passbook_core:user-settings")) + self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-sidebar"))) + self.driver.get(self.shell_url("authentik_core:user-settings")) self.assert_user(User.objects.get(username="foo")) diff --git a/tests/e2e/test_flows_login.py b/tests/e2e/test_flows_login.py index c7085bb4..6e59987b 100644 --- a/tests/e2e/test_flows_login.py +++ b/tests/e2e/test_flows_login.py @@ -21,5 +21,5 @@ class TestFlowsLogin(SeleniumTestCase): self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) - self.wait_for_url(self.shell_url("passbook_core:overview")) + self.wait_for_url(self.shell_url("authentik_core:overview")) self.assert_user(USER()) diff --git a/tests/e2e/test_flows_otp.py b/tests/e2e/test_flows_otp.py index 7890c31d..113acc33 100644 --- a/tests/e2e/test_flows_otp.py +++ b/tests/e2e/test_flows_otp.py @@ -12,10 +12,10 @@ from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec -from passbook.flows.models import Flow, FlowStageBinding -from passbook.stages.otp_static.models import OTPStaticStage -from passbook.stages.otp_time.models import OTPTimeStage -from passbook.stages.otp_validate.models import OTPValidateStage +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 tests.e2e.utils import USER, SeleniumTestCase, retry @@ -49,7 +49,7 @@ class TestFlowsOTP(SeleniumTestCase): totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift) self.driver.find_element(By.ID, "id_code").send_keys(totp.token()) self.driver.find_element(By.ID, "id_code").send_keys(Keys.ENTER) - self.wait_for_url(self.shell_url("passbook_core:overview")) + self.wait_for_url(self.shell_url("authentik_core:overview")) self.assert_user(USER()) @retry() @@ -63,12 +63,12 @@ class TestFlowsOTP(SeleniumTestCase): self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) - self.wait_for_url(self.shell_url("passbook_core:overview")) + self.wait_for_url(self.shell_url("authentik_core:overview")) self.assert_user(USER()) self.driver.get( self.url( - "passbook_flows:configure", + "authentik_flows:configure", stage_uuid=OTPTimeStage.objects.first().stage_uuid, ) ) @@ -106,12 +106,12 @@ class TestFlowsOTP(SeleniumTestCase): self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) - self.wait_for_url(self.shell_url("passbook_core:overview")) + self.wait_for_url(self.shell_url("authentik_core:overview")) self.assert_user(USER()) self.driver.get( self.url( - "passbook_flows:configure", + "authentik_flows:configure", stage_uuid=OTPStaticStage.objects.first().stage_uuid, ) ) @@ -120,7 +120,7 @@ class TestFlowsOTP(SeleniumTestCase): destination_url = self.driver.current_url token = self.driver.find_element( - By.CSS_SELECTOR, ".pb-otp-tokens li:nth-child(1)" + By.CSS_SELECTOR, ".ak-otp-tokens li:nth-child(1)" ).text self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() diff --git a/tests/e2e/test_flows_stage_setup.py b/tests/e2e/test_flows_stage_setup.py index 351e150f..69fca1c9 100644 --- a/tests/e2e/test_flows_stage_setup.py +++ b/tests/e2e/test_flows_stage_setup.py @@ -5,10 +5,10 @@ from unittest.case import skipUnless from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys -from passbook.core.models import User -from passbook.flows.models import Flow, FlowDesignation -from passbook.providers.oauth2.generators import generate_client_secret -from passbook.stages.password.models import PasswordStage +from authentik.core.models import User +from authentik.flows.models import Flow, FlowDesignation +from authentik.providers.oauth2.generators import generate_client_secret +from authentik.stages.password.models import PasswordStage from tests.e2e.utils import USER, SeleniumTestCase, retry @@ -38,11 +38,11 @@ class TestFlowsStageSetup(SeleniumTestCase): self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) - self.wait_for_url(self.shell_url("passbook_core:overview")) + self.wait_for_url(self.shell_url("authentik_core:overview")) self.driver.get( self.url( - "passbook_flows:configure", + "authentik_flows:configure", stage_uuid=PasswordStage.objects.first().stage_uuid, ) ) @@ -51,7 +51,7 @@ class TestFlowsStageSetup(SeleniumTestCase): self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password) self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() - self.wait_for_url(self.shell_url("passbook_core:overview")) + self.wait_for_url(self.shell_url("authentik_core:overview")) # Because USER() is cached, we need to get the user manually here user = User.objects.get(username=USER().username) self.assertTrue(user.check_password(new_password)) diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py index 4834a33e..e2e5e03b 100644 --- a/tests/e2e/test_provider_oauth2_github.py +++ b/tests/e2e/test_provider_oauth2_github.py @@ -9,15 +9,15 @@ from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec -from passbook.core.models import Application -from passbook.flows.models import Flow -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.models import PolicyBinding -from passbook.providers.oauth2.generators import ( +from authentik.core.models import Application +from authentik.flows.models import Flow +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.generators import ( generate_client_id, generate_client_secret, ) -from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes +from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes from tests.e2e.utils import USER, SeleniumTestCase, retry @@ -49,13 +49,13 @@ class TestProviderOAuth2Github(SeleniumTestCase): "GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret, "GF_AUTH_GITHUB_SCOPES": "user:email,read:org", "GF_AUTH_GITHUB_AUTH_URL": self.url( - "passbook_providers_oauth2_github:github-authorize" + "authentik_providers_oauth2_github:github-authorize" ), "GF_AUTH_GITHUB_TOKEN_URL": self.url( - "passbook_providers_oauth2_github:github-access-token" + "authentik_providers_oauth2_github:github-access-token" ), "GF_AUTH_GITHUB_API_URL": self.url( - "passbook_providers_oauth2_github:github-user" + "authentik_providers_oauth2_github:github-user" ), "GF_LOG_LEVEL": "debug", }, diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py index e69f39cf..88d8efa9 100644 --- a/tests/e2e/test_provider_oauth2_grafana.py +++ b/tests/e2e/test_provider_oauth2_grafana.py @@ -10,21 +10,21 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from structlog import get_logger -from passbook.core.models import Application -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.models import PolicyBinding -from passbook.providers.oauth2.constants import ( +from authentik.core.models import Application +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.constants import ( SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, ) -from passbook.providers.oauth2.generators import ( +from authentik.providers.oauth2.generators import ( generate_client_id, generate_client_secret, ) -from passbook.providers.oauth2.models import ( +from authentik.providers.oauth2.models import ( ClientTypes, OAuth2Provider, ResponseTypes, @@ -62,17 +62,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, "GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email profile", "GF_AUTH_GENERIC_OAUTH_AUTH_URL": ( - self.url("passbook_providers_oauth2:authorize") + self.url("authentik_providers_oauth2:authorize") ), "GF_AUTH_GENERIC_OAUTH_TOKEN_URL": ( - self.url("passbook_providers_oauth2:token") + self.url("authentik_providers_oauth2:token") ), "GF_AUTH_GENERIC_OAUTH_API_URL": ( - self.url("passbook_providers_oauth2:userinfo") + self.url("authentik_providers_oauth2:userinfo") ), "GF_AUTH_SIGNOUT_REDIRECT_URL": ( self.url( - "passbook_providers_oauth2:end-session", + "authentik_providers_oauth2:end-session", application_slug=APPLICATION_SLUG, ) ), @@ -249,7 +249,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): self.driver.get("http://localhost:3000/logout") self.wait_for_url( self.url( - "passbook_providers_oauth2:end-session", + "authentik_providers_oauth2:end-session", application_slug=APPLICATION_SLUG, ) ) diff --git a/tests/e2e/test_provider_oauth2_oidc.py b/tests/e2e/test_provider_oauth2_oidc.py index 5e97b210..5f9c55f8 100644 --- a/tests/e2e/test_provider_oauth2_oidc.py +++ b/tests/e2e/test_provider_oauth2_oidc.py @@ -12,21 +12,21 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from structlog import get_logger -from passbook.core.models import Application -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.models import PolicyBinding -from passbook.providers.oauth2.constants import ( +from authentik.core.models import Application +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.models import PolicyBinding +from authentik.providers.oauth2.constants import ( SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, ) -from passbook.providers.oauth2.generators import ( +from authentik.providers.oauth2.generators import ( generate_client_id, generate_client_secret, ) -from passbook.providers.oauth2.models import ( +from authentik.providers.oauth2.models import ( ClientTypes, OAuth2Provider, ResponseTypes, diff --git a/tests/e2e/test_provider_proxy.py b/tests/e2e/test_provider_proxy.py index c4ed8185..4e8eb181 100644 --- a/tests/e2e/test_provider_proxy.py +++ b/tests/e2e/test_provider_proxy.py @@ -11,17 +11,17 @@ from docker.models.containers import Container from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys -from passbook import __version__ -from passbook.core.models import Application -from passbook.flows.models import Flow -from passbook.outposts.apps import PassbookOutpostConfig -from passbook.outposts.models import ( +from authentik import __version__ +from authentik.core.models import Application +from authentik.flows.models import Flow +from authentik.outposts.apps import AuthentikOutpostConfig +from authentik.outposts.models import ( DockerServiceConnection, Outpost, OutpostConfig, OutpostType, ) -from passbook.providers.proxy.models import ProxyProvider +from authentik.providers.proxy.models import ProxyProvider from tests.e2e.utils import USER, SeleniumTestCase, retry @@ -47,13 +47,13 @@ class TestProviderProxy(SeleniumTestCase): """Start proxy container based on outpost created""" client: DockerClient = from_env() container = client.containers.run( - image=f"docker.beryju.org/proxy/beryju/passbook-proxy:{__version__}", + image=f"docker.beryju.org/proxy/beryju/authentik-proxy:{__version__}", detach=True, network_mode="host", auto_remove=True, environment={ - "PASSBOOK_HOST": self.live_server_url, - "PASSBOOK_TOKEN": outpost.token.key, + "AUTHENTIK_HOST": self.live_server_url, + "AUTHENTIK_TOKEN": outpost.token.key, }, ) return container @@ -104,7 +104,7 @@ class TestProviderProxy(SeleniumTestCase): sleep(1) full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text - self.assertIn("X-Forwarded-Preferred-Username: pbadmin", full_body_text) + self.assertIn("X-Forwarded-Preferred-Username: akadmin", full_body_text) @skipUnless(platform.startswith("linux"), "requires local docker") @@ -114,7 +114,7 @@ class TestProviderProxyConnect(ChannelsLiveServerTestCase): @retry() def test_proxy_connectivity(self): """Test proxy connectivity over websocket""" - PassbookOutpostConfig.init_local_connection() + AuthentikOutpostConfig.init_local_connection() SeleniumTestCase().apply_default_data() proxy: ProxyProvider = ProxyProvider.objects.create( name="proxy_provider", @@ -135,7 +135,7 @@ class TestProviderProxyConnect(ChannelsLiveServerTestCase): type=OutpostType.PROXY, service_connection=service_connection, _config=asdict( - OutpostConfig(passbook_host=self.live_server_url, log_level="debug") + OutpostConfig(authentik_host=self.live_server_url, log_level="debug") ), ) outpost.providers.add(proxy) diff --git a/tests/e2e/test_provider_saml.py b/tests/e2e/test_provider_saml.py index ccc6e413..63810f4f 100644 --- a/tests/e2e/test_provider_saml.py +++ b/tests/e2e/test_provider_saml.py @@ -12,12 +12,12 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from structlog import get_logger -from passbook.core.models import Application -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.models import PolicyBinding -from passbook.providers.saml.models import ( +from authentik.core.models import Application +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow +from authentik.policies.expression.models import ExpressionPolicy +from authentik.policies.models import PolicyBinding +from authentik.providers.saml.models import ( SAMLBindings, SAMLPropertyMapping, SAMLProvider, @@ -52,7 +52,7 @@ class TestProviderSAML(SeleniumTestCase): "SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", "SP_METADATA_URL": ( self.url( - "passbook_providers_saml:metadata", + "authentik_providers_saml:metadata", application_slug=provider.application.slug, ) ), @@ -76,8 +76,8 @@ class TestProviderSAML(SeleniumTestCase): provider: SAMLProvider = SAMLProvider.objects.create( name="saml-test", acs_url="http://localhost:9009/saml/acs", - audience="passbook-e2e", - issuer="passbook-e2e", + audience="authentik-e2e", + issuer="authentik-e2e", sp_binding=SAMLBindings.POST, authorization_flow=authorization_flow, signing_kp=CertificateKeyPair.objects.first(), @@ -86,7 +86,7 @@ class TestProviderSAML(SeleniumTestCase): provider.save() Application.objects.create( name="SAML", - slug="passbook-saml", + slug="authentik-saml", provider=provider, ) self.container = self.setup_client(provider) @@ -116,8 +116,8 @@ class TestProviderSAML(SeleniumTestCase): provider: SAMLProvider = SAMLProvider.objects.create( name="saml-test", acs_url="http://localhost:9009/saml/acs", - audience="passbook-e2e", - issuer="passbook-e2e", + audience="authentik-e2e", + issuer="authentik-e2e", sp_binding=SAMLBindings.POST, authorization_flow=authorization_flow, signing_kp=CertificateKeyPair.objects.first(), @@ -126,7 +126,7 @@ class TestProviderSAML(SeleniumTestCase): provider.save() app = Application.objects.create( name="SAML", - slug="passbook-saml", + slug="authentik-saml", provider=provider, ) self.container = self.setup_client(provider) @@ -162,8 +162,8 @@ class TestProviderSAML(SeleniumTestCase): provider: SAMLProvider = SAMLProvider.objects.create( name="saml-test", acs_url="http://localhost:9009/saml/acs", - audience="passbook-e2e", - issuer="passbook-e2e", + audience="authentik-e2e", + issuer="authentik-e2e", sp_binding=SAMLBindings.POST, authorization_flow=authorization_flow, signing_kp=CertificateKeyPair.objects.first(), @@ -172,13 +172,13 @@ class TestProviderSAML(SeleniumTestCase): provider.save() Application.objects.create( name="SAML", - slug="passbook-saml", + slug="authentik-saml", provider=provider, ) self.container = self.setup_client(provider) self.driver.get( self.url( - "passbook_providers_saml:sso-init", + "authentik_providers_saml:sso-init", application_slug=provider.application.slug, ) ) @@ -211,8 +211,8 @@ class TestProviderSAML(SeleniumTestCase): provider: SAMLProvider = SAMLProvider.objects.create( name="saml-test", acs_url="http://localhost:9009/saml/acs", - audience="passbook-e2e", - issuer="passbook-e2e", + audience="authentik-e2e", + issuer="authentik-e2e", sp_binding=SAMLBindings.POST, authorization_flow=authorization_flow, signing_kp=CertificateKeyPair.objects.first(), @@ -221,7 +221,7 @@ class TestProviderSAML(SeleniumTestCase): provider.save() app = Application.objects.create( name="SAML", - slug="passbook-saml", + slug="authentik-saml", provider=provider, ) PolicyBinding.objects.create(target=app, policy=negative_policy, order=0) diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py index 1653915a..206501f6 100644 --- a/tests/e2e/test_source_oauth.py +++ b/tests/e2e/test_source_oauth.py @@ -14,12 +14,12 @@ from selenium.webdriver.support import expected_conditions as ec from structlog import get_logger from yaml import safe_dump -from passbook.flows.models import Flow -from passbook.providers.oauth2.generators import ( +from authentik.flows.models import Flow +from authentik.providers.oauth2.generators import ( generate_client_id, generate_client_secret, ) -from passbook.sources.oauth.models import OAuthSource +from authentik.sources.oauth.models import OAuthSource from tests.e2e.utils import SeleniumTestCase, retry CONFIG_PATH = "/tmp/dex.yml" @@ -50,7 +50,7 @@ class TestSourceOAuth2(SeleniumTestCase): "name": "Example App", "redirectURIs": [ self.url( - "passbook_sources_oauth:oauth-client-callback", + "authentik_sources_oauth:oauth-client-callback", source_slug="dex", ) ], @@ -141,8 +141,8 @@ class TestSourceOAuth2(SeleniumTestCase): self.driver.find_element(By.NAME, "username").send_keys(Keys.ENTER) # Wait until we've logged in - self.wait_for_url(self.shell_url("passbook_core:overview")) - self.driver.get(self.url("passbook_core:user-settings")) + self.wait_for_url(self.shell_url("authentik_core:overview")) + self.driver.get(self.url("authentik_core:user-settings")) self.assertEqual( self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo" @@ -198,7 +198,7 @@ class TestSourceOAuth2(SeleniumTestCase): """test OAuth Source With With OIDC (enroll and authenticate again)""" self.test_oauth_enroll() # We're logged in at the end of this, log out and re-login - self.driver.get(self.url("passbook_flows:default-invalidation")) + self.driver.get(self.url("authentik_flows:default-invalidation")) self.wait.until( ec.presence_of_element_located( @@ -223,8 +223,8 @@ class TestSourceOAuth2(SeleniumTestCase): self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() # Wait until we've logged in - self.wait_for_url(self.shell_url("passbook_core:overview")) - self.driver.get(self.url("passbook_core:user-settings")) + self.wait_for_url(self.shell_url("authentik_core:overview")) + self.driver.get(self.url("authentik_core:user-settings")) self.assertEqual( self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo" @@ -260,7 +260,7 @@ class TestSourceOAuth1(SeleniumTestCase): "OAUTH1_CLIENT_SECRET": self.client_secret, "OAUTH1_REDIRECT_URI": ( self.url( - "passbook_sources_oauth:oauth-client-callback", + "authentik_sources_oauth:oauth-client-callback", source_slug=self.source_slug, ) ), @@ -316,8 +316,8 @@ class TestSourceOAuth1(SeleniumTestCase): # Wait until we've loaded the user info page sleep(2) # Wait until we've logged in - self.wait_for_url(self.shell_url("passbook_core:overview")) - self.driver.get(self.url("passbook_core:user-settings")) + self.wait_for_url(self.shell_url("authentik_core:overview")) + self.driver.get(self.url("authentik_core:user-settings")) self.assertEqual( self.driver.find_element(By.ID, "id_username").get_attribute("value"), diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index 7b531e7f..7852aa32 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -10,9 +10,9 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from structlog import get_logger -from passbook.crypto.models import CertificateKeyPair -from passbook.flows.models import Flow -from passbook.sources.saml.models import SAMLBindingTypes, SAMLSource +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow +from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource from tests.e2e.utils import SeleniumTestCase, retry LOGGER = get_logger() @@ -133,8 +133,8 @@ class TestSourceSAML(SeleniumTestCase): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.shell_url("passbook_core:overview")) - self.driver.get(self.url("passbook_core:user-settings")) + self.wait_for_url(self.shell_url("authentik_core:overview")) + self.driver.get(self.url("authentik_core:user-settings")) # Wait until we've loaded the user info page self.assertNotEqual( @@ -184,8 +184,8 @@ class TestSourceSAML(SeleniumTestCase): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.shell_url("passbook_core:overview")) - self.driver.get(self.url("passbook_core:user-settings")) + self.wait_for_url(self.shell_url("authentik_core:overview")) + self.driver.get(self.url("authentik_core:user-settings")) # Wait until we've loaded the user info page self.assertNotEqual( @@ -233,8 +233,8 @@ class TestSourceSAML(SeleniumTestCase): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.shell_url("passbook_core:overview")) - self.driver.get(self.url("passbook_core:user-settings")) + self.wait_for_url(self.shell_url("authentik_core:overview")) + self.driver.get(self.url("authentik_core:user-settings")) # Wait until we've loaded the user info page self.assertNotEqual( diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 41ad96a3..619ea159 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -1,4 +1,4 @@ -"""passbook e2e testing utilities""" +"""authentik e2e testing utilities""" import json from functools import wraps from glob import glob @@ -24,14 +24,14 @@ from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from structlog import get_logger -from passbook.core.api.users import UserSerializer -from passbook.core.models import User +from authentik.core.api.users import UserSerializer +from authentik.core.models import User # pylint: disable=invalid-name def USER() -> User: # noqa - """Cached function that always returns pbadmin""" - return User.objects.get(username="pbadmin") + """Cached function that always returns akadmin""" + return User.objects.get(username="akadmin") class SeleniumTestCase(StaticLiveServerTestCase): @@ -109,7 +109,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): def assert_user(self, expected_user: User): """Check users/me API and assert it matches expected_user""" - self.driver.get(self.url("passbook_api:user-me") + "?format=json") + self.driver.get(self.url("authentik_api:user-me") + "?format=json") user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text user = UserSerializer(data=json.loads(user_json)) user.is_valid() diff --git a/tests/integration/test_outposts_kubernetes.py b/tests/integration/test_outposts_kubernetes.py index 8ec940c3..99345849 100644 --- a/tests/integration/test_outposts_kubernetes.py +++ b/tests/integration/test_outposts_kubernetes.py @@ -1,14 +1,14 @@ """outpost tests""" from django.test import TestCase -from passbook.flows.models import Flow -from passbook.lib.config import CONFIG -from passbook.outposts.apps import PassbookOutpostConfig -from passbook.outposts.controllers.k8s.base import NeedsUpdate -from passbook.outposts.controllers.k8s.deployment import DeploymentReconciler -from passbook.outposts.controllers.kubernetes import KubernetesController -from passbook.outposts.models import KubernetesServiceConnection, Outpost, OutpostType -from passbook.providers.proxy.models import ProxyProvider +from authentik.flows.models import Flow +from authentik.lib.config import CONFIG +from authentik.outposts.apps import AuthentikOutpostConfig +from authentik.outposts.controllers.k8s.base import NeedsUpdate +from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler +from authentik.outposts.controllers.kubernetes import KubernetesController +from authentik.outposts.models import KubernetesServiceConnection, Outpost, OutpostType +from authentik.providers.proxy.models import ProxyProvider class OutpostKubernetesTests(TestCase): @@ -17,7 +17,7 @@ class OutpostKubernetesTests(TestCase): def setUp(self): super().setUp() # Ensure that local connection have been created - PassbookOutpostConfig.init_local_connection() + AuthentikOutpostConfig.init_local_connection() self.provider: ProxyProvider = ProxyProvider.objects.create( name="test", internal_host="http://localhost", diff --git a/tests/integration/test_proxy_kubernetes.py b/tests/integration/test_proxy_kubernetes.py index ff4f1c74..89f83f5d 100644 --- a/tests/integration/test_proxy_kubernetes.py +++ b/tests/integration/test_proxy_kubernetes.py @@ -2,11 +2,11 @@ import yaml from django.test import TestCase -from passbook.flows.models import Flow -from passbook.outposts.apps import PassbookOutpostConfig -from passbook.outposts.models import KubernetesServiceConnection, Outpost, OutpostType -from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController -from passbook.providers.proxy.models import ProxyProvider +from authentik.flows.models import Flow +from authentik.outposts.apps import AuthentikOutpostConfig +from authentik.outposts.models import KubernetesServiceConnection, Outpost, OutpostType +from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController +from authentik.providers.proxy.models import ProxyProvider class TestControllers(TestCase): @@ -14,7 +14,7 @@ class TestControllers(TestCase): def setUp(self): # Ensure that local connection have been created - PassbookOutpostConfig.init_local_connection() + AuthentikOutpostConfig.init_local_connection() def test_kubernetes_controller_static(self): """Test Kubernetes Controller""" diff --git a/tests/setup.sh b/tests/setup.sh index f8589e2a..8ccc45de 100755 --- a/tests/setup.sh +++ b/tests/setup.sh @@ -1,6 +1,6 @@ #!/bin/bash -x # Setup docker & compose -# curl -fsSL https://get.docker.com | bash +curl -fsSL https://get.docker.com | bash sudo usermod -a -G docker ubuntu sudo curl -L "https://github.com/docker/compose/releases/download/1.26.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose @@ -10,7 +10,7 @@ sudo apt-get install -y nodejs # Setup k3d curl -s https://raw.githubusercontent.com/rancher/k3d/main/install.sh | bash # Setup python -sudo apt install -y python3.9 python3-pip libxmlsec1-dev pkg-config +sudo apt install -y python3.9 python3.9-dev python3-pip libxmlsec1-dev pkg-config # Setup docker sudo pip3 install pipenv diff --git a/web/passbook/sources/azure-ad.svg b/web/authentik/sources/azure-ad.svg similarity index 100% rename from web/passbook/sources/azure-ad.svg rename to web/authentik/sources/azure-ad.svg diff --git a/web/passbook/sources/discord.svg b/web/authentik/sources/discord.svg similarity index 100% rename from web/passbook/sources/discord.svg rename to web/authentik/sources/discord.svg diff --git a/web/passbook/sources/dropbox.svg b/web/authentik/sources/dropbox.svg similarity index 100% rename from web/passbook/sources/dropbox.svg rename to web/authentik/sources/dropbox.svg diff --git a/web/passbook/sources/facebook.svg b/web/authentik/sources/facebook.svg similarity index 100% rename from web/passbook/sources/facebook.svg rename to web/authentik/sources/facebook.svg diff --git a/web/passbook/sources/github.svg b/web/authentik/sources/github.svg similarity index 100% rename from web/passbook/sources/github.svg rename to web/authentik/sources/github.svg diff --git a/web/passbook/sources/gitlab.svg b/web/authentik/sources/gitlab.svg similarity index 100% rename from web/passbook/sources/gitlab.svg rename to web/authentik/sources/gitlab.svg diff --git a/web/passbook/sources/google.svg b/web/authentik/sources/google.svg similarity index 100% rename from web/passbook/sources/google.svg rename to web/authentik/sources/google.svg diff --git a/web/passbook/sources/openid-connect.svg b/web/authentik/sources/openid-connect.svg similarity index 100% rename from web/passbook/sources/openid-connect.svg rename to web/authentik/sources/openid-connect.svg diff --git a/web/passbook/sources/twitter.svg b/web/authentik/sources/twitter.svg similarity index 100% rename from web/passbook/sources/twitter.svg rename to web/authentik/sources/twitter.svg diff --git a/web/azure-pipelines.yml b/web/azure-pipelines.yml index d2e61c6c..0771a4de 100644 --- a/web/azure-pipelines.yml +++ b/web/azure-pipelines.yml @@ -10,7 +10,7 @@ variables: stages: - stage: lint jobs: - - job: lint + - job: eslint pool: vmImage: 'ubuntu-latest' steps: @@ -27,6 +27,23 @@ stages: command: 'custom' workingDir: 'web/' customCommand: 'run lint' + - job: lit_analyse + pool: + vmImage: 'ubuntu-latest' + steps: + - task: NodeTool@0 + inputs: + versionSpec: '12.x' + displayName: 'Install Node.js' + - task: Npm@1 + inputs: + command: 'install' + workingDir: 'web/' + - task: Npm@1 + inputs: + command: 'custom' + workingDir: 'web/' + customCommand: 'run lit-analyse' - stage: build_local jobs: - job: build @@ -55,7 +72,7 @@ stages: - task: Docker@2 inputs: containerRegistry: 'dockerhub' - repository: 'beryju/passbook-static' + repository: 'beryju/authentik-static' command: 'buildAndPush' Dockerfile: 'web/Dockerfile' tags: "gh-${{ variables.branchName }}" diff --git a/web/rollup.config.js b/web/rollup.config.js index 76e0d893..b11d9934 100644 --- a/web/rollup.config.js +++ b/web/rollup.config.js @@ -14,8 +14,9 @@ const resources = [ { src: "node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css", dest: "dist/" }, { src: "node_modules/@patternfly/patternfly/assets/*", dest: "dist/assets/" }, { src: "src/index.html", dest: "dist" }, - { src: "src/passbook.css", dest: "dist" }, + { src: "src/authentik.css", dest: "dist" }, { src: "src/assets/*", dest: "dist/assets" }, + { src: "../icons/*", dest: "dist/assets/icons" }, ]; export default [ diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 411b3005..529fd2cd 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -19,13 +19,13 @@ export class Config { return DefaultClient.fetch(["root", "config"]).then((config) => { if (config.error_reporting_enabled) { Sentry.init({ - dsn: "https://33cdbcb23f8b436dbe0ee06847410b67@sentry.beryju.org/3", - release: `passbook@${VERSION}`, + dsn: "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8", + release: `authentik@${VERSION}`, integrations: [new Integrations.BrowserTracing()], tracesSampleRate: 1.0, environment: config.error_reporting_environment, }); - console.debug("passbook/config: Sentry enabled."); + console.debug("authentik/config: Sentry enabled."); } return config; }); diff --git a/web/src/assets/fonts/DINEngschriftStd.woff b/web/src/assets/fonts/DINEngschriftStd.woff deleted file mode 100644 index f2f8734bba9efaa442199e9847ebf2468e16919e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16768 zcmZs?bBrj>6E3{AZQHhO+qUhqw(UJ@d-tqu+qP}reSi6qFE_c}d8Vgoo~o{?>GU6+ zDR+4>F#te--zMk}fbiewI{DxA|J8&9l|_Gf48PA<{sX^}JcO8-itMk>_+Op@p?JHe>euz*d7c2E{|g-eplzBh z@V{{*Xq)T1=KJ@B!2kS;5CA~`X#oH{){PBJ3=Fm&-H{Jy;6ndgd{-YB7yv{P0E>YE z6xV>+{{I{k69_{Ca{xdx0y7XHFfee2JU{?wQGmk#`7o+5&F<|T@9njkbV>~fFhmPB z8$#6~dUb*n_G5{^ZX zV6-+h2@WAcsC&I)G(rSeQJi#lW%`#hn2aZ&DHCLvciwrh4gm{X{$1Ie@HLe%r`F#C11>cM%FL~k8^%< zjs(wbMBg zhJHz}wsg#;;-7vb@VmzuXw10Z$)dAoGzzc{-GAzBe>9076j4Y-FVEh6T*^l=u6UM4 zKFWC2TI2Q@Q+J1?2@We}cYn1!}0qVG&iqqvQN_2}l~A{^iC+ppWooN-RH zk~OL>zCk?{GAqq+9`uM|te9{1@``c32S7y>9{R*ND=_aUU~>)?9MXRFW&27@Z5xco z?8SBkY*g?Xi&}RCoKd4TO=?f~@|p%TjTZPukni!E+(=V=0V_*_*#Eaynv>LJR0R=2 zdz|TXP^k%|kY{+I)D-Fijp#KL0KGw_>%>VFS%iq5x9hQ?(JqH;!UONHV6ZwUoUR3J zM2tNZ6gzR)1$^cP$;sDpa>BmM+`lnYf8_|pNzd4@Ql2T zCYm&D)tg_m0#ZxQGE0su*0fUHlr}0zk3gv0M)v35Kf#$Nc+8)xrw zkaG@MPCJD<>+JHB>l{N_9x^65Mw=784c3R9$sFT0rVqNOHpJ8p-UksGrB8wLJw(`a zGlqEb93!Nq&uEm~h0s8~jjHT>j$t5a4w~5+98u29)+G!$)7>YzFnLM?)_M|`0P8V? z>A2|C$EVO3piXEGXf4-g%92Fj+nIoKugQV6c#gtcJH>rW9wEZFK0vGP%qRbDi&Xz# z7XVNJhy+*%qylsT^asoYYyw;cyas#+f&fARA_0;EG60GP+6DRqW&m~o4hG%?!2+QH z;R8_uF$3`esRN||l>iL}Z30~ey#@mT;{j6vGXV1g%LJ5reUSiG=wJ(+{%^^9hRtD*~$o8w&gHw~xa?z!|`0!}Y^0!;8SH!8gEP zBS0ZwAy6O$BE%!qAWS2?BXS{1Avz#NB4#5FA}%AoBB3A&A^9V1A|oJkAR8b@BM&3r zqEMhHp!`MYMcG9KL8Uem!2-fkz^cNA!Pde~#h%AOz>&i-!STXL#;L-2#HGWvz%9Vt#iPNqz>CJ4 z#Cygkz?Z}K!vBlEOaMRtLqJ9#K@d!^Oh`g#K^RR~LpVzWL4-v_N5n&F*z8y5qTyB00j|+0EHe!I7L6jCnW`?mdE;#5447|`m)6{A32~% z0)mi8E}_8T7lh;?qJUuUEdrvlh-l=fbRW1@WjTCN>U*#FQfftWEK{s;bXrJRwlH+Jffv}KD8AL!xmy?XVNeMn5~OCK0ZSCDu2^0d9B&HN$GwPM z_9m7ukN4&1VO2+0>V=lp&Y4(lyf80G!1Lw;qRQJ?6pb-TYRXMhruzJ_-oSM@p?WDh$f5Vkwgv zh&RfXzVeR(kmMCKL>a9 z_-@G=t_wVKCjsv}S3Hj)H``tJ@OicJtC&(&tYf_P3Cr;Zz0l=@=DU1+U@hCMHyJ2(G*ko7Q|S#Ep-KUZjSGBN{yx3226ld-b#+15XO z@k-0hTzU?8)mclmnUfbckIsI=%5uotVm6?<7%g6SWpSiEXwo6cgqce-4W1WjeJBv5SiICW zch+I4idC4koSxz~RJipeb-q+tuD-d!TT`TaWY1RpeciDnTP>Yg?S7+`m8O8P^tW>A z(?X1TQn*@Wg4L-R@58OoUBZx^vLE#ng))%+RE0gd1%`#7dQj}7I`JYIO&i^+sqZD_ z6zdxU-{f<)G9{%IQ5X3Uu_l204l5^DZ@^C=Q4#oMPBWep3R=V*1Y| zlOW*%C#)(kY*Mp9)niHvqaLKf;JpMBbb3D&eP5pTHv>((Q4k@#c+e(;7|=4T zoXCeST>dd*N2Y}hSr!!v8xei63f?m^>0z>6w7iE_uw>Pbr^mSGNM^gS(b*~;j;mhm zFp8e(doY@x7nM0It=?Iw`phP;7FE+qVh`LeCs*6ZO;56{jz8Ia^D+F?28iqR5_f6A zOApADDHV$F7GVv2<><7VRaSrP6Rn@$+n8OWMZcWF%xXcm*@i#84Ctw_Zj&x$2Wt%~GP}~&MkhN-pW+;o&AC7~-P86}J4DUP#go4tDlKjs6^|RlJp{H) z{!1^_1k~)a6s0{1_L8eU36Y82M;IL>`BD zQsBpZ6FaEpq;5i8gBwxq2HS(U3Mpe+Rct+O1Hwo0YGXypF{iX@BT13htUDH}8qzYY3{blvw*_Mkf@F*lR@S zq4HpCu!*s`47(@c-eN+*ld=tbme7zHba68s@6=Gdj05tOIW@STpb--LuNVefot7==yOooN7!aAk=6OX&}C0KrB z_PHp^m{A@<{c)`*W-zuN@X?^s%PPhMEMbBblI~|pG$^PexZ^)1q_U9Q&DTlCxmlKU zms#g<9@_6Z)(OwbkjYRT$`=Gabx6_1MU2nD`{y|K%vowO#}wjco1O4t=zitHt>&w} z;}}9IZ#6>YnEg0u#qN{c>9&!-aQyz-EXIWLRlQ2AP z9}4QE`H7F&`Fi4}(ClMra`#borK082*>!8~56_GB{Z=2k8NT0*@Z)4&?caOokI|pk zxXkUe3*~j8ej{J`W_kW5U&rT1OKS{!PY3{+$rUB~AxjQ;21zWrDZ>}8?pz&FPx9I1 zi;P2QWTMrLZ{9u@JAv}U1_bWK-lXWaF#@tRC)QPmS>XzI!BOq0u>fNhgoRmm8nZG% zN!AWCu_Kg>(s)(T6v?&(Da-EyPSxfiX6s_!lHn&a;fZ;Wsbf>qB2@Pzjz3q)Rfi|% z^%+f*_7%QrkKTWO;4@?-$_Ny=;iQB~gCIdrL1*o8o6leO#Iwk0tr>E>T~EOOeKwUN zuN2D&gHyBXLQ-#X#VuUE?h2`E>bZ%EW^CEdBqsv(@pBy?5h_A78Xc=;2WL<9JKS#; zzl>MpQD$>VuRFJm*u5Q?e%tMw%uEiPn5ipa0fPO5bPU`53K56Zld)1D_Jqa#``r3T zM2YV6&$u(};<*C0HGs-f+HOMvK z?EP`6xVW+uUvMTu|1T7r_CJmN{oOH}X?i-UQ}Q=a(YMhPn7Jr^37jTIzq&R6*IR%M zn%a6on?^(+GCy#=`#KQ&hFX&Y>|ILL@V(PlFl=b{xn5X}7NA!@nwW^wcFP!*HiJ8d zzLBu!>eJ<{Pb1{OB3@r{y|1b+U4FLBssmX$EVO~TBQUxQU%zm8|Zr2{-Z9?Obiu~6EIBZ(u5Tq!9vf&w}_aZ zsyizBtFxPv*D%seDQ|MSs~r(z=D(8cqv*$ztP;evoq_Y78=&`uUB{?3pf#4)J42UM zb#_o#fShV_lr>4#mq!}RcSB$oV3jBB245mRBdQRU!-g7yif&tLJ}d>7k=vqZL7$8c z+NntNz<_dD&3d(QSfv@x#(RPBCQ}~v@OaVLBdd;v5q-N30F#%AxaSD5jw<9|BDBJHKAQym9p|XDd}S z@G&PTW7kpp;{D&?1zF@(!eGU;3F>x1kPser5&i|**P)`=MNx4=ycwylsAjEIO+Y!l z#IRpmR&qYLUF~IjM!p9pzTK$H!H$)*8NL;7ktQ|L1gvpd7LzLak&P0_Z~8r}N)7Z1 zr|&|hV?QmcL=i%@JHzL-kL+|g@p!97o zB0S5nKVM%lK`o;#6xYl6a(jM0naKr7ZjCwO?&0)>s7Aok>U9xToW!bOhZKRE-LA>} zUQwUO><(rh9~`AWO^dCt?#D#99Z2{6 z3qbe=)r7}@Fs0mFqn?&=PkU08He0hiQh=RY0iAG3_(dmpSVegZ@11~Rf%EVPyb)@f*L(K`n5{pWoH)#FO8W#3fqMT7+_63Y-s?37qgR{X$JHt_ zD~RQLs@#j;ZV4ksXutb7$=}cfxOIF$V6R8p069`;Su=~?i1W) z@zEdB;JsEga0r=)S=6@v?yYjg>@{}obc1L<5qLzuGEQ1vRszu5iX8eRgsmy@2)xm% zvmZFHLIG3DcuJ0FsvI<=;L{j;lHFtW{uCoog4RMP5#_{X4yRDCbla@%99`i7}@zuG{c4%$F^K_s}6E$VWkBh2;K&!g!vS3ql zGoj12P_(8}G-=!ZdW`X0wAL9DZXLaW_?k(P6t444YuF-(xr$1Y?e)K%#LUW2E8_Dj z6!Iod>;{_+o-TLl9w*bxF_9w-f}OxO3ec>A!o&nPC3#}AQNAPTi;IKElk>{TK%tkb zgUt38S=%wVHU!M&rL7;T18~Zrv;eX8KctFhfdM4Q^P@VPh|y(W{^Sb~H8%04XxMTK zst=ylBEuwT;ubcD$}2Jpsx#B=I_PN$sR}JtLdS`fvO$xI)=jFu)x*P5#unFFcFdOg zIVr(zrDz<_Jge&z*-BtS)d^9G_~9vQT-Mc7@hpspBxj|?*6Z&*gDdl7s3@xsU=M^N zcw`~Fqplvc-QJ(J5+Jv|uuNewcm6W|$HFXXbL7~EHizD|=$N)veEGG$GksA*^J2*bS?oZ>m9Riz=x>@6H^Ap>mVOx25LIRAaI$s zbC{&EsT4)~FHg-SLt}K&%+(INdl%_7$~;mfrh(BM84StTw4!$pkyPxur8I^(6%NOOw;? zEvUNeYS?8IGloDsxGYIHz*97|ThYZugiFZ@q3vZN&`g|Rb7e(bP}#~##v}1{Ik?qg zH4d}Vs^dlrB535*>d z0|iA@hOLW>cno9a<7de}>eK;vx1MTd>o6Bdk;JTQO0=V#{(Z8l`7zZN6Ma>0DwFuB zw@Y#3?p(V_`--GA)rnV^3jgGFHSw^Ja@jc}U#!ODem>G2$DEnUP6}5@0cYU*UE`4n zU}Y6eX6>$sbsuYfHx_wqg^t(7cUSS- zoWqEWIjkI}^23Mcg`Vv$B5F>$0eT`M_pQ*D9k4tX^jn;iOww*6Qh-T_TTRMCBdo5W z<>eCkhQmc!7>nI51+cbRTmHPlHVkXJsG%8G?uXWiPV^M!-3q;df4Lb*40`($Y<}m^ z1Qzc_iy{@d&C<(Fjsed_O_!fQpEU^j2)iq={(DF>{*FEFp_?7eAv@#A>l54I`h~9y zr*eSNxRkk+9~#_C@f3JM8{9IK6-GVD>F|2If4( zLP+?xt*kqqkoWCf-Hh-T<-7e6!diAj$?h48k%nC6uq#U>YLrp@T!C~`{k)A6y@F&W)W9+}xR*_DY7gH<*M z`Lwrh1)T^lU^$I8%Pym1#dm*;_SPp!#OH#U_pjevk|DW|$@gcr?}|&6`1bRZQxAyo zDq}x`s;ct4@HN+^C?O!Av&-oVQd-as@-D}?9xjBUS7j+E$@zLw%y>7|ioL^CHV5dG zZ}r}~6oBzTyDy}}m!}Ke+W@Of^uy(fooxn{JHu;pb;%e)B5n(|5B549w}qM8q4SrL zfb3nz@NzZ?(Br$O&+~O1;A^7@|gPpsc8y+iCA0ep5bC=2u~OV*2W*g zqUBKDZ1L35`MaBUW$r;&fyorNkzAm!k2OE?X^8M^3C<7ZB?YVy=7S9jezQb3*ha- z-dVEgTH~tPSb^YX0ZxLH36KVvHiZXS-ie6{4>(}Kp9e58K&<Bv`I}~zI=3b^)-^YzZY$Vn>Sb@B`?+2TI`Hl-kL$)5LB*tc9Zlua(Ds(yy zZ#G=sFIUKp==UMwS>??l#Jety<- zSw~tnT34{3Y>~+@3NXCbh%^{X_JMAg+t~o+YXiRbCP<>n6rX(s#|uQ7rz%QZr$f2D zYT40iGv^+}Z`kSpx>0l;355I1W9h1^+@s~B7!2he(<@3_=CuBcv*3Si*MR;rmfJ6V z%KQ|7!2Hy^E|VzZY?j0%&NViU5QKdZ%QT39Y#V(7B1~%|$o0%|w~ahJ82!3CmR4$I z%7@(G(hSyPF!kPPIb#lI zROF0}QdSZNE>bKp)M0c>(AhVL5o5r?9v^r^;%V4LpEXhb9HMj6ac`SF&HZz%Wxzb= zXvp^smznlLL3SzYPsd9Odu4DM-&Lfv#N9f%#qrU^VYR#-@s4i4D7PHZOVl4BofkRh zs|?yy-Ul=_{@0Pc^Yeip1U)){Lew-h9U}V#(L-7f;T87q3858@7^+A$G%#n%0f_Qs zwHY@E3!;>1@QVXrq@+Nrs=|G-Baoq6Tqfi9%W>8Y;H?YtN^%EK%>+X|!#7FkOpjz9 zRt^)l$ZkV8%D1}kTA39fJ^e8~x_}PKgoZAS;w-5Ml*P->{4`p8S+;@{6Xbb*OV_d= z-u@p{U%KYmHwPO&$xr=iH9>ab){j%~^f>IN1~_D*LQxleO^uVq&~jyLznA&8*jQ(! z+LFM)lE9uE@3UfOddL;7i)LfoY?NoiVt>$&1}56-&Goo@V>=>e_DreoSy{QAOEhCl zR5~GjfNTFgD$orGaNxq_4ipL%+a=X2zi<2<;tUBkIqqrw0ep7tRMwPZ5_8dnZC%>a z7skB^K|@2qHMrI`7`x8hnV9!{)~UWNH?(8eOvK2W3QDXHP>^!?TKzhQ46QVbZkMr6 z*suYh5$l3hd2XOWT0lcB{s9x|i3-f4u_PcM=ckyX%9H6CX^4n$j1~qyjZNf(4gvhZ zE^u5aUgxOIpjx3Z{y~h`p<%s#Ih>fi!BcH!me_|lDEO$mBim_>X#}qAv}EDfV~CUZ z!K}$Wq+YqW{5lQvz*x1ZKCar;#}<4(;iV3@pc~q*uAjmQe%z$3uNHWnq}Xp{*l&)8hMU<9 zXS5{^W>e9Jt2o9YE{9`DYJPTNYND;nNJt3?XcwX+2>~#I?RBoz| zH>jqJ(7I-k_aT^qo+=j4iKHXR;pbkyDt< zG04{UUd_2)yjG2U526kiRbRI0NcJRuy5@cTR9nW1622d2ap}>>jrp7HaP>@f{+!5- zwC~jshxLiYC2!(#?TQ4K5X;AZ_=jJkTZ71A#M(2SM?^?KKq-TpXVF&6E{INH1af13 zQVo1TyBe*2Mqy|+j8H?_;f67j~;OFnExoK$AFqK zhv1P1nM)VDc!n6|ROjn-a#$!y2=IGM7=%=J%j<*1lqyAV7h!6d`X}i>7N^EO!%lHB zrS|6rJ;fh8S+#M%?>0sbBUZ_+$I>-}uEKK31@|cjXj+@d9%aCccvvLPVE*O$+85;3 zg3toVu)W%tA;sm!1VYA2D0wjuLuBR|!e!fQ6Y9^=rAglYz?E;7U8N0%7uuH}C>ZBTPe^7e39`B6EOG|ucdwe(g~f11Lz-#)=B zmiN@OtFR5RNApVSI`*^al`P_=O;c?Ehg#V=WEF}1p1ro>FUr$C4R5^^fpuzMJP*C7 zzUT-py=$_64zjY?VS$B6bCGxbFC!A!K@urEYyM8>t}Ssxj1bzg#PY*OnHj#Hu0n(I zv8+BrHvZ9IvVih;(~{}pb*X|I_7hjlRgJ;1f^&%LUk$Ulq%f@F=mEplq&aPTfKm(t zujylDPv>jnJfDnh>ou(n+@7_@Su}P_h#rChtOAp;=GX_ri4o3L^)A||4_Z1KYsRX# zo7o|C#eL2THuBAb?_Lk6494naP*+<4_3p2Vur_rkvEHwGF1U&@5bN(&55zRQ`Td4c z4zz&qjD!rzYVcrulpr&=&Zve~J>oS`>hnupPL>$-&Q(YBgO%PedN12OK@T28dMd~9 zQTZOkfAHwZB_>D9UZUjoC=-IhBU%k*Deb;y4nkFrLu>DisqSrI3j>i#$Y8V|N;P7b zbmvTBl+ipAy0!N+E%JJC6`93KtUz6vOrsn9|C6R_sob;8tn7s&Il|2dA;hP|7 zNxaqmf;H8lUP>5KuL-xq&Bjew)V?siJAzsf)iyLg;hye|6B3m84%V|w$C_~a#a83y zv!Il_DiaXCqeOD#adn>7*y=s~G$MR5#}3X6q-%HI`QwvpU7I_OZj)CI;m=tBuP; zKHGV;;VeO5J%DH+8i422jS5$w#Q|l}SOieh^dK54mb(L*JFZS30x}(pK{5!sQTSFM z{*9d92XcdLEFCaZV~Xe%+QPLkd7?Kz%F*&kJO9uw|oL5Es$JmqM1Zr;b^0 zHn@~1iUu|LkdhMFs0`unm9r&!q3t|++9KO=Rls+3Gr|*!~y~?@2hh=(@>wG+QCb1^WE6gx`aUiXLK8E>L^%i;109 zsHtFJ_V;MoJs^T77AdCisA4k@df?a%po|GCdb*8Hvu=SSmSZ+e}Gh zZ_7wA{zO+tA$ovSe@7wk7i4aoRk0!C4jrY{*r5Y0-CONxjF+_iVzJvhkD#^b;e*=( z+G&gx)Y|8>+lH99k%Dgt7`H!ooEWTngIi?Np8X33?lvksXkUC-GJI$+hKg@@y^y_R zk7aF#iD(;s>y=_J-nt)4rx`KU!XrYdh8+{nsg^_C6B*J%E)m^k#$m^aXrrV*qe>l<=-slKfaImUw#wk_F%UBf+y)NxRE5X%gY_a%7{tH8+1@m{HSCduo+QergiG5y#+;r5yoD)l9gtoE8ei%3!Pc**+UHOMQ!zLh2SjKv zSvi~)myP{D`LURZ=RA&xHSdE(famDKx!S*qF^@d=skvgMr21NX9Wx(A!~oBWqKm4V24dC=MNjLiD1fkJ%GM$}_1z4*Gr|-2he*FErsC{4!2JmM+L$@ogpO0kS zEZ-i=!mRD(cAE0IDa=(~7G-sJUTBCt(vz}MwSi9~-(2DQ&pPv|S$$HTE!Lq(`vwl@ z8*wtX2V?ZRI|jQ>guV0N%D$)ma?+qrX2?_3e7ECW^aRFxSJpN}4`zzpW0kaQ%vB$- z3qV-%W9n|tdacfBmq6#K=2`&#P`7f3#sEsK<73|P%EtLQ4sTgmyOTO8pVT302lXX9 z&Q_TXeGvbIRrV!J_(YqOIjP_%0M2{q!a-tU^Fm2AZ6ED)I}rY@&3&x#h+7rQJw;m7 zSVh_Bc~1tbHQLX=R?8f8yq__+Y5Ng!v_^{o%}zYBh*S&_Yf`a2skiueahUZ4<|6!g zFRm3WK3m=x7o2+e&{Y9Rx-dWBXoTuE4L=-~O#f2H#n z;+rRi88`G?vilJ%*>a{n`}s)7r=%W#OS!cJqsmPU$0I}Z6lcpJ&~A8CTgoIBku$~G z_;bNaTi?vR#p^YTf{~vyOj=g?l6*!n;l*>$H*E_HwQDcz77Pf1p-=c${Zd-2!-^yt zCjjH;0vFt}ff3KKlPS~JX*OYaFu{cT7%}LkQgZ`2T^@Cy9BD%Wz}OP8e9#{QuFJ7q zp}by|kA`(#HHw@U94>DGWZR=7IV%nzw}%s%y7Z+H#_!qAhsAIpJRVZ&=akcArN}(m3US@Acg@EL` zE?gT~7uV8c9~TYr7j{(eL5hyP|G zKf(vE)|h)4tF2ZF_*jQ{zz!=YjxpM8v!ZR0YRLOw$5B4khn)#CjpX&@_dIA*=_B_b zC5i4}SU&ua-JF4s*R3TY$61Ko6LR~4=Ooxpuam;(=K8*?r*9&N^^u8n|}gL|Gd zx5MGP#NcJ>?+di!>jdIWc>pP5C*}v&w7){QmWRR41u4+x3drifu;M9-RB-hn*T7w7 zIQTFui-CTqI1m2~hWK5?%N+*n#a)cY4N}@Q6N*r`aHQU$@O2{RXS1G549U|oDdZz} zjUvYl;eKlzMCU>wf*Y>yqY}j-_cb4vIGOu~He8bAY0$uHiXE6ay47*!8_zQFkuAj2 zLu$zNJ9OmFg#KvWoyp!4*;A!EDH1!rBVKO*7SQ7U!L{q-&KZ7(qd%9I+8;uh!#*NwqaC-Bl~ z7a@Pr%$l#m>}enO_9c?NyK((3dbWO~`*6L6&UMTI4&^2m_?(}NOXQPY#8!IK$@p3z z<>PQ)I(;ys*m7FR`FuR7;Nf5|txxh3wdASGDCDx;AXdcc(|G{+tL~ZRKlvgPH;Jnf zxPqEMGCDC*R>$n&GGK1=V03)f27EvD6@!d}0HT7?0s2BYFO?K_(lhRkZ8gn8GQ&+2 zRVKjVqeUrh{`o6z7Y6t7{GDs*_M)z^PPn&IqfhvPZUU;>^8h~|(NvP`b@NmfJnSbu z2$eO8M;nSRLW%rhK+a})0W3Xbl`lO-X)ypS+uKNQvIgt7P0zysR!c4YJ?2abXr`1u zX<9uc943zTjre6sQA;kg&~S&V>S8lO@nzvu`z2P57sCFbGS6!&i6_SJ5PwuV!tHn z4Md?iz3$i#-*?z-Ua_Snxdn!Os0-o3icUUQL$9lfBvU7^{PGcUMOnvpYrKe11Zt0Y#| zCQ%jg*-HzD`4d8Pc~$1VK#_!8`;4T9iLfPj5MOx>iyWrNX~+NO*~Bk= zJ5WFve#>d6NuNkx$L?hNh~IaW$Tx(qX8w+m0dK%#G^)sIog1=2>_)fXxQ-WUig4tP zQprD2f}JSsW&g`#BEa3``QiD9XJBY(;)9iHb3?wk@Th zF`3Pc7}z!#2*@Ewv9Fp0zuVK;5dv!gFZ@p9<)fjIf5<`Gcpd9f{M#63yE5&Y{GwJ| z@zb;h^N=n;O)DOw4SFnfwmSDrZUTvAfI-Y^XdgsZcbgWY{x-MgvodvlY5}TcM*DU6 zd#RDD^zW+TTsI@3qvN-<-qj!EFjEsztNXwP=+e~bq1s)?_FE9uY?t><*o6s4%xw>g z_x`pAxSrW%T~>5b@4*O-J0NgpNP^b=z-&`?itPYV>rF8R>p zC1Z9t7}nvJen;`XZ1Fzfj)VfDRpGuA4U82sET#2b9QkP84I^Js6if} z-03s8)872QPHzr~)kP|YOQ&>pdC5@5KE=Jq8Zd4mNw!|Cuc^`CThEfQ*yGLFX-SN^ zh*uSfYoxwTv!s%R-A+`C^crY_XgijE>2u63=mo)V3Td+y(t3}n=K4r&dnBwSDy>+fKH>>LG(pu=2SaN+ z`hQV>Ms}gyoUFv7(kCr;&gkLM;zzO;+}YWWs18 zak{_|Va_ymcAFR(MJasg;aXmBPG<27b?}F__BL7#dX#-MMrizF+R6aM8!u-i-(JJ zQ_6xQ7vu0lng3>Rh+WQk$9M@OH$uC#&&0G(M}jdv?mUX~8jTF*IFD^yKMvkK@Lc_A zseT%6-ij-p5te=Q4P?}8c+v(Q$2p)Q2D^DOj-m5p{I?xP5)Vvc6&nl8=hgA(2Nr|$ z&u_8TZ=Few@|4|z0Kzv8c7>8kN`(-C*Q&65nbReUAT46p>>o%~RpOM!BpaMcH=OFN zREbUi9su>h0N_pt^_p7@Ngw9MMu5~DV~%v6+MEtFZVeYc zO`X2zhbbd;cq(|>=Q;&U4Iki(lpD02!N3mVI~cpRjG@MK_ZsFV7A-g*zf zmaa6koj%%|>Y*r&N4})3J2TA9#0U{%aO!F9DC%_gZmrAxgognm_<{!GJJ@I{<0yNf zR%)~vgE3#bJDSujbhMfL22yL^o$8{8=?^iV+xXRMghnG z_-sNp4xlQ%T`Wat-g9??h>a$yGWrBZ^wkjr-CU~o1#6ROuUpR&T4lI^Lg?K7Ps!`V zb#wG~Lcolq*NY@hXIm0lONpwn(Y9hY*Y6a*lbn5RSwU z6?3hMsg(g$Md-lH)g(#z65Mj5^1)QDt6USsIb0H9PLyih9S;R$c&l?j8C!gC80W0= zK{nPLfgK&yEbBO_4{#%IjD;+z_hO5*jGmO8}YbK)qZj)=W zQjhIPQi&deyk>1|dAIBmuM#I6zE$EVWcKma`;$ ztMUcgy6=(4ot2&_WzQOoU9hrU;kG(CQY$3x^V3P3R?!3nFoLJ1bR=n?RAbqd3>Y0Jv}gIO0F3wCgu*To<2V}<6Ji0w|FY8@>cHDOl4h}MK^to^ANwX z3Nyz!q`m|*iql|m?uoE;YFTVG?1PiszO+3ZHJ2{9L-F{k;bq)3C zkTLP(5<;z8tUxd7b#Y*|a$1>Zty|gySi?RGYv+wcigs+Ht)07=xN=+M{^9Dw$ypMDmELXwx<^ zPnK;7<)w9n#olxJ>-xbT{A-{6=5=0iq|>VKz^`s7PR?UZy&FEG`>tBJ`=ObbklIAs zENN={wxn9`q1_hmK3{YBef5Ly)~*cs+-tCraAZME+bUv5xGVmGR9?$2T z&`@l_Z&B=j4e+-{7RX~AH);;np8#d>eHKIlCvu3Ty^05U{zy)!s2 z-VlJtp+$?0BEjIaHkri76tu=^bztKf)ADi0%(kB9y41}fdpEMTUK(a0#)=gu5b37m zjJ67lA+r}J?+$>Bz>DzQL3o9gMXbDLq}_D%nV66 z;4~Ktf-#di8ROo0qr2g}`S6~3$tmUY}=zfcq*T6gJ(%@GMDvkTUdhN(Dto(<_!c;3}75c?UU=vdllL({9`zyZNk72V# zKALrTs%2X!OO?TsUY0Bco?dB&RDl3o1J=L{4CnlCP*btV3=UffW7J~`E&0B_FRkfe z*3^qaMV#+-&mpJo)X!gqlejzKd6J1sLUo3VoW^RWiU)R3ziKJQnSEEI>1IwPvpC6= z+$jz7PYW8;nP8{9FJu1g87SYc`zPtS?kwo%k4&vAr;zN%++& z8~qcb}~K+2ldX%zuaZkYO4NAa^z5LTorR;)6!SP+)H zix$Kpzcq$Um}HjFi`LkK&A26&;4D_;EN69c2=+AFOZ&=^P352C6|2M; zOBH40m*n%*%AraXL-YiM=n40eBe{!>~5Cvd);$xlBas^4vC<;Hmv{N*KW)c-ju)%h-Yzv8KZ>5``V45_t` zzkv7fgSPFnFz_)jWu~O&7v<;X0S_2REXf1z*k=RE083in&V3FB9tOsLOTg+uB0z9; Mi8^@0Kk|Nm00Jl_O#lD@ diff --git a/web/src/assets/fonts/DINEngschriftStd.woff2 b/web/src/assets/fonts/DINEngschriftStd.woff2 deleted file mode 100644 index 04e3f2bf82176e6cfb86c5d911fa2d4b698ef173..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12200 zcmV;ZFIUiaPew8T0RR91057Nj5C8xG0Cmg&053=Y0RR9100000000000000000000 z0000PG(Sd0R81TjNE?5eN!_r8NI}3xos!0X7081Be0yAO(y$2Zc8q z^j`(zm=&;b00Yjlv_(-SR%=nzia9Eu{eMetj6oHU{s)a@j|3-la+GT0sY>e&4*IYW zGRp4PoSAPyc6m4~{#mO&Xwp+{4O$ynEH;XRdnYeE61>Y18v7&{s@nVO*^7jzQa1i$ z44Q28)st9IX!HY%5-1$_7;Gpzc+1wD5pKdpsPtwxhb+_s6_Ntpm(%(xfuTwo@4iev61LQ~l-NT- zFM*}L$n$T`&W#A1i3dSa2X^^z`Ec7G|LqNavfZX-entk9v6owMrPSnEi#4|$&v<@A z#1M*ByJfiIxpu$XTnuO5<_5}=px~QmNbjbV$Nc}(*}v_~`hnkPf9@cOLR>V=U2^(g zRMORGtkGD0w&ZwiYboAO_CBxalkLFDl4aSS9NuhzX$e9LA%qIJW$IJCqRdHI5tn`X zj(Fa7=i%rHM;`!NKtqeQhdCQWXmgnas+R7a>bw1$j=Qd9AKqH2NC+Vf_9}M%t@R_D zem~VY+xmChT}zE10Rnj;ShRed=wCntPyr$o`27PXtyB!KN4q@(k}?1Y9FjWN|8rmf zAONmE4+#KVeRHn|0B%0=0x*D4fB}F(QlbI|2?7!~uXy0NJ&^ zfHJ+#k_y@>jnWV7wz-+LgIN?~@!1^4!L=TrMp{%%hWHdyy9)n_|HOZyKPAL7coy$J z=J2{m#-Nq-6SIP4_!NWJXjXz8%)NDt0{34rNH&N(ka+QN&wcQnY#^ZQS2o5B0srD} z$S;6>{a&cY7yEjyVF2Lm3uv!}9G;PFng-r0uIPnlcmQcX$Gg=mOL00zs7nDEdYK&g z5+D&uMX~K?4;foba-q?v_qgCZMd6btbi6NiJ1LKg;MWCQmql@6|38CsUcMPz>j|>y zU-+Eiq9(N4+Zh6p&Yt7#Jt?u@6Ukx<#Lx>sZ9?zxy)SmCXmI3}BKl$gq63S5ejc_O zPw=3Yf^Cr%$s7a-ik8~F!ladXZ{ry4iBR{6BNnis33%W=^DQ@(_7z+Xj>sZJU@R%w z1i=Q9167VTaH5U?g$T_Jh6fx^T1d2c(d9!Q6&wwOP8oxWP--G+h_VrfCN8Wvc=2>3 z(vy@1>a~FBPlQ23P(~7E)C4V)^i9Fq`-9BbPuU_F4aW{NoTldtxU=+}r%n##0u7g7 zU8e6U1NRwOyN36xPN8wK5n-U|(1c}3mk~V{)DYl#G$c$xEahDV1qE)j1p|F_C@dSW zZ4^pf7!8pxTBI=$3#}zpfqAL`oGGEzN==>sp5ukNP!-emzy-9cQ4W~1(FV}fXvdwR z<++kgD>)ZJ`j)wd3*ZCjfB{!=X;lyKB}0MH*BVkbB`n1S0c)eWq z@B>~BbqBQj*t&*XX*U1`l;=WFg=4^iEB69hv_!Zb4&_C#`I0(^{1E^}fXY!(Jd6OI-<3j`FAV9?7NmHg>1pTOpTf)(K@>b87% zSXy>W$KTJLScSk_Tz_P zfJ1>K+6_tbz~OLIB!%->AzcQHk7Mergdfd?_6!6A3p)(F9D}IA5Kq@o z0kO1-!EF@Lv>BFm9y&!;eHC_iei>k>b z@GBnx34>bU;x3rnncO^E*LFt;VDiTh^MCUiDYXUw=yKsdb_rkw1Q49Qu$+DBaoFxh zHWUGXZ2=JQYW#v-R*<5Us&dt+-!9hWTJ62zL^yMRBT*5J$>x~Mnqp1XY{;2qvk9|l zc_meKU9%LkOv@{8!oJxrV8xEOC{U3~QMszsZx`xv{gLxRu>>+)u7#&|Mx1dF_?Fkd z->q}2&+EN7DQUK4sp_S0A2#c<$lZho@Ry z<-d7tI&|sLXTXpVV2xBD*hriFd^@fRRa5OQPS z!MkfQv9N^*6((Fnqu|lIR*cwo!4q~Hd6K0_1$J|7It<`uCP++^+GVHR_S$ErLCkc} z0f)?4u;fFB$v#w0IPRoV9x0L`lh708rWDxhjZ)eE(`v-uiv91KEM?Yx^uZtgk)z6Q z_Bci?U7oM5`PX~7sy&saUX$(QF4S6U&4cG7Ue!l^ch+Suz4F`(zhJVw?mG3aiu%(3Br&)Ii1+U zyTJ~2!jfVo8gPLwn-YaO9E1la;KRGlklc|Hl|#;eVWsdj%_lxe7pyPNe}a^Eh4mO` z1KIMx8l)^Ct!HNY2k~gU#X!Rkd+i5fdaRfbtSkec+?vm>`)* z?r1I}S{Wfh0hyPn=3cgLrr)ecEs-J10K&F!czQkE z0#FnC5beF4-9WM@>*Q%jPh>1g>0$rOp~fc^w<>;0A_m_X;|tZqitOjo6W()4#;Ly= zq>aSW$cLnqc5%n~Pg{SD^4X2dc{FFQfU_BB(@J_ii&!z?D^+HOe7TbEF5nnsI_Q}U zAy$Bqpet9k1x$^GZ{NwEQz@?1O#$gDt1;hsK97Pa*+ zd-}+KPFY4|S%HF+Y2p`CEWed}EPliFp53JUD+dW{nkLgblA}2qALWP%0J1;;Q1E9| z(25|&2KGS<8$47oMxY>3a<#rBCP5mO@(|OsQoMLOZ_N}qU7<$?4nw<9aSG-zkIlSg z>fVu@zDatUMti89KJTgM_NF8*QQfKD_A2Vn)M zq)H-h1(yn%57nZ$jNt3EhSkf9n&Vsxd320|s%ytQ$PI2;jLN|Zv|La?5rcCLisBx| zX3>orjCuy+qXzbYs?o5EG|Axzn~C~qC+Y0RNXtMDVt^-!G((yB(v;!tX_wH28?WBo z`vnNiHgyyTH=0H_%}P!LN#tABmU9D!BcP_FNhdCeaQE)pmoe^7;V%8fC*ZNo(en<( zhZ&h!JHQ=y4?7{#x<1XA;PHkl?g2q5v9j#KkC96cJ>^m)=|xaZD^kcHlP%Oo zbD2wV9K2F;DlAhyDM~A-f{HN;gc0tyF8mrAs}&5)=++})ORXzppoOIowdOI9izexB z^diBUGP>OqwM#=E|6FGtuo`Ae8CY}3E}^#fBKk|z8^zUvERjx>oxMdqzD!#m)=#=! z`aSZ-7n3uA^-VRth#GqLSjinO0#Q{7Rf*uN&e7RQ9<*|~F@^@YF;J(0HLNn!2qt}1 zWie|?4SAxgC1bNmqEWMfwNGjes+a#2>^O4?2zdu8RGrrpM@=X#NetNe-nq}>)noDw zOfs4BK$vyq&68(OlCDqg|B?^dNh@xgICJ8hxz#&F0=Su5Y;XVE>dj*{%=2FeWTdC8 z!L$Bg-D+s}C!m4c4*XevSK2%VTCpLhYqYG2E3Rzu7u4{#2 zES;7xCAmV{f7>Tc-c8*&(ceb|Prq-Ue?B_WeB22ts-deucrYA3R4DT??6WkCgo42_ zMR(C+c#PC2Ame{IviK_f{|wVo{pjYBwDYv-psT|ap1?j(*Rzlu{!*2}!4XA@E~4|} z3MbBebYnMRS?TZ2{G-!Aj4rTl{McC)@)A)`9sb^FDt>_sQcG*7+$r}dw}=!)Zxkfw zNfxJWQPQ%UBHU2#HkofVY=cQFP)70-_i7Yib4n( z{BW>itU_I=vdr7!WhuKvnG9DTkpR|LWe;zU5c`2@KLE-=s6Bwvumu4!8$$6t)W{d- zMC{a@t;r8j%MziZ7e>X?z6)%VXS%NcVV;j07O{Ee>XUiIrh$d>WUirrpt-3Zo2GJ) zagBz9*BSB>QXQK|=>C!yvr87ew`3hsJW-mGQn(8YVKBmwZf}q6nlHw@sX_*<9=!?vt|Aq zqCG|V*;w_fvp3erfL?DS11miA92=yUSt_jdFo8o4puemG_AfNFL^U;cPVT2Ef>?p) ze_;{x12H!UN$Y|*0w&>>|mfe#1}LEus3Z|a# ztjpRf-o*jph}dtEBrvu%Ons$|?UKPjE`r zrIg5EphAbB#Q=NJq0dSVdE+r-_kPOvQwBM9Be~m3VoDs#BMOFP-Vk8e#`WBPKrm<1 zmU#nL+G>R&EAhyFQ`3E0CjEx&e9PCoo7e{?Do_TYGRLZ5?e~Lp)jw3$vlAudN3D^= zm}{$sH~yuUWlPgn26P4a)u^UlXud3-J42r1$PuvxUulUO!KH~zf-pi>FI?dOuppga zg@3|rQXY`xwa5<^C!2FKd)`w6jS`pNpYQ8m+5(Y>ZV4=vC){A5XpN@L_b)t-)o0Tv zjgv)19&B&{bS-vVd*7d(h$FG!dW+c%?3wK^h;E;|F=G;PEROMIC} z^;qPGwcI-Hw>~J2;3>Dhb@n5)KK+=Yh?FCBgpWt_usL4rJdBNk+j9Xh)FJ`Ld?Dd4 z9?ag~uRp0&3xP(abc_;jRc}>ySJ2K$IJG5Zw;pwWnt$PLg*?^gi-zl`&+nd`pJmQk zxe_}`U)w*OeGNK6AoT;eL+8J$O}=h%#tvyPl}Fh4%ieL}iqqToFMppe$~9MZ+~{Ai zXT^?iJm(`1BXIWGckf>vJF~+{{uQSL0|^%#;Y&i64!ZK`=kR%W2A-|W!-{$qp26qw zxu}H-ehCyu!BL;m4^?YhJl3CVn5VDz#U4Q;@P|W=a*|scUYh_v$Z6)VTyQzU6qcjT zA+~X$EMTu)r9NhUermbXtA_^4^S`>}+0!Ad8Jgdg!_# zXcnRtr7_!tnmyeTxxyqmhwA-K)vt3+J=8Zn^hXbNL@?5f-g~+{OX1X)Y&^y>t9OZ+ z+t=l6Gz-*mg}8OR0RP_lSGunC^va234z-!hi{FtH&u*qFKM9zLTmPId{dIURc9`H% zuqi~H;M$NTYCpr6I^QC%M{t{(S6Mk)yZ^-20>Kh5x@K-%G&tER9@t(mf9Hf-@4MN8 z;kvldOXL4dg|TBdlkKA2rY`#B6*0n+*&C|L@~nI89Z3wJe1{z`Akf-5PZJa%I|P-q zEi={U6?f~;fShZztQ_^8tkPky>fCbTvq0%fMiO4CK)!8Id0Q;!DZEy6E#EWeQl-kf z2IRX#jFi)@1=yHH>Ee=f$%#A-;A|Hv6d>+~LK%Py|FWa#uU91KXIB8#KDKLdAB1|B ze>x59vsnAl*wH!W;Qu57-@7-5*KQ6e0Ae1{6dPuVdwFWje+Y!zvWOr~hag*(vOVLxm%e5MWiNsHl@I)`t}P=q#Iw8p#lSL%>T3 zx7+57^7TX|ioHuGE*Bl!xYsSqG>J;8iY!y)5^{OD?uzIyVE)e?a?Bmv8di$CIjNY; zkZ1e}?5Ok0HdG9Aq!N6_{dTNtJ=GzSwDl9t&*oSjf?E`MCOEX)U- z7&zCbJ%dweU(aG8Hq&#NE6m_D35w3VB5Vyg=dS+Td+K zU|?gbz@lkBe%rb0)1xKzl6s0S{?ro#2GJM$=?+8ALkj(1E%WTPx6K8&77yr;3RHg? zeIA0)3(%E|*~*~nMZ#-EP#K8dpT{obL3yMfrDdE_E|g1iv*3^hHkS4XyecyJSE5&3 zts^bidZ;N&yU{vf`|Zu7^$(fG+7<)-xnqeqeUFk2>2gSf7M_x^7+-;f1peQS-@e z4~JLJz zG%v$!onKW;{~uCo_#ZHy&1-C(m#ZnwL#j{amo=sjQf`=d2PaSLP?Ib|%2A5|vigQz zS{pk!d2m&0$^P5w;WrC!fTck5Gjnx!5VSjJRr3i`0E}figYH|FV|VG|g8Sq^KXYEx z0uXI2B7dM<3)G&SLO;7AL4UmhHeM68+EA+~;QuEUW@+uJW1kG!V&;{zz4`B znasFV!x%iuhHh}&X*M)G&BLZRBFV6t5npGMiVlR(xgvor*`w|%vV+5kiRoxNI5U2G z#ks3glNm=1^(6%p-MpxP3lQ@PI7G{?tnudgt7jE=?r?NX6upd0IM6- zU3@1}FloT$v;$xL^B;I)$FSp;V%Jp19i7GMS^nO zJNvQi3Qy|ZbEI=zsqA{0S7J&ID9p~1W_2^{bJ_lhMs6v~Jj1VML!;8sY``g3Xpa+9 z3a&#`VRbIACTF%ukQGnSe^Hs_60>TUrhkYIA(7OH$A>2!DKeEWs_F8#}(Wy3TMVgJyZ1aYi8$ z46MeR&=4snCBbV5B!eTN=;hC|ie#I()VZZO?&v617x;T8)rhD< z2!7*nbr6B&>f{+r8gx0}kOjU<;1E?Ps##psUtLh#vBS|VtMXdf2mjW+IM3Ms&=4@b%KuoY<(k#{ci7+Yin*ssdT;;@_WPFQnUD=6fq#*Sd!vI z^&>dk+nQmKZjm>@uV0w0tKB&Z5E&>Rg^yS&_9q9i;6od-iN!oE}>1@dbT=b9n}5eFo+G_~L6SQ;e6lXkt^nq{A`W`W zEO$jrN6G=hT|{DtGdu*vm7IK$WqgZUWo$hiDt>TOKa1?r7H=ua=M;+sZdr^5(EAGo z+W`{5nK==O*~oVj6p0e7)#gIh<6pU14AEoiE8SYYy}(740*?%)82G12>5YkV5^iVE z-K&O=g3*RnpB9T44a37Y-Q*eEA6*1R5(LELi-}rpgumUwmYd2ozJF21^z5BwA-1qL zSkXFDI<08xB_V9(;ppKi)&9dBip*zCX)4i*_9!kZG#@z&^iul^Y(mVN)1oZ9LJGtI zTsQuvc(zAlRMK)}05Qs!;#0(H3*7gv{}gQ5y4N(!2$K_7q#&zb)U}c6w0AlNWP30y zm93nuQ?%L-=|$*0b`ML-bSwiZOB@c&GS|1B!i?fXMZuAwl$tuRWD%WeN@$&!!iu_G zm`SOx4WUJG0#{`9^~+MApYpopb*7YF{Ni&D1h*VofXCEX#F_AcC@M+ony2Yn(0g#QdKBEBNIC+@^=n2&0MuVZD(`S-mKsg zU}NVU03gaC#Zkq8yj}nq!*0>t zZcTP-^501hCZ_Kte_|2E#&*7ZNbj;^2}Ul*lx)8eEx!;ws6-XJvc3CWAeRH|0^J|F z8|bM217<*TDM%HH)Xw)37-Yl!el3XjbZJ6-RaWBsz-jgZGArIY$g~s?KHpW`xOU^( z%H){ExOelwr}xE(P^#hQ2z>KkYk~T_hM(p`@rw3fFrTIb+k7r6FHYa^Pb~eXR8aQV zmC|Jp(t9Eo5kTHWWz&NN4CmE{?wx9CI@OF;1=u^xMd>0#%2<*vF=Or+s~-)pl36CV zt}umHFAOIWc&+DxKMkDAL(ErNI2)$w=$aY^0QK8fTYN`Vx%!&=hRS$%Nf5U-H*<4g zc~msPB*6BYIoVAZT~JYwEKW?PNt}#v26`4cn;pA=(%_KHcvZuAz^H5LA(`0`q1l0J z;-Imv5=nU5y@zAxK3uqw0#vvIKzV&o8i1Ew4x615a{qD#)(0C8@{%fxbrK^Ief+~? za?O1sbF9obkwygjAPXn6&6c)F;3VsIbIbrwC*=cpwA$;qqJk65;!0GwYGJ?MQ>vdY z^=YuLA6OV(sg+YHCdXao#=SBSRcl#$X|wc9dc|AmTi+JnT8!sD zoRzOOS83^Fn5v^6U=$!_avO@$c=f^vRt^G|S&X|nq_2ZGwbkvbH0X;~#R@(V`TA+R z%^Q-OyFMD)9uA5iL|ys;dam6GJDUIxH(LFZMRhmsh;a+b_}D>E)0eAjRigwU|8mLr z45xOqp4D!`S2VzX5#WTpiUia55qO)&I-Ms^Qh9>ehj5(jWBhU@uDU{|7!)^0=K-_> z+{((?N)g8^M70X&^!x9f0i^qE-e7rrRv;J5jw|v=#$ld$KR$f@#Cx#eV3p)av`)CI z7t5?D*bR9?ium%8Q(N#cMGXx_VAI2h_JPeT5<@!%Mx@bX-?25=@B}YK zvlopg+hA*Q*s`Fh4{bH0BfTxXNQdh2 zU~%i$(^}Ga4wti)GOF|Umofe!Gpq$01c~6e1!Kk)mc#RSL6wMW{u-)hG=eprC;fM! zE<(_6z;Xj&{ED`eM^sVFBS=reog<1LeWfc_)vIUElWDB!|z?px`f|^2P>A+#W zFk)YbedaCmp!7W>jApmb=4;9&KAx!*6h`y%4P(&*wQ95&@W4=-w{JKrIKZwl7gFoM zO^AsIWV0L8vo{b(H^-Cpab;`ja`mE~wTCGcKZUOB)%5=G%TaE2@A&!cF;yP4EE92L zu0tx*u{@|-$TSuDM4E-Dx-L8I^KG&^;dqo#>nwGaczQdBWzcLfOLg!<@7Pun7HVNv zTWu?vr1NK5OnI`8MzG>JlUY?Z!R$(B0|chAt{N*y+bFn(e*VTXyl6I{jv6U~R^xy>UDfieGzxXGEP>-HiK7 zJ336orM{{(nof9_lM`gxd>#+2e zt-Fu)G)HoxI;pl>JJZf&*I0o$yjStKJZipalMpUZPfwfi$9`bAW4b?p?B})Ui|lBnL7t!)^}x+D2f_N zt?Q`WjN5X9Uzs@^EwuD*)5%<@*YuN`LVHi;nm+9MkQ=h+r=4PO#!h~=BcQgXCPP2| zuu0r()VB8`t_9i0iF%i00`lXP@DE-x>xkIMaW4w-j=;O&b5#tE!``4h98obAwMG{g3*e8n6SRzfFa@ab5g%Jk|d7Ec3amRH7E((`aF1!5N?mA{E zbAiE@Lf3T$$Ud`la90lGS-cL=Vwf%S0)+w~GMIv&nx%j#b(~#Mglt>H;n9<@Mk|^{ zh#Hbf&8yHzauX}V-jvphqW=OAya`|2a>Mmwz}e*D?;{DLQSB3pOEP6=zl09upK@*@ zuk5Foej9s~p1`Txozx(9=Ghw6sb|&4=^xP6L=Bg0q&@)bqDXktj;Xxxw(!twW@0xl zdGpwGm3UNA8P}OaNd$94W8PD*&&hQQwvM@L65wV>UdGx`ldgPcF~ZVAX^E+Qac~_o zx3^hxYB)cUULT(A9}<-YET7-j8lTX1kZ(at`lqoSM{T^y4atRmWXctT>0h(<@n&Rj z95*tvXnUPN{x93b(+k)>Nn?2#hUac3Thq3E3;zA|j270!f34>y%iB1zkRPmHd(y}> zYJPXMeA6f7rVyjA%N2E|6nHE;?dajlERu0_VaDNCnb|O~)zfZ2XTv5xVEIJu*>|q? zRor88bq@7ll96Zca`n5I$JE+9>Ku4@zq7r)vwcLL0O`A`rA;(W0ED!0=|eFk@Z`|J0Ml2<-&bpsMSvg8au`+tC>@)mH8!V{23_=s1qf)2p7 zJx!VY6ce;BE9wo~dG_YVHo!S-aD?yul@;eZy1*DaITxm{rQY%m2LZ%SZUcbG3*A!V@#IN%O zLT;}8A=PgGKhOH~=M<)G;9t-~x%Oh53(5qmEsvgS_yYiXDgA+&&U8?yZx1~u_nxOfwJJfqVinRB|DLa>4S#! zuwKS5uksU_PblOKTL4ZjkX@d?;40r`I4no=o_4URAW=zofo_@iI(syi^2nQ*^J@X5 zDWCyl2gx0sIZvW2h0PtADL)&8z03EE-)sRugJu>q*4pSwco}J+gQTd$exlcnV~98d z1h0eQKqufQmD)C06zRJWA)2vQN=rjx&_Fez(Nq;a=wJV29!Z-4DCGkh(sD7XNfXnX z@JPVBP(zTkUA~Tg!g#DNNN~UyO#wAGM+1#p#(On0B8?j1Q?4<32>sO}J@$K`<<42C{qwDx9U21W^zD zl4ivYlglx&wUs3_2N$?4Iz6lW+~~m!(W-a2S{||pl2{pr(ZT@u1ujmB4I3;CO?UFpt9v5AH-1Z5=`^Ye5SYSt4jYZX&wZ0$$K-<*{Omlm$ z9dKmM%Jbjg;7?388aR+Vt>wS=CvEdmvu+*6?In#8X!n-}1A$-&&}F+;=dDEJoU_R&%LnAA09;o+;iWmhyL^+ zNSG=ZHE+RTM=V>kM6TXBE%v$Kl+#W`4YmPvnv6Oavd4&F@1p@jft~t|*{#Q*t&V0R z3@uGz%0gT6gwD=^&i*(>y`(JvLnU(5O8|s7C#mP>Eo5wqKqgK1CkAGM$j~d&SV}dO qFBH1F5Ijk$&x`=*cml!24H+clt5Lq-)SMEPVqy7BmH5d*(Q*L2Y+2C& diff --git a/web/src/assets/images/logo.png b/web/src/assets/images/logo.png deleted file mode 100644 index 2735ab357d295b048372ffba0358d7e27a0a023a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10833 zcmdUVcTki~w`b3gv#%mKDIkdCAjp7#A~1kt5s;)PIfo$$GZ;{bibRRRkVJBli~&#) z(IGR!fT#os`Z8pJ?cx1ucW>2J?X7$Fk8iD_=JDx%x=)|}opZW>=R7bm(q&=fVFUoM zp!KxP06@Y=B%r5-Kje_XZTLeSa1L!j5C7eyzkVP7pTSqpIso<`q5L7viD!num)wCD ztOCt_oCAZe`riP-!NJmQ-W~x?SAB0t`}n(LF01hXAOz6bS{5N$OB3N)wm}j5zc)6r z6kMEFGJVadkqp|}clG|#GL_6npQf!sD%jGRYBADZGKowLL&%gT=4!~?J#JQQ!{OIB z+I(!{7`sK)lJTMY)C*EN$AqYFz4@|!8l_N-y-t<+@Z!3BK)cJ2ov^b4q1S_@XIllM zCRTI$n=-^>?u2}++2TM~3hV?_;W22hB%7YSoVlN$&e^IsPBgoC>T2GW?|#d5gxl$T zcY2bXvxWQxC&g=V+({quaZaoI0_Il=MkEOkQ|5g2%pEZTm2z9)=Qz2=eyH)L;|qbrv6KCK%~mqr zX^lX;AA)u(dx~?*2YL4KM{mWq3qB`DtYDm4uWU0#+=ji6Fm{T-)e4J9eQsdcyCPaY zdXd_Kjt0cP;wrF^SkonK2>w#rFM5^Tn@aqc9LSDifW0F`vqk6C(7IO=s0OKFUkUnK zFD)d3(pGkI7iA=O3YXN-+6XQePwd{w@rKV1;wP&z=}V*-f$gE_>1`o;?^U&Zn*`pF z2dgxWizT~9>bwfD(RkLD2e?fWFm>3;kUe^OL5RLWxx3}_S}kLi6ln5nM-%QQojpE( zsMimi5(mC1D?5d<%qzckr(QuN(-K2HeJcPI%@Uoa*K9^;Pn`jlreI37e^p{WFzoIM zo#31dS=|L?oRxKwGMqW#d(xgh!L!#7r=14?F(ilc?$zx{A^J&@!`u}QlrbMqJ7v5! zQM+UFW?n61X`CE8aMbNi7tb~#2~xk@+P!Fc)HK1%|XI6RKnkr`r3?=ve z;MCjRPuer}{DS}r7Nr&+K7DeGyna2zeB`TnZNsXC`FTU&KX$VP-|t3@j3oK<<$22@ zK+%cE@18T>8cc#(7-*>dU@>7#>19NytY4zXp56nvC)@%8XWe~$r`~nNrrNupdJTt` zsD;w{AOu;GCa7)aAEJbmZtb{5=e%h8YW{6S|BZ*Qz}qQkT)0;R?!BzzR$ z<9iYC`Bh=#IHg^Q((aYiezNo_>l@&C6$vbKxr%a`U;lamWl<$spD6P_0ad-{iayoV zEeIaUc71)Tb^iQqj~0&Hu!bec$Co&1pd1d*yoxvY-BC;cw3tb!ncu!mMY5~&b*qFu z9?jWNHtRg0QKW4I!;$w_H4`X_mu1dneNFxi@m}odlA(7^j514nQlvCxxvJ)p91co~ zikmwXUB#{anA8U%HI$7U5Zx`tpZ9b@07upL-~DA(R5r7rLiRL>))-O013@H9FPn{H zH~?@y9&?<9mDRq1Izw}hFTy!Eu<-AKOeZAR%#Jq0gd=NDt66yyvg&d)I6s8Hyu!X@#xVb;h3&R z^#;vG9*x;u?g*}ySP~-|dsTrB0KbzQu{()z%$0V8pz$)#A61rcKmSA~MIN7DYLExO z@%7xC>+G*zpM25+v$s{Ra6f0qRU7XGqgCH`ZzDiexrKxh3me=1hhY(+b);-VKQ?1@ zlM3)%mPmP`qZ7T~(=CUn=D<}K?D`yW!U7=TMdUY;O!(uD9&yvk`WYXN{1xQaDF^^c zoFYg9$K|f3kE4v8qZcAohS&hkj*FZ7%<+%0G+kz(TY6KZwEE5ay<)HT@luTNtFxY+1>_dYJ#+*}wNviH8kq$!>0b7W^10ZN{D z^-wWKxWM&RKL6ogrjFPw!k;f|H3T5b65_Ax+V4Gg>00*#NQj)gQvMwQaB1wZ!gO#R z^02#9r39r3n$O6yYDoa9JYK&(KN~1@Hpo;d^-?)QCrgt2B^!vM(YxX?7Lo}!8+m%4 z_)$bVgI*~)2OFyB3JMBpIc03ZCJfV@^2ML5X9iFV+ri-3!lMV^Yqj@;LW3|!r-ja| zuTP3@jdMFEXI(mVE|C&*St5SD|2p@8G{k;n6yCt4#vcG#_1CXo>jptl z#$8Ak%OLDSh39q9dx4jkA*hS2_?klDst3kXjf0WCQTUmnEqee=XrMIZ-~G2x1EDL) zY{(BcR>KXp>Yei*K=nP&i7>C%XWM*wJ+UogF{Ur1;a+u25E)4+YdB3gcwZXdyN zw-(^KYnI}0sh8KGa)>h?Voe_m-as#d(=M}+D6*B_9k_1a(!)U6^(68ge1wYti)Iq^ zmpuKW@we9JWyoHmOFvIkKWC!2CS0>gsYR=Q6S+^l&AagkuG(X7--vMR(g#|hW6(Fo zzuVX~GdB-t?)Y>$V@@gj3CI$Ht_BRI_&4!sn|}W~(dKMSGu0~b0|9{C$rSyxrR6OK z3gY_pR0|d!z?JkJjqTck(Egb5DQ7(E&^-PHpiZ#Z9EK1F{^;fQ)i3$k+)ilS;M zk@7KTuE~xAx}1(m{9PpjY5?3TtR$CP@IMEIF>7D6kKk%WXzF9(`>!U_X&N16Jz^3d zZXW?$0o==$thtWo6zvL&@Y}6w!&!dl7n%jYFp{?XXS3o2Fu3-X3V>^7rO7hmTh(?) zagVRPg$KH8(EF0=R-I7naOT`3^6=}NB98HffRlP24->A&~b;W>{FiiJ^mz0!r0#jNJeI2j)-0Ui+&I?jCBWazvi zQaF=HdFQpLf@5mHYP18{J!t4r(9kzL_n$y%aKi7u|NV&Au`Y5lt&bP2YyFQJEF|is z=T-Y2+dlz(BBk%_MIeAZOxe*f36Je1KxG3bbfIrVWAcv_rZIL-bJW6t(jnxWL{31l zR0_=={4k&hm<1QVktTld&bA^@FX>%QsCO*BJarXR{d2|xj#?fb#K@dYGIV<8`QoY> z6rr5*OZ;2K<{gPza8qwW(_KNkH}b=kyb+7Z#~7PsE(yUC>{(Mhhp}1N!LyO6!Vx$P z-;oQZM`++fkM<+NA{?sojOknW9sIv(az6Z>N*9koVjzGh4>u2_JrQ$!2F>V22Veev zkgu1sbO??ed7xiy_^wmyKljK!%8KLXc2e}2w3V{*#sA<_)6LH2Uszl;Iq|rfpM}M> zH&v=ZI8W=6#L=U%7u0ta1DZE_Hs+UGuqFD1*H9eYa$^ep@t*f^$@+zX1hm+K9h0$T zczDZ_iZ49;`Go}zIW;dg^D4^9I-(f05qfu&`Pwv_zRW*S4{ul!_hp8CAXe_~9c^A4 zCVJ}n3A6x7`EJ*Ff^=KBTUwl4!wj?!Zl}6$6R|;~f6fOjDdoB};x6z}yFQT9S=sLl zwF!HF7}{Hw0(S3e^YV7(+tpF#u~!|fq6TT0`OO*9brdbIsvWTppH~>UNx(3>m;%X= z*Pofl%Y}9gP5>9QIxAQd(63I!FpHUjYpME$$-UVhM+HH5pp-Q}HI;)XmvtU-e+(^_ z{r2-7kWG-&NppxSUE=!q6uf!MRS+ztHilsadRfAWVd`|$-Er6@uD(p=wAeqOlX`ON zWAJFg6g#749EYR|0YeS+o;U7qJ!lYCxr;nhaE7kGnjZlU|2$eb+K-_wHUZZL$HuOr zkU+$eEBc>K!ti2c7^P&?}WlxR2t zP$8L1fEAwD{+|s0-xu)zJOV}R!)<@}d+7oUc~Qnw3}Afo?R`+U6rfKAQ$m#MDC>dg z{F^=Ibz(r;&FZZ{;$Jv^waB+f_?xZfj>7%_A^6wYU3mIHm8h-JQ5Nv0X}y~NCAT5e zw(lW|2ct9nH+(d4MBxnldDm}Ul@90s-`v;sg3dIVNE#qmo-}&Z|5^ApA%Y>KH;arIv z0qN*4tLfN8exBsOb1L~Wukuwh4k_M=nf>V}Q-I|)DwCZfCQymc(f}@(izh!lG0VF` zdxbncSTcDyVQ#dl#Q6kOkm%oP>N76n6=AmstU=!4{ViPDQ+P`SK0`G^ycdX_4c)V`J$)T%L(!sp%IOg;#EP)|3yc;#zolt%Ro3G|j0&kGVx)IRjqY1+1a^4e3WJOH`Q+5mwX>j`mU!>I>{yg=N2O};lRB1%T?b+ ziN)Q|*=jMUqD@IF_Uv}?iI2T>&i79tkP3=C+!OMn`Tag_BTPpl@$Q9Rcbco^4LH4B zooCoA5FO<>iLEYIeL>P->Aqx@(o~EiYG}=# z{+)>;hkFyK2H9yqN%hOOb4*vp$tH4NR%7`akENJ;pGWQNL~%jcxZ-ql9LC0r1QUIu zJGJ*7x?5t-^A>L+x;zF^1ORdRLt(zuE9BHNpIk|vI(|FT-d4TH?A z_Ov{??VZm?>XzR|4mnb3gyKXyq-mN%^M)|Y9?KRjsVr5esH^TGfmPs&p3SLfg$AcL z%NwN}yQGVJ%nYOOL?O8|X;Qm4_sNd>RD0zq<Z05JCt@M%$bj-xU6>Q7FINBW26Mt%qa;4-b(9TtDjxJJ9rVML5Tz95N_y!UYA{Yk9mtzQH5B5(hGdP02ycbS2Y1sUO95blBkXdM_Y@sjgb zsN$7Rbd-)3U9DJU6Vay_czfvTh?lPKtEs8H9PD1A6E=smzOk+0zPmkJfAuvj@%Q>% zT4C}sFZ$;j^8F?8OTP`Dq4&4XMf`b|p@D5@)wtPCIsH_IbVX7QCpI=V$@)XOdpf?B z(v+~W*}=n7+|*^DlI!$d23IGB9jM$`{&HS1c=_0SXHr@2pKl#Oq&AJu zxqEe=G0)S+4s-9WYQ~=Z(maGo=C&57pD#6&@Ap(8e+msFZH4lO&8ZPh4|p!KD_1g8 zC9UyYv82M>{hKLaVcsL%caBEwOB5*uzra^~s9F*qs5#;K`LeRe%8|9!(15kyWfdm6 zy7!jEahISNeXDA_W4Ucw_xMj6D@V*Ym)o~>WAOZ6;5w-O)Fc?jk0;2BW}3U%wLTrg z#HGzI*x>(!&WCgSwr!v4ovL7{iv0H1)YH2mB3`KXy?>4k zJztDI?WH$rw^y;dSp02gt#*g{K0Ju0cRtc>w-;-hS!!@vbhOY>W~p1~y+W)LMJThz zs2P)DRa3A}FWhE$t1y`&xZ|2%q@qcHhCQJ&6W8$xLD%{o>M9iaRi{kVHOJ4B)r>ND z`R%FBMeWJSjTbYC19CllUMSj!u8=cSz9sbOp7ge3J}cuFZ}YB!w8n<{$51I&aXqx2 zMulsI1kdcfbD08y7l(@@-H2LP!M$Hy@Z9=97Xj9%QYvrW^>0$}>`AA@2pLbek@`&H znxmY>MQLBpID5Stff1ppMiEc#?!NrCe0l%NtcEaY_A+mk*gK6%dOciY`;yf5SibT>3dvzn^H=N`OXRd_a|3| zBX^cuP_!nTkkS;%4)8V_v0uHbP14pI$4uY^6Y*;^kLt`~6vf1Ue#*m#YN2;Rj@Cbk zu`$G39(kv%bF|-lEJT8*pzrTBqQ5>HJJkg0o9J?9`^3BJ^6yWCm{Ezaz-_=^ZoQ@< zWgAkny>odqLEf!E-o~7<&iPg>eL2x0cY}L0rL3aaET?BqFkLdz26xh`=6dz|WVj4b zuIjOHLP)%Qz?*5Pt03st47DT-=bzWsHaIzo3ATN$^wb3ltZ<5a;l!K#BPqrY;T7OY zdASs&J4CACdFkM2!u&To-cBWce*OiUnztC{JZV!H!P3>kQztjt1H5{CCrJ^%1LTV$ zKU#pKJMg0c-b8gBgH5tqU$Mrn@|W*y=?EA-sfu$NEPO&IM9%w`4je=Wf}MnkPJ zb2*V;GEL^b<)u}6Hj|)Xv=SMdw#2150mG?6veCWmUpF|*%Hyx}504}07At#YI^IAT zj!~l9s7K{k^S7i}c4}MOT1)wixU>6r`~5!&nuxN4JbhMm&z!THFE`$8JPt2z4NHMT zEsV6|1693HjGttow>`?g+O08gPo~A{>RTn`v%>Q7i^?~5!-6oF@dTtIJ0mc;Q5in* zSM%5Ry+dEVTu13o5M6R3$ZX0tFG@-EH533AsG`Bn|K(3wmc)ZRK4zWSUbx)xMvuO8q`UJ7@)6)!z0(;?HlvId4vSwoHfSR5UN>+hMi}p%g}WFktEx zn!R^69?{hZ%TRUkQ{4IZqc5{8vty0LyO#M4yd=GuQ_|(D@Tl105Qq5k^2Ys(ox1ZdpVMDm49At?L&^LJ0~r<(Tf+g&n|H&AAm+Gb^p z2Er!;VjvZs^k+CvV8M(%*?z`V#=l@-phnu^a-;0hvoB#cd{!i6DVFgbzoBY^J*dT=X;i4-e zmORMgBkek~Kl%yJbOnZ}*wjjf%0^sWUEk^#wQWoqh;^*;hY^WJpPpOSKEDoRBOp9K z-nm+$ED#{_x;Y{4Zv7Y~oJ$v;140tasi z2mQ)Avye6Pu6{d86*|6!bUB@!rN+v2qK`w{{0k>hbc+53V~yRo;fe?|US;pWsm?rF zT3RJpm6E6^1Pd!O%y1B$q@<+AgGmwf8)w7a))&WMkWW=rRUQ01S&=%-5fm8cy1r!nf)PhDR|8X+A>B&AzbTh;NuJjssZDv!8UzC;fmTJ@jZ$y%Z}f-K0~BVB>| z10G{ikmQ28WiAfnwkK91D2SDCxVnm6Q=nXH9zR+>JAO8MnJL`#Rx>sJ;mOIJL}ow%SzLE zE4T8d_I`V-BZcB=3|sRF`dQvMmM|^Ch!gEi<0KE{o;_$g@P2%Q^ONZC?1|qsZv)c3FBuL5)vG-Ve4bFfh&nC0~5jf5B;#OWD$Ch?W=R{ zTcP}U>PArwmxp&l4k^|5kkEgL-7q%$jG+hMkf2kX8u|+hWEk&pIUP49SpLIU#>w3{ zpccEi%nz6oE~gLTEtKng!by1wr3hC7qa5Tp43vLmIDtg2ZTb*Y{n45fM#msfV5qY@5_LW>lLLIAUz~!+Y}KG@q*UxsUWvKwX;`1 z?4V3OMNitmR_*@$O*7RY8vjP{U&C~VDEsX1bs;|u5Y;8;i!IG4+&3J_3H#J%W__hC zJI6&Ce$DcB<95nSM=@m}K-%yY+~563Gv$&*`!yC$3Tzb^%e@~41I#w6-*_gk4$ z#)14hc2S1Rl7RJ`Wx&1hhF|l$lxSATpoIAOJ-V5=qgYs6R*mkRDq+v*Uw% zPe7#D zblZ=TbK!P6-oILzJqI%wouhqG-nnz306et4%2gqNUCqs2@L0_np+3)SrQj*d@_n3~Gm_tct6?Z@4GV^$!9m9EE`9-^K#{;qB=LBSR_ zIdH&YlCs06vW+TXk;4^rn8OM}3Zb#5K0Wu=kej}+17%t( z9LTs*ygl#mWD*YVof^2WNP>y(XF@6ZUTYEif96|*ZD8h10KsV>VLZDFm-pk9Y{bi) ziY<*t^G0BBfVg{KMGEr%r{ps}JcGIUu;QIPH%^>fZc?3hbT8aj^QlmKQF=22=eoFz z)+V^PTKp;J8kp~sRmw2@5ltxzP_T}Eknh^91@p-2Nt1FjG>H2{|Dehd9zF)6!y{i@ ztIUvgDQW=?EfxgdlvGIbVP0|-$QtIF$KSYANZHDL9fIz^@|_um1Hnwe(pOAj+YQc6 z49w8$nl-pHQJa`UpRqux0%+aId6flIr8blyO$bjUWMC4cc&lB5Cwkg%$krL=V~u?_ zIJ`1JoJ9akslRy7RbW^&|7Y)ify(K>VM5HReI*TQ^d`(4 z*HUb|4b2A=k)6q2#FgBpypB&B7UjdHy$m>LDT2v?6?nIaQn;O*N=ix*O}?}I`?kG7 zgLOd93`)Bp8u4QICj#XCrKqH|@ulh7R*!pI&J#|6UYK8MHzm~jJ!b;Au#|U-6OCUh zzPof)S6DIPMApa{2jTgm7sGTwQypDA(9!x+V{doKuUw`9Df{CvdY@xe6#?|>;1bY< zuL{1}x;&?VwB`7`Cwim131(=MBp_OtUz0tcITWgl@NOJvM>xqSrA}fwhzdLu(K^Ho zfi?mY#ix7PmPeTmX7x#}Gf9SqS^z!A`|I*OluT(ub>t};%_O+m+{fh8)hJ5338vKK z9=oYN@2FbDdftHb26#>}MC_ZWC;)WtT-`KO&CYQ8`FI~=$%G?+l|#7PH>XAb7U^uG zeR<}A0MEr$l6BuQtqMOZ!r(#YR%cWB(gagce|-$~z&RVb7k(`QDLXmZbxZoM4ORdY z9~Kq;$Pw}SQ+VennD6SP4dB!QniC4$B~49Ry?%C+!4iirLlJz0$d>3t;zuOt4BTvY zaQF5;tC7IFW2k$aB1ZWglF+<^7i1_LC0@ziY%VPg^)C0BM;&>fO7D9dN@9j~NKJ0Y zrg0VFqo%h=lhc8q?RfseBXk&(s*YZycBI57UXeCy0uK>--_fI_4c*MlD<4bm#dlbU z{iCAG`l>c&unYx&IL_#KvCJn=X4#i*F_{OYD=h$%3JVdE$VzB#qBSn8TN*ezckAqt z?EFUl4)Aixc-EW&;zkCD5s&dSNs+FwzNPbGsfGLbfPmhwVP7^4f>aS)GHHFYA`uq| zr{GE!SdIY41?Ud1%V+Q|+gAVaCt5q_ON)USh@cHURa-(((tB9e1bD1YJhq0HHh+^g zpFf4P=^!qRQasdbO7MgACu=wcAbSQ*uq&3SlToI%&}J4C_NZLs;*j&Pp(Gt{s;c_I%w1D%%+?7Q{##%-n@rU)Ye#br;#s5B*GET*SwOyEvZT|9yO zdLDr@h?6triY~h?^kR~0r5pO0F3gF{ESUpe5{EahD`%EWW{1UZgRnqLn*OzK#3Aa& z&>XB64F3hNo=ppp4)gh>%LMCjOWK46KDrVHM!<9F5cTa+iv?*IaK{|1)(wcEN%xVm z3=n_xXakXKk@@l@8#w>7+enYnLN?In)_R= zc6_z&YIuByu1ENTM!mj$~k*9ZG_yQVGe=Y8j+zI*a z - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/src/assets/images/user-default.png b/web/src/assets/images/user_default.png similarity index 100% rename from web/src/assets/images/user-default.png rename to web/src/assets/images/user_default.png diff --git a/web/src/authentik.css b/web/src/authentik.css new file mode 100644 index 00000000..cda7830d --- /dev/null +++ b/web/src/authentik.css @@ -0,0 +1,87 @@ +html { + --pf-c-nav__link--PaddingTop: 0.5rem; + --pf-c-nav__link--PaddingRight: 0.5rem; + --pf-c-nav__link--PaddingBottom: 0.5rem; + --pf-c-nav__link--PaddingLeft: 0.5rem; +} + +/* Fix patternfly sidebar and header with open Modal */ +.pf-c-page__sidebar { + z-index: 0; +} + +.pf-c-page__header { + z-index: 0; +} + +/* Ensure card is displayed on small screens */ +.pf-c-login__main { + display: block; +} + +/* login page's icons */ +.pf-c-login__main-footer-links-item-link img { + fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill); + width: 100%; + max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width); + height: 100%; + max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height); +} + +/* fix multiple selects height */ +select[multiple] { + height: initial; +} + +/* Form with user */ +.form-control-static { + margin-top: var(--pf-global--spacer--sm); + display: flex; + align-items: center; + justify-content: space-between; +} +.form-control-static .left { + display: flex; + align-items: center; +} +.form-control-static img { + margin-right: var(--pf-global--spacer--xs); +} +.form-control-static a { + padding-top: var(--pf-global--spacer--xs); + padding-bottom: var(--pf-global--spacer--xs); + line-height: var(--pf-global--spacer--xl); +} + +/* Static OTP Tokens, authentik.stages.otp_static */ +.ak-otp-tokens { + list-style: circle; + columns: 2; + -webkit-columns: 2; + -moz-columns: 2; + margin-left: var(--pf-global--spacer--xs); +} +.ak-otp-tokens li { + font-size: var(--pf-global--FontSize--2xl); + font-family: monospace; +} + +/* Fix pre elements within alerts */ +.pf-c-alert pre { + white-space: pre-wrap; +} + +.pf-c-content h1 { + display: flex; + align-items: flex-start; +} +.pf-c-content h1 i { + font-style: normal; +} +.pf-c-content h1 :first-child { + margin-right: var(--pf-global--spacer--sm); +} + +.subtext { + font-size: var(--pf-global--FontSize--sm); +} diff --git a/web/src/common/styles.ts b/web/src/common/styles.ts index fc234d22..0e5629ee 100644 --- a/web/src/common/styles.ts +++ b/web/src/common/styles.ts @@ -5,7 +5,7 @@ import PFAddons from "@patternfly/patternfly/patternfly-addons.css"; // @ts-ignore import FA from "@fortawesome/fontawesome-free/css/fontawesome.css"; // @ts-ignore -import PBGlobal from "../passbook.css"; +import PBGlobal from "../authentik.css"; import { CSSResult } from "lit-element"; export const COMMON_STYLES: CSSResult[] = [PF, PFAddons, FA, PBGlobal]; diff --git a/web/src/elements/AdminLoginsChart.ts b/web/src/elements/AdminLoginsChart.ts index 04c5ad2a..4142a217 100644 --- a/web/src/elements/AdminLoginsChart.ts +++ b/web/src/elements/AdminLoginsChart.ts @@ -6,7 +6,7 @@ interface TickValue { major: boolean; } -@customElement("pb-admin-logins-chart") +@customElement("ak-admin-logins-chart") export class AdminLoginsChart extends LitElement { @property() url = ""; diff --git a/web/src/elements/CodeMirror.ts b/web/src/elements/CodeMirror.ts index 5baba60b..b47ad341 100644 --- a/web/src/elements/CodeMirror.ts +++ b/web/src/elements/CodeMirror.ts @@ -7,7 +7,7 @@ import "codemirror/mode/xml/xml.js"; import "codemirror/mode/yaml/yaml.js"; import "codemirror/mode/python/python.js"; -@customElement("pb-codemirror") +@customElement("ak-codemirror") export class CodeMirrorTextarea extends LitElement { @property({type: Boolean}) readOnly = false; diff --git a/web/src/elements/Messages.ts b/web/src/elements/Messages.ts index 9fd990b8..248ee591 100644 --- a/web/src/elements/Messages.ts +++ b/web/src/elements/Messages.ts @@ -18,7 +18,7 @@ interface Message { message: string; } -@customElement("pb-messages") +@customElement("ak-messages") export class Messages extends LitElement { url = DefaultClient.makeUrl(["root", "messages"]); @@ -34,7 +34,7 @@ export class Messages extends LitElement { try { this.connect(); } catch (error) { - console.warn(`passbook/messages: failed to connect to ws ${error}`); + console.warn(`authentik/messages: failed to connect to ws ${error}`); } } @@ -48,12 +48,12 @@ export class Messages extends LitElement { }/ws/client/`; this.messageSocket = new WebSocket(wsUrl); this.messageSocket.addEventListener("open", () => { - console.debug(`passbook/messages: connected to ${wsUrl}`); + console.debug(`authentik/messages: connected to ${wsUrl}`); }); this.messageSocket.addEventListener("close", (e) => { - console.debug(`passbook/messages: closed ws connection: ${e}`); + console.debug(`authentik/messages: closed ws connection: ${e}`); setTimeout(() => { - console.debug(`passbook/messages: reconnecting ws in ${this.retryDelay}ms`); + console.debug(`authentik/messages: reconnecting ws in ${this.retryDelay}ms`); this.connect(); }, this.retryDelay); this.retryDelay = this.retryDelay * 2; @@ -63,7 +63,7 @@ export class Messages extends LitElement { this.renderMessage(data); }); this.messageSocket.addEventListener("error", (e) => { - console.warn(`passbook/messages: error ${e}`); + console.warn(`authentik/messages: error ${e}`); this.retryDelay = this.retryDelay * 2; }); } @@ -72,7 +72,7 @@ export class Messages extends LitElement { * This mostly gets messages which were created when the user arrives/leaves the site * and especially the login flow */ fetchMessages(): Promise { - console.debug("passbook/messages: fetching messages over direct api"); + console.debug("authentik/messages: fetching messages over direct api"); return fetch(this.url) .then((r) => r.json()) .then((r: Message[]) => { @@ -85,10 +85,10 @@ export class Messages extends LitElement { renderMessage(message: Message): void { const container = this.querySelector(".pf-c-alert-group"); if (!container) { - console.warn("passbook/messages: failed to find container"); + console.warn("authentik/messages: failed to find container"); return; } - const id = ID("pb-message"); + const id = ID("ak-message"); const el = document.createElement("template"); el.innerHTML = `
  • diff --git a/web/src/elements/Spinner.ts b/web/src/elements/Spinner.ts index d4434049..a9cbcd81 100644 --- a/web/src/elements/Spinner.ts +++ b/web/src/elements/Spinner.ts @@ -10,7 +10,7 @@ export enum SpinnerSize { XLarge = "pf-m-xl", } -@customElement("pb-spinner") +@customElement("ak-spinner") export class Spinner extends LitElement { @property() size: SpinnerSize = SpinnerSize.Medium; diff --git a/web/src/elements/Tabs.ts b/web/src/elements/Tabs.ts index a33f433f..da4bbe61 100644 --- a/web/src/elements/Tabs.ts +++ b/web/src/elements/Tabs.ts @@ -5,8 +5,9 @@ import TabsStyle from "@patternfly/patternfly/components/Tabs/tabs.css"; // @ts-ignore import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css"; import { CURRENT_CLASS } from "../constants"; +import { gettext } from "django"; -@customElement("pb-tabs") +@customElement("ak-tabs") export class Tabs extends LitElement { @property() currentPage?: string; @@ -20,7 +21,7 @@ export class Tabs extends LitElement { return html`
  • `; @@ -30,7 +31,7 @@ export class Tabs extends LitElement { const pages = Array.from(this.querySelectorAll("[slot]")); if (!this.currentPage) { if (pages.length < 1) { - return html`

    no tabs defined

    `; + return html`

    ${gettext("no tabs defined")}

    `; } this.currentPage = pages[0].attributes.getNamedItem("slot")?.value; } diff --git a/web/src/elements/buttons/ActionButton.ts b/web/src/elements/buttons/ActionButton.ts index 770425d2..8b7b7eab 100644 --- a/web/src/elements/buttons/ActionButton.ts +++ b/web/src/elements/buttons/ActionButton.ts @@ -3,7 +3,7 @@ import { customElement, property } from "lit-element"; import { ERROR_CLASS, SUCCESS_CLASS } from "../../constants"; import { SpinnerButton } from "./SpinnerButton"; -@customElement("pb-action-button") +@customElement("ak-action-button") export class ActionButton extends SpinnerButton { @property() url = ""; @@ -13,7 +13,7 @@ export class ActionButton extends SpinnerButton { return; } this.setLoading(); - const csrftoken = getCookie("passbook_csrf"); + const csrftoken = getCookie("authentik_csrf"); if (!csrftoken) { console.debug("No csrf token in cookie"); this.setDone(ERROR_CLASS); diff --git a/web/src/elements/buttons/Dropdown.ts b/web/src/elements/buttons/Dropdown.ts index 3a76ad9a..3a7c767e 100644 --- a/web/src/elements/buttons/Dropdown.ts +++ b/web/src/elements/buttons/Dropdown.ts @@ -1,6 +1,6 @@ import { customElement, html, LitElement, TemplateResult } from "lit-element"; -@customElement("pb-dropdown") +@customElement("ak-dropdown") export class DropdownButton extends LitElement { constructor() { super(); diff --git a/web/src/elements/buttons/ModalButton.ts b/web/src/elements/buttons/ModalButton.ts index d7389ec4..f854f3ca 100644 --- a/web/src/elements/buttons/ModalButton.ts +++ b/web/src/elements/buttons/ModalButton.ts @@ -14,7 +14,7 @@ import { convertToSlug } from "../../utils"; import { SpinnerButton } from "./SpinnerButton"; import { PRIMARY_CLASS } from "../../constants"; -@customElement("pb-modal-button") +@customElement("ak-modal-button") export class ModalButton extends LitElement { @property() href?: string; @@ -92,17 +92,17 @@ export class ModalButton extends LitElement { if (data.indexOf("csrfmiddlewaretoken") !== -1) { const modalSlot = this.querySelector("[slot=modal]"); if (!modalSlot) { - console.debug("passbook/modalbutton: modal slot not found?"); + console.debug("authentik/modalbutton: modal slot not found?"); return; } modalSlot.innerHTML = data; - console.debug("passbook/modalbutton: re-showing form"); + console.debug("authentik/modalbutton: re-showing form"); this.updateHandlers(); } else { this.open = false; - console.debug("passbook/modalbutton: successful submit"); + console.debug("authentik/modalbutton: successful submit"); this.dispatchEvent( - new CustomEvent("hashchange", { + new CustomEvent("ak-refresh", { bubbles: true, }) ); @@ -133,7 +133,7 @@ export class ModalButton extends LitElement { modalSlot.innerHTML = t; this.updateHandlers(); this.open = true; - this.querySelectorAll("pb-spinner-button").forEach((sb) => { + this.querySelectorAll("ak-spinner-button").forEach((sb) => { sb.setDone(PRIMARY_CLASS); }); }) diff --git a/web/src/elements/buttons/SpinnerButton.ts b/web/src/elements/buttons/SpinnerButton.ts index 74b94d63..99aca32f 100644 --- a/web/src/elements/buttons/SpinnerButton.ts +++ b/web/src/elements/buttons/SpinnerButton.ts @@ -7,7 +7,7 @@ import ButtonStyle from "@patternfly/patternfly/components/Button/button.css"; import SpinnerStyle from "@patternfly/patternfly/components/Spinner/spinner.css"; import { ColorStyles, PRIMARY_CLASS, PROGRESS_CLASS } from "../../constants"; -@customElement("pb-spinner-button") +@customElement("ak-spinner-button") export class SpinnerButton extends LitElement { @property({type: Boolean}) isRunning = false; diff --git a/web/src/elements/buttons/TokenCopyButton.ts b/web/src/elements/buttons/TokenCopyButton.ts index bb363405..fd87b8bf 100644 --- a/web/src/elements/buttons/TokenCopyButton.ts +++ b/web/src/elements/buttons/TokenCopyButton.ts @@ -6,7 +6,7 @@ import ButtonStyle from "@patternfly/patternfly/components/Button/button.css"; import { tokenByIdentifier } from "../../api/token"; import { ColorStyles, ERROR_CLASS, PRIMARY_CLASS, SUCCESS_CLASS } from "../../constants"; -@customElement("pb-token-copy-button") +@customElement("ak-token-copy-button") export class TokenCopyButton extends LitElement { @property() identifier?: string; diff --git a/web/src/elements/cards/AggregateCard.ts b/web/src/elements/cards/AggregateCard.ts index 0aa65ae2..7ad95421 100644 --- a/web/src/elements/cards/AggregateCard.ts +++ b/web/src/elements/cards/AggregateCard.ts @@ -3,7 +3,7 @@ import { css, CSSResult, customElement, html, LitElement, property, TemplateResu import { ifDefined } from "lit-html/directives/if-defined"; import { COMMON_STYLES } from "../../common/styles"; -@customElement("pb-aggregate-card") +@customElement("ak-aggregate-card") export class AggregateCard extends LitElement { @property() icon?: string; @@ -20,9 +20,6 @@ export class AggregateCard extends LitElement { font-size: var(--pf-global--icon--FontSize--lg); text-align: center; } - .subtext { - font-size: var(--pf-global--FontSize--sm); - } `]); } diff --git a/web/src/elements/cards/AggregatePromiseCard.ts b/web/src/elements/cards/AggregatePromiseCard.ts index 91ccf15f..94525ba0 100644 --- a/web/src/elements/cards/AggregatePromiseCard.ts +++ b/web/src/elements/cards/AggregatePromiseCard.ts @@ -4,7 +4,7 @@ import { AggregateCard } from "./AggregateCard"; import "../Spinner"; import { SpinnerSize } from "../Spinner"; -@customElement("pb-aggregate-card-promise") +@customElement("ak-aggregate-card-promise") export class AggregatePromiseCard extends AggregateCard { @property({attribute: false}) promise?: Promise>; @@ -20,7 +20,7 @@ export class AggregatePromiseCard extends AggregateCard { renderInner(): TemplateResult { return html`

    - ${until(this.promiseProxy(), html``)} + ${until(this.promiseProxy(), html``)}

    `; } diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index ab09b204..9cc8cd19 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -18,7 +18,7 @@ export interface SidebarItem { condition?: () => Promise; } -@customElement("pb-sidebar") +@customElement("ak-sidebar") export class Sidebar extends LitElement { @property({attribute: false}) items: SidebarItem[] = []; @@ -36,6 +36,18 @@ export class Sidebar extends LitElement { max-height: 82px; margin-bottom: -0.5rem; } + nav { + display: flex; + flex-direction: column; + max-height: 100vh; + height: 100%; + overflow-y: hidden; + } + .pf-c-nav__list { + flex-grow: 1; + overflow-y: auto; + } + .pf-c-nav__link { --pf-c-nav__link--PaddingTop: 0.5rem; --pf-c-nav__link--PaddingRight: 0.5rem; @@ -44,12 +56,6 @@ export class Sidebar extends LitElement { .pf-c-nav__subnav { --pf-c-nav__subnav--PaddingBottom: 0px; } - - .pf-c-nav__item-bottom { - position: absolute; - bottom: 0; - width: 100%; - } `, ]; } @@ -89,18 +95,12 @@ export class Sidebar extends LitElement { } render(): TemplateResult { - return html`
    - -
    `; + return html``; } } diff --git a/web/src/elements/sidebar/SidebarBrand.ts b/web/src/elements/sidebar/SidebarBrand.ts index 1c0601d8..e997c333 100644 --- a/web/src/elements/sidebar/SidebarBrand.ts +++ b/web/src/elements/sidebar/SidebarBrand.ts @@ -6,15 +6,15 @@ import GlobalsStyle from "@patternfly/patternfly/base/patternfly-globals.css"; import { Config } from "../../api/config"; export const DefaultConfig: Config = { - branding_logo: " /static/dist/assets/images/logo.svg", - branding_title: "passbook", + branding_logo: " /static/dist/assets/icons/icon_left_brand.svg", + branding_title: "authentik", error_reporting_enabled: false, error_reporting_environment: "", error_reporting_send_pii: false, }; -@customElement("pb-sidebar-brand") +@customElement("ak-sidebar-brand") export class SidebarBrand extends LitElement { @property({attribute: false}) config: Config = DefaultConfig; @@ -24,41 +24,28 @@ export class SidebarBrand extends LitElement { GlobalsStyle, PageStyle, css` - .pf-c-brand { - font-family: "DIN 1451 Std"; - line-height: 60px; - font-size: 3rem; - color: var(--pf-c-nav__link--m-current--Color); + :host { display: flex; - flex-direction: row; - justify-content: center; - width: 100%; - margin: 0 1rem; - margin-bottom: 1.5rem; + flex-direction: column; + align-items: center; + height: 82px; } .pf-c-brand img { - max-height: 60px; - margin-right: 8px; + width: 100%; + padding: 0 .5rem; } `, ]; } - constructor() { - super(); + firstUpdated(): void { Config.get().then((c) => (this.config = c)); } render(): TemplateResult { - if (!this.config) { - return html``; - } return html` -
    - passbook icon - ${this.config.branding_title - ? html`${this.config.branding_title}` - : ""} +
    + authentik icon
    `; } diff --git a/web/src/elements/sidebar/SidebarUser.ts b/web/src/elements/sidebar/SidebarUser.ts index 037b2d84..8985c6b4 100644 --- a/web/src/elements/sidebar/SidebarUser.ts +++ b/web/src/elements/sidebar/SidebarUser.ts @@ -8,7 +8,7 @@ import AvatarStyle from "@patternfly/patternfly/components/Avatar/avatar.css"; import { User } from "../../api/user"; import { until } from "lit-html/directives/until"; -@customElement("pb-sidebar-user") +@customElement("ak-sidebar-user") export class SidebarUser extends LitElement { static get styles(): CSSResult[] { return [ diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index d5d6b729..ba1cf2d2 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -21,6 +21,13 @@ export abstract class Table extends LitElement { return COMMON_STYLES; } + constructor() { + super(); + this.addEventListener("ak-refresh", () => { + this.fetch(); + }); + } + public fetch(): void { this.apiEndpoint(this.page).then((r) => { this.data = r; @@ -74,15 +81,15 @@ export abstract class Table extends LitElement {
    - + .pages=${this.data?.pagination} + .pageChangeHandler=${(page: number) => {this.page = page; }}> + @@ -96,10 +103,11 @@ export abstract class Table extends LitElement {
    - + .pages=${this.data?.pagination} + .pageChangeHandler=${(page: number) => { this.page = page; }}> +
    `; } diff --git a/web/src/elements/table/TablePagination.ts b/web/src/elements/table/TablePagination.ts index b50f0d3b..bfad9d99 100644 --- a/web/src/elements/table/TablePagination.ts +++ b/web/src/elements/table/TablePagination.ts @@ -1,41 +1,29 @@ import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element"; -import { Table } from "./Table"; import { COMMON_STYLES } from "../../common/styles"; +import { PBPagination } from "../../api/client"; -@customElement("pb-table-pagination") +@customElement("ak-table-pagination") export class TablePagination extends LitElement { @property({attribute: false}) - table?: Table; + pages?: PBPagination; + + @property({attribute: false}) + // eslint-disable-next-line + pageChangeHandler: (page: number) => void = (page: number) => {} static get styles(): CSSResult[] { return COMMON_STYLES; } - previousHandler(): void { - if (!this.table?.data?.pagination.previous) { - console.debug("passbook/tables: no previous"); - return; - } - this.table.page = this.table?.data?.pagination.previous; - } - - nextHandler(): void { - if (!this.table?.data?.pagination.next) { - console.debug("passbook/tables: no next"); - return; - } - this.table.page = this.table?.data?.pagination.next; - } - render(): TemplateResult { return html`
    - ${this.table?.data?.pagination.start_index} - - ${this.table?.data?.pagination.end_index} of - ${this.table?.data?.pagination.count} + ${this.pages?.start_index} - + ${this.pages?.end_index} of + ${this.pages?.count}
    @@ -43,8 +31,8 @@ export class TablePagination extends LitElement {