From 49152056785427ad3ba7cef05bfd466e0fd2d890 Mon Sep 17 00:00:00 2001 From: Jens L Date: Sun, 7 Jun 2020 16:35:08 +0200 Subject: [PATCH] WIP Use Flows for Sources and Providers (#32) * core: start migrating to flows for authorisation * sources/oauth: start type-hinting * core: create default user * core: only show user delete button if an unenrollment flow exists * flows: Correctly check initial policies on flow with context * policies: add more verbosity to engine * sources/oauth: migrate to flows * sources/oauth: fix typing errors * flows: add more tests * sources/oauth: start implementing unittests * sources/ldap: add option to disable user sync, move connection init to model * sources/ldap: re-add default PropertyMappings * providers/saml: re-add default PropertyMappings * admin: fix missing stage count * stages/identification: fix sources not being shown * crypto: fix being unable to save with private key * crypto: re-add default self-signed keypair * policies: rewrite cache_key to prevent wrong cache * sources/saml: migrate to flows for auth and enrollment * stages/consent: add new stage * admin: fix PropertyMapping widget not rendering properly * core: provider.authorization_flow is mandatory * flows: add support for "autosubmit" attribute on form * flows: add InMemoryStage for dynamic stages * flows: optionally allow empty flows from FlowPlanner * providers/saml: update to authorization_flow * sources/*: fix flow executor URL * flows: fix pylint error * flows: wrap responses in JSON object to easily handle redirects * flow: dont cache plan's context * providers/oauth: rewrite OAuth2 Provider to use flows * providers/*: update docstrings of models * core: fix forms not passing help_text through safe * flows: fix HttpResponses not being converted to JSON * providers/oidc: rewrite to use flows * flows: fix linting --- passbook/admin/forms/source.py | 17 +- .../administration/provider/list.html | 2 +- passbook/admin/templates/generic/create.html | 4 +- passbook/core/api/applications.py | 1 - passbook/core/api/providers.py | 2 +- passbook/core/forms/applications.py | 1 - .../migrations/0002_auto_20200523_1133.py | 52 +++ ...2_default_user.py => 0003_default_user.py} | 4 +- passbook/core/models.py | 28 +- passbook/core/templates/login/base.html | 37 ++ .../templates/partials/form_horizontal.html | 7 +- passbook/core/views/access.py | 2 +- passbook/core/views/overview.py | 4 +- passbook/crypto/builder.py | 8 +- .../migrations/0003_auto_20200523_1133.py | 29 ++ .../flows/migrations/0004_source_flows.py | 131 +++++++ .../flows/migrations/0005_provider_flows.py | 44 +++ passbook/flows/models.py | 12 +- passbook/flows/planner.py | 8 +- passbook/flows/templates/flows/shell.html | 51 ++- passbook/flows/views.py | 36 +- passbook/policies/types.py | 3 + passbook/providers/app_gw/forms.py | 2 +- passbook/providers/app_gw/models.py | 3 +- passbook/providers/oauth/forms.py | 11 + passbook/providers/oauth/models.py | 3 +- passbook/providers/oauth/settings.py | 1 + passbook/providers/oauth/urls.py | 12 +- passbook/providers/oauth/views/github.py | 39 +- passbook/providers/oauth/views/oauth2.py | 144 ++++--- passbook/providers/oidc/apps.py | 5 +- passbook/providers/oidc/auth.py | 7 +- passbook/providers/oidc/forms.py | 24 +- passbook/providers/oidc/models.py | 8 +- passbook/providers/oidc/signals.py | 15 - passbook/providers/oidc/urls.py | 13 + passbook/providers/oidc/views.py | 127 ++++++ passbook/providers/saml/forms.py | 6 + .../0002_default_saml_property_mappings.py | 12 +- .../0003_samlprovider_sp_binding.py | 20 + passbook/providers/saml/models.py | 14 +- .../templates/saml/idp/autosubmit_form.html | 30 +- .../saml/templates/saml/idp/login.html | 26 -- .../saml/templates/saml/xml/metadata.xml | 5 +- passbook/providers/saml/urls.py | 31 +- passbook/providers/saml/views.py | 371 ++++++++---------- passbook/root/settings.py | 1 + .../migrations/0004_auto_20200524_1146.py | 31 ++ passbook/sources/oauth/forms.py | 8 + passbook/sources/oauth/tests.py | 38 ++ passbook/sources/oauth/types/azure_ad.py | 14 +- passbook/sources/oauth/types/manager.py | 4 +- passbook/sources/oauth/types/oidc.py | 4 +- passbook/sources/oauth/views/core.py | 79 ++-- passbook/sources/saml/forms.py | 1 + .../migrations/0002_auto_20200523_2329.py | 30 ++ passbook/sources/saml/models.py | 20 +- passbook/sources/saml/processors/base.py | 58 ++- passbook/sources/saml/views.py | 35 +- passbook/stages/captcha/tests.py | 8 +- passbook/stages/consent/__init__.py | 0 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 ++ .../stages/consent/migrations/__init__.py | 0 passbook/stages/consent/models.py | 19 + passbook/stages/consent/stage.py | 25 ++ passbook/stages/consent/tests.py | 47 +++ passbook/stages/dummy/tests.py | 8 +- passbook/stages/email/tests.py | 9 +- passbook/stages/identification/tests.py | 12 +- passbook/stages/invitation/tests.py | 25 +- passbook/stages/password/tests.py | 27 +- passbook/stages/prompt/tests.py | 14 +- passbook/stages/user_delete/tests.py | 15 +- passbook/stages/user_login/tests.py | 25 +- passbook/stages/user_logout/tests.py | 9 +- passbook/stages/user_write/tests.py | 23 +- pyproject.toml | 2 + swagger.yaml | 47 ++- 81 files changed, 1609 insertions(+), 529 deletions(-) create mode 100644 passbook/core/migrations/0002_auto_20200523_1133.py rename passbook/core/migrations/{0002_default_user.py => 0003_default_user.py} (87%) create mode 100644 passbook/flows/migrations/0003_auto_20200523_1133.py create mode 100644 passbook/flows/migrations/0004_source_flows.py create mode 100644 passbook/flows/migrations/0005_provider_flows.py delete mode 100644 passbook/providers/oidc/signals.py create mode 100644 passbook/providers/oidc/urls.py create mode 100644 passbook/providers/oidc/views.py create mode 100644 passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py delete mode 100644 passbook/providers/saml/templates/saml/idp/login.html create mode 100644 passbook/sources/ldap/migrations/0004_auto_20200524_1146.py create mode 100644 passbook/sources/oauth/tests.py create mode 100644 passbook/sources/saml/migrations/0002_auto_20200523_2329.py create mode 100644 passbook/stages/consent/__init__.py create mode 100644 passbook/stages/consent/api.py create mode 100644 passbook/stages/consent/apps.py create mode 100644 passbook/stages/consent/forms.py create mode 100644 passbook/stages/consent/migrations/0001_initial.py create mode 100644 passbook/stages/consent/migrations/__init__.py create mode 100644 passbook/stages/consent/models.py create mode 100644 passbook/stages/consent/stage.py create mode 100644 passbook/stages/consent/tests.py create mode 100644 pyproject.toml diff --git a/passbook/admin/forms/source.py b/passbook/admin/forms/source.py index b18d7157a..2207dec1c 100644 --- a/passbook/admin/forms/source.py +++ b/passbook/admin/forms/source.py @@ -1,4 +1,17 @@ """passbook core source form fields""" -SOURCE_FORM_FIELDS = ["name", "slug", "enabled"] -SOURCE_SERIALIZER_FIELDS = ["pk", "name", "slug", "enabled"] +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/templates/administration/provider/list.html b/passbook/admin/templates/administration/provider/list.html index 9ae3a7f20..5a804bfd6 100644 --- a/passbook/admin/templates/administration/provider/list.html +++ b/passbook/admin/templates/administration/provider/list.html @@ -29,7 +29,7 @@ {% for type, name in types.items %}
  • - {{ name|verbose_name }} + {{ name|verbose_name }}
    {{ name|doc }} diff --git a/passbook/admin/templates/generic/create.html b/passbook/admin/templates/generic/create.html index 3094c2da7..640ab3916 100644 --- a/passbook/admin/templates/generic/create.html +++ b/passbook/admin/templates/generic/create.html @@ -5,14 +5,14 @@ {% block above_form %}

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

    {% endblock %} {% block action %} -{% blocktrans with type=form|form_verbose_name|title %} +{% blocktrans with type=form|form_verbose_name %} Create {{ type }} {% endblocktrans %} {% endblock %} diff --git a/passbook/core/api/applications.py b/passbook/core/api/applications.py index f0b4c69e2..f2bcb5e9c 100644 --- a/passbook/core/api/applications.py +++ b/passbook/core/api/applications.py @@ -15,7 +15,6 @@ class ApplicationSerializer(ModelSerializer): "pk", "name", "slug", - "skip_authorization", "provider", "meta_launch_url", "meta_icon_url", diff --git a/passbook/core/api/providers.py b/passbook/core/api/providers.py index 2b54a5807..c2830c06e 100644 --- a/passbook/core/api/providers.py +++ b/passbook/core/api/providers.py @@ -17,7 +17,7 @@ class ProviderSerializer(ModelSerializer): class Meta: model = Provider - fields = ["pk", "property_mappings", "__type__"] + fields = ["pk", "authorization_flow", "property_mappings", "__type__"] class ProviderViewSet(ReadOnlyModelViewSet): diff --git a/passbook/core/forms/applications.py b/passbook/core/forms/applications.py index e63eeb06d..4eb071719 100644 --- a/passbook/core/forms/applications.py +++ b/passbook/core/forms/applications.py @@ -19,7 +19,6 @@ class ApplicationForm(forms.ModelForm): fields = [ "name", "slug", - "skip_authorization", "provider", "meta_launch_url", "meta_icon_url", diff --git a/passbook/core/migrations/0002_auto_20200523_1133.py b/passbook/core/migrations/0002_auto_20200523_1133.py new file mode 100644 index 000000000..393a9be75 --- /dev/null +++ b/passbook/core/migrations/0002_auto_20200523_1133.py @@ -0,0 +1,52 @@ +# 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/0002_default_user.py b/passbook/core/migrations/0003_default_user.py similarity index 87% rename from passbook/core/migrations/0002_default_user.py rename to passbook/core/migrations/0003_default_user.py index 1dd6ecdd0..e1d52f3b4 100644 --- a/passbook/core/migrations/0002_default_user.py +++ b/passbook/core/migrations/0003_default_user.py @@ -12,7 +12,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): pbadmin = User.objects.create( username="pbadmin", email="root@localhost", name="passbook Default Admin" ) - pbadmin.set_password("pbadmin") # nosec + pbadmin.set_password("pbadmin") # noqa # nosec pbadmin.is_superuser = True pbadmin.is_staff = True pbadmin.save() @@ -21,7 +21,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): class Migration(migrations.Migration): dependencies = [ - ("passbook_core", "0001_initial"), + ("passbook_core", "0002_auto_20200523_1133"), ] operations = [ diff --git a/passbook/core/models.py b/passbook/core/models.py index 4e42ba6e9..2c8a8930e 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -16,6 +16,7 @@ from structlog import get_logger from passbook.core.exceptions import PropertyMappingExpressionException from passbook.core.signals import password_changed from passbook.core.types import UILoginButton, UIUserSettings +from passbook.flows.models import Flow from passbook.lib.models import CreatedUpdatedModel from passbook.policies.models import PolicyBindingModel @@ -75,6 +76,13 @@ class User(GuardianUserMixin, AbstractUser): class Provider(models.Model): """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" + 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 ) @@ -95,7 +103,6 @@ class Application(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) provider = models.OneToOneField( "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT ) @@ -128,6 +135,25 @@ class Source(PolicyBindingModel): "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", + ) + form = "" # ModelForm-based class ued to create/edit instance objects = InheritanceManager() diff --git a/passbook/core/templates/login/base.html b/passbook/core/templates/login/base.html index b7234adaa..5c8000887 100644 --- a/passbook/core/templates/login/base.html +++ b/passbook/core/templates/login/base.html @@ -20,3 +20,40 @@ {% endblock %} +
    diff --git a/passbook/core/templates/partials/form_horizontal.html b/passbook/core/templates/partials/form_horizontal.html index facce648e..1e2836e6e 100644 --- a/passbook/core/templates/partials/form_horizontal.html +++ b/passbook/core/templates/partials/form_horizontal.html @@ -28,6 +28,9 @@
    {{ field|css_class:"pf-c-form-control" }} + {% if field.help_text %} +

    {{ field.help_text|safe }}

    + {% endif %}
    {% elif field.field.widget|fieldtype == 'CheckboxInput' %}
    @@ -36,7 +39,7 @@
    {% if field.help_text %} -

    {{ field.help_text }}

    +

    {{ field.help_text|safe }}

    {% endif %} {% else %} @@ -49,7 +52,7 @@
    {{ field|css_class:'pf-c-form-control' }} {% if field.help_text %} -

    {{ field.help_text }}

    +

    {{ field.help_text|safe }}

    {% endif %}
    {% endif %} diff --git a/passbook/core/views/access.py b/passbook/core/views/access.py index c2e07dd19..287a12f80 100644 --- a/passbook/core/views/access.py +++ b/passbook/core/views/access.py @@ -35,6 +35,6 @@ class AccessMixin: def user_has_access(self, application: Application, user: User) -> PolicyResult: """Check if user has access to application.""" LOGGER.debug("Checking permissions", user=user, application=application) - policy_engine = PolicyEngine(application.policies.all(), user, self.request) + policy_engine = PolicyEngine(application, user, self.request) policy_engine.build() return policy_engine.result diff --git a/passbook/core/views/overview.py b/passbook/core/views/overview.py index 6185dce05..ef5186ac6 100644 --- a/passbook/core/views/overview.py +++ b/passbook/core/views/overview.py @@ -16,9 +16,7 @@ class OverviewView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): kwargs["applications"] = [] for application in Application.objects.all().order_by("name"): - engine = PolicyEngine( - application.policies.all(), self.request.user, self.request - ) + engine = PolicyEngine(application, self.request.user, self.request) engine.build() if engine.passing: kwargs["applications"].append(application) diff --git a/passbook/crypto/builder.py b/passbook/crypto/builder.py index 67545f5bb..47722a463 100644 --- a/passbook/crypto/builder.py +++ b/passbook/crypto/builder.py @@ -36,11 +36,11 @@ class CertificateBuilder: x509.Name( [ x509.NameAttribute( - NameOID.COMMON_NAME, u"passbook Self-signed Certificate", + NameOID.COMMON_NAME, "passbook Self-signed Certificate", ), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"passbook"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "passbook"), x509.NameAttribute( - NameOID.ORGANIZATIONAL_UNIT_NAME, u"Self-signed" + NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed" ), ] ) @@ -49,7 +49,7 @@ class CertificateBuilder: x509.Name( [ x509.NameAttribute( - NameOID.COMMON_NAME, u"passbook Self-signed Certificate", + NameOID.COMMON_NAME, "passbook Self-signed Certificate", ), ] ) diff --git a/passbook/flows/migrations/0003_auto_20200523_1133.py b/passbook/flows/migrations/0003_auto_20200523_1133.py new file mode 100644 index 000000000..951835239 --- /dev/null +++ b/passbook/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 = [ + ("passbook_flows", "0002_default_flows"), + ] + + 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/0004_source_flows.py b/passbook/flows/migrations/0004_source_flows.py new file mode 100644 index 000000000..97db472d6 --- /dev/null +++ b/passbook/flows/migrations/0004_source_flows.py @@ -0,0 +1,131 @@ +# 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 = """{{ pb_is_sso_flow }}""" + +PROMPT_POLICY_EXPRESSION = """ +{% if pb_flow_plan.context.prompt_data.username %} +False +{% else %} +True +{% endif %} +""" + + +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.create( + name="default-source-enrollment-if-sso", 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.create( + name="default-source-enrollment", + slug="default-source-enrollment", + designation=FlowDesignation.ENROLLMENT, + ) + PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0) + + # PromptStage to ask user for their username + prompt_stage = PromptStage.objects.create( + name="default-source-enrollment-username-prompt", + ) + prompt_stage.fields.add( + Prompt.objects.create( + field_key="username", + label="Username", + type=FieldTypes.TEXT, + required=True, + placeholder="Username", + ) + ) + # Policy to only trigger prompt when no username is given + prompt_policy = ExpressionPolicy.objects.create( + name="default-source-enrollment-if-username", + expression=PROMPT_POLICY_EXPRESSION, + ) + + # UserWrite stage to create the user, and login stage to log user in + user_write = UserWriteStage.objects.create(name="default-source-enrollment-write") + user_login = UserLoginStage.objects.create(name="default-source-enrollment-login") + + binding = FlowStageBinding.objects.create(flow=flow, stage=prompt_stage, order=0) + PolicyBinding.objects.create(policy=prompt_policy, target=binding) + + FlowStageBinding.objects.create(flow=flow, stage=user_write, order=1) + FlowStageBinding.objects.create(flow=flow, stage=user_login, 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.create( + name="default-source-authentication-if-sso", expression=FLOW_POLICY_EXPRESSION + ) + + # This creates a Flow used by sources to authenticate users + flow = Flow.objects.create( + name="default-source-authentication", + slug="default-source-authentication", + designation=FlowDesignation.AUTHENTICATION, + ) + PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0) + + user_login = UserLoginStage.objects.create( + name="default-source-authentication-login" + ) + FlowStageBinding.objects.create(flow=flow, stage=user_login, order=0) + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_flows", "0003_auto_20200523_1133"), + ("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/0005_provider_flows.py b/passbook/flows/migrations/0005_provider_flows.py new file mode 100644 index 000000000..a197d0532 --- /dev/null +++ b/passbook/flows/migrations/0005_provider_flows.py @@ -0,0 +1,44 @@ +# 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_authz_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 no consent is needed + Flow.objects.create( + name="default-provider-authorization", + slug="default-provider-authorization", + designation=FlowDesignation.AUTHORIZATION, + ) + + # Flow with consent form to obtain user consent for authorization + flow = Flow.objects.create( + name="default-provider-authorization-consent", + slug="default-provider-authorization-consent", + designation=FlowDesignation.AUTHORIZATION, + ) + stage = ConsentStage.objects.create(name="default-provider-authorization-consent") + FlowStageBinding.objects.create(flow=flow, stage=stage, order=0) + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_flows", "0004_source_flows"), + ("passbook_stages_consent", "0001_initial"), + ] + + operations = [migrations.RunPython(create_default_provider_authz_flow)] diff --git a/passbook/flows/models.py b/passbook/flows/models.py index ffb194386..d049a6885 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -1,5 +1,5 @@ """Flow models""" -from typing import Optional +from typing import Callable, Optional from uuid import uuid4 from django.db import models @@ -9,6 +9,7 @@ from model_utils.managers import InheritanceManager from structlog import get_logger from passbook.core.types import UIUserSettings +from passbook.lib.utils.reflection import class_to_path from passbook.policies.models import PolicyBindingModel LOGGER = get_logger() @@ -19,6 +20,7 @@ class FlowDesignation(models.TextChoices): should be replaced by a database entry.""" AUTHENTICATION = "authentication" + AUTHORIZATION = "authorization" INVALIDATION = "invalidation" ENROLLMENT = "enrollment" UNRENOLLMENT = "unenrollment" @@ -48,6 +50,14 @@ class Stage(models.Model): return f"Stage {self.name}" +def in_memory_stage(_type: Callable) -> Stage: + """Creates an in-memory stage instance, based on a `_type` as view.""" + class_path = class_to_path(_type) + stage = Stage() + stage.type = class_path + return stage + + class Flow(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 diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py index 262279434..ca8a81c5e 100644 --- a/passbook/flows/planner.py +++ b/passbook/flows/planner.py @@ -16,6 +16,7 @@ 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: @@ -45,10 +46,13 @@ class FlowPlanner: 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( @@ -80,11 +84,13 @@ class FlowPlanner: 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: + if not plan.stages and not self.allow_empty_flows: raise EmptyFlowException() return plan diff --git a/passbook/flows/templates/flows/shell.html b/passbook/flows/templates/flows/shell.html index be6b22724..08190994d 100644 --- a/passbook/flows/templates/flows/shell.html +++ b/passbook/flows/templates/flows/shell.html @@ -113,19 +113,18 @@ const updateMessages = () => { }); }); }; -const updateCard = (response) => { - if (!response.ok) { - console.log("well"); - } - if (response.redirected && !response.url.endsWith(flowBodyUrl)) { - window.location = response.url; - } else { - response.text().then(text => { - flowBody.innerHTML = text; +const updateCard = (data) => { + switch (data.type) { + case "redirect": + window.location = data.to + break; + case "template": + flowBody.innerHTML = data.body; updateMessages(); loadFormCode(); setFormSubmitHandlers(); - }); + default: + break; } }; const showSpinner = () => { @@ -139,10 +138,28 @@ const loadFormCode = () => { document.head.appendChild(newScript); }); }; +const updateFormAction = (form) => { + for (let index = 0; index < form.elements.length; index++) { + const element = form.elements[index]; + if (element.value === form.action) { + console.log("Found Form action URL in form elements, not changing form action."); + return false; + } + } + form.action = flowBodyUrl; + return true; +}; +const checkAutosubmit = (form) => { + if ("autosubmit" in form.attributes) { + return form.submit(); + } +}; const setFormSubmitHandlers = () => { document.querySelectorAll("#flow-body form").forEach(form => { + console.log(`Checking for autosubmit attribute ${form}`); + checkAutosubmit(form); console.log(`Setting action for form ${form}`); - form.action = flowBodyUrl; + updateFormAction(form); console.log(`Adding handler for form ${form}`); form.addEventListener('submit', (e) => { e.preventDefault(); @@ -150,19 +167,13 @@ const setFormSubmitHandlers = () => { fetch(flowBodyUrl, { method: 'post', body: formData, - }).then((response) => { - showSpinner(); - if (!response.url.endsWith(flowBodyUrl)) { - window.location = response.url; - } else { - updateCard(response); - } + }).then(response => response.json()).then(data => { + updateCard(data); }); }); }); }; -fetch(flowBodyUrl).then(updateCard); - +fetch(flowBodyUrl).then(response => response.json()).then(data => updateCard(data)); {% endblock %} diff --git a/passbook/flows/views.py b/passbook/flows/views.py index 5c8c71f11..3613a7017 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -1,8 +1,15 @@ """passbook multi-stage authentication engine""" from typing import Any, Dict, Optional -from django.http import Http404, HttpRequest, HttpResponse +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 @@ -81,6 +88,8 @@ class FlowExecutorView(View): ) stage_cls = path_to_class(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) @@ -91,7 +100,8 @@ class FlowExecutorView(View): view_class=class_to_path(self.current_stage_view.__class__), flow_slug=self.flow.slug, ) - return self.current_stage_view.get(request, *args, **kwargs) + stage_response = self.current_stage_view.get(request, *args, **kwargs) + return to_stage_response(request, stage_response) def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """pass post request to current stage""" @@ -100,7 +110,8 @@ class FlowExecutorView(View): view_class=class_to_path(self.current_stage_view.__class__), flow_slug=self.flow.slug, ) - return self.current_stage_view.post(request, *args, **kwargs) + stage_response = self.current_stage_view.post(request, *args, **kwargs) + return to_stage_response(request, stage_response) def _initiate_plan(self) -> FlowPlan: planner = FlowPlanner(self.flow) @@ -191,3 +202,22 @@ class FlowExecutorShellView(TemplateView): kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs) kwargs["msg_url"] = reverse("passbook_api:messages-list") return kwargs + + +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 diff --git a/passbook/policies/types.py b/passbook/policies/types.py index 29bf02932..29ab69146 100644 --- a/passbook/policies/types.py +++ b/passbook/policies/types.py @@ -38,6 +38,9 @@ class PolicyResult: self.passing = passing self.messages = messages + def __repr__(self): + return self.__str__() + def __str__(self): if self.messages: return f"PolicyResult passing={self.passing} messages={self.messages}" diff --git a/passbook/providers/app_gw/forms.py b/passbook/providers/app_gw/forms.py index ad4c7fc60..6dddc8927 100644 --- a/passbook/providers/app_gw/forms.py +++ b/passbook/providers/app_gw/forms.py @@ -32,7 +32,7 @@ class ApplicationGatewayProviderForm(forms.ModelForm): class Meta: model = ApplicationGatewayProvider - fields = ["name", "internal_host", "external_host"] + fields = ["name", "authorization_flow", "internal_host", "external_host"] widgets = { "name": forms.TextInput(), "internal_host": forms.TextInput(), diff --git a/passbook/providers/app_gw/models.py b/passbook/providers/app_gw/models.py index 664c75f8b..b85f759ee 100644 --- a/passbook/providers/app_gw/models.py +++ b/passbook/providers/app_gw/models.py @@ -14,7 +14,8 @@ from passbook.lib.utils.template import render_to_string class ApplicationGatewayProvider(Provider): - """This provider uses oauth2_proxy with the OIDC Provider.""" + """Protect applications that don't support any of the other + Protocols by using a Reverse-Proxy.""" name = models.TextField() internal_host = models.TextField() diff --git a/passbook/providers/oauth/forms.py b/passbook/providers/oauth/forms.py index 9c4acc4e0..3d3a7517e 100644 --- a/passbook/providers/oauth/forms.py +++ b/passbook/providers/oauth/forms.py @@ -1,21 +1,32 @@ """passbook OAuth2 Provider Forms""" from django import forms +from django.utils.translation import gettext_lazy as _ +from passbook.flows.models import Flow, FlowDesignation from passbook.providers.oauth.models import OAuth2Provider class OAuth2ProviderForm(forms.ModelForm): """OAuth2 Provider form""" + authorization_flow = forms.ModelChoiceField( + queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION) + ) + class Meta: model = OAuth2Provider fields = [ "name", + "authorization_flow", "redirect_uris", "client_type", "authorization_grant_type", "client_id", "client_secret", ] + labels = { + "client_id": _("Client ID"), + "redirect_uris": _("Redirect URIs"), + } diff --git a/passbook/providers/oauth/models.py b/passbook/providers/oauth/models.py index b99ebaccf..c42d3bcb4 100644 --- a/passbook/providers/oauth/models.py +++ b/passbook/providers/oauth/models.py @@ -12,7 +12,8 @@ from passbook.lib.utils.template import render_to_string class OAuth2Provider(Provider, AbstractApplication): - """Associate an OAuth2 Application with a Product""" + """Generic OAuth2 Provider for applications not using OpenID-Connect. This Provider + also supports the GitHub-pretend mode.""" form = "passbook.providers.oauth.forms.OAuth2ProviderForm" diff --git a/passbook/providers/oauth/settings.py b/passbook/providers/oauth/settings.py index b8bd2e1a0..bd04dd43e 100644 --- a/passbook/providers/oauth/settings.py +++ b/passbook/providers/oauth/settings.py @@ -24,6 +24,7 @@ OAUTH2_PROVIDER = { "SCOPES": { "openid": "Access OpenID Userinfo", "openid:userinfo": "Access OpenID Userinfo", + "email": "Access OpenID E-Mail", # 'write': 'Write scope', # 'groups': 'Access to your groups', "user:email": "GitHub Compatibility: User E-Mail", diff --git a/passbook/providers/oauth/urls.py b/passbook/providers/oauth/urls.py index a8b68f603..4da8b3a07 100644 --- a/passbook/providers/oauth/urls.py +++ b/passbook/providers/oauth/urls.py @@ -6,17 +6,12 @@ from oauth2_provider import views from passbook.providers.oauth.views import github, oauth2 oauth_urlpatterns = [ - # Custom OAuth 2 Authorize View + # Custom OAuth2 Authorize View path( "authorize/", - oauth2.PassbookAuthorizationView.as_view(), + oauth2.AuthorizationFlowInitView.as_view(), name="oauth2-authorize", ), - path( - "authorize/permission_denied/", - oauth2.OAuthPermissionDenied.as_view(), - name="oauth2-permission-denied", - ), # OAuth API path("token/", views.TokenView.as_view(), name="token"), path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), @@ -26,7 +21,7 @@ oauth_urlpatterns = [ github_urlpatterns = [ path( "login/oauth/authorize", - oauth2.PassbookAuthorizationView.as_view(), + oauth2.AuthorizationFlowInitView.as_view(), name="github-authorize", ), path( @@ -35,6 +30,7 @@ github_urlpatterns = [ name="github-access-token", ), path("user", github.GitHubUserView.as_view(), name="github-user"), + path("user/teams", github.GitHubUserTeamsView.as_view(), name="github-user-teams"), ] urlpatterns = [ diff --git a/passbook/providers/oauth/views/github.py b/passbook/providers/oauth/views/github.py index b52446ee9..11adb9e0a 100644 --- a/passbook/providers/oauth/views/github.py +++ b/passbook/providers/oauth/views/github.py @@ -1,21 +1,32 @@ """passbook pretend GitHub Views""" -from django.http import JsonResponse +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.views import View from oauth2_provider.models import AccessToken +from passbook.core.models import User -class GitHubUserView(View): + +class GitHubPretendView(View): + """Emulate GitHub's API Endpoints""" + + def verify_access_token(self) -> User: + """Verify access token manually since github uses /user?access_token=...""" + if "HTTP_AUTHORIZATION" in self.request.META: + full_token = self.request.META.get("HTTP_AUTHORIZATION") + _, token = full_token.split(" ") + elif "access_token" in self.request.GET: + token = self.request.GET.get("access_token", "") + else: + raise PermissionDenied("No access token passed.") + return get_object_or_404(AccessToken, token=token).user + + +class GitHubUserView(GitHubPretendView): """Emulate GitHub's /user API Endpoint""" - def verify_access_token(self): - """Verify access token manually since github uses /user?access_token=...""" - token = get_object_or_404( - AccessToken, token=self.request.GET.get("access_token", "") - ) - return token.user - - def get(self, request): + def get(self, request: HttpRequest) -> HttpResponse: """Emulate GitHub's /user API Endpoint""" user = self.verify_access_token() return JsonResponse( @@ -65,3 +76,11 @@ class GitHubUserView(View): }, } ) + + +class GitHubUserTeamsView(GitHubPretendView): + """Emulate GitHub's /user/teams API Endpoint""" + + def get(self, request: HttpRequest) -> HttpResponse: + """Emulate GitHub's /user/teams API Endpoint""" + return JsonResponse([], safe=False) diff --git a/passbook/providers/oauth/views/oauth2.py b/passbook/providers/oauth/views/oauth2.py index 208dd99ac..301d3e432 100644 --- a/passbook/providers/oauth/views/oauth2.py +++ b/passbook/providers/oauth/views/oauth2.py @@ -1,76 +1,124 @@ """passbook OAuth2 Views""" -from typing import Optional -from urllib.parse import urlencode - from django.contrib import messages -from django.forms import Form -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, reverse +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.views import View +from oauth2_provider.exceptions import OAuthToolkitError from oauth2_provider.views.base import AuthorizationView from structlog import get_logger from passbook.audit.models import Event, EventAction from passbook.core.models import Application from passbook.core.views.access import AccessMixin -from passbook.core.views.utils import PermissionDeniedView +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.providers.oauth.models import OAuth2Provider LOGGER = get_logger() +PLAN_CONTEXT_CLIENT_ID = "client_id" +PLAN_CONTEXT_REDIRECT_URI = "redirect_uri" +PLAN_CONTEXT_RESPONSE_TYPE = "response_type" +PLAN_CONTEXT_STATE = "state" -class OAuthPermissionDenied(PermissionDeniedView): - """Show permission denied view""" +PLAN_CONTEXT_CODE_CHALLENGE = "code_challenge" +PLAN_CONTEXT_CODE_CHALLENGE_METHOD = "code_challenge_method" +PLAN_CONTEXT_SCOPE = "scope" +PLAN_CONTEXT_NONCE = "nonce" -class PassbookAuthorizationView(AccessMixin, AuthorizationView): - """Custom OAuth2 Authorization View which checks policies, etc""" +class AuthorizationFlowInitView(AccessMixin, View): + """OAuth2 Flow initializer, checks access to application and starts flow""" - _application: Optional[Application] = None - - def _inject_response_type(self): - """Inject response_type into querystring if not set""" - LOGGER.debug("response_type not set, defaulting to 'code'") - querystring = urlencode(self.request.GET) - querystring += "&response_type=code" - return redirect( - reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring - ) - - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Update OAuth2Provider's skip_authorization state""" - # Get client_id to get provider, so we can update skip_authorization field + # pylint: disable=unused-argument + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Check access to application, start FlowPLanner, return to flow executor shell""" client_id = request.GET.get("client_id") provider = get_object_or_404(OAuth2Provider, client_id=client_id) try: application = self.provider_to_application(provider) except Application.DoesNotExist: return redirect("passbook_providers_oauth:oauth2-permission-denied") - # Update field here so oauth-toolkit does work for us - provider.skip_authorization = application.skip_authorization - provider.save() - self._application = application # Check permissions - result = self.user_has_access(self._application, request.user) + result = self.user_has_access(application, request.user) if not result.passing: for policy_message in result.messages: messages.error(request, policy_message) return redirect("passbook_providers_oauth:oauth2-permission-denied") - # Some clients don't pass response_type, so we default to code - if "response_type" not in request.GET: - return self._inject_response_type() - actual_response = AuthorizationView.dispatch(self, request, *args, **kwargs) - if actual_response.status_code == 400: - LOGGER.debug("Bad request", redirect_uri=request.GET.get("redirect_uri")) - return actual_response - - def form_valid(self, form: Form): - # User has clicked on "Authorize" - Event.new( - EventAction.AUTHORIZE_APPLICATION, authorized_application=self._application, - ).from_http(self.request) - LOGGER.debug( - "User authorized Application", - user=self.request.user, - application=self._application, + # Regardless, we start the planner and return to it + planner = FlowPlanner(provider.authorization_flow) + # planner.use_cache = False + planner.allow_empty_flows = True + plan = planner.plan( + self.request, + { + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_APPLICATION: application, + PLAN_CONTEXT_CLIENT_ID: client_id, + PLAN_CONTEXT_REDIRECT_URI: request.GET.get(PLAN_CONTEXT_REDIRECT_URI), + PLAN_CONTEXT_RESPONSE_TYPE: request.GET.get(PLAN_CONTEXT_RESPONSE_TYPE), + PLAN_CONTEXT_STATE: request.GET.get(PLAN_CONTEXT_STATE), + PLAN_CONTEXT_SCOPE: request.GET.get(PLAN_CONTEXT_SCOPE), + PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE), + }, ) - return AuthorizationView.form_valid(self, form) + plan.stages.append(in_memory_stage(OAuth2Stage)) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "passbook_flows:flow-executor-shell", + self.request.GET, + flow_slug=provider.authorization_flow.slug, + ) + + +class OAuth2Stage(AuthorizationView, StageView): + """OAuth2 Stage, dynamically injected into the plan""" + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Last stage in flow, finalizes OAuth Response and redirects to Client""" + application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + provider: OAuth2Provider = application.provider + + Event.new( + EventAction.AUTHORIZE_APPLICATION, authorized_application=application, + ).from_http(self.request) + + credentials = { + "client_id": self.executor.plan.context[PLAN_CONTEXT_CLIENT_ID], + "redirect_uri": self.executor.plan.context[PLAN_CONTEXT_REDIRECT_URI], + "response_type": self.executor.plan.context.get( + PLAN_CONTEXT_RESPONSE_TYPE, None + ), + "state": self.executor.plan.context.get(PLAN_CONTEXT_STATE, None), + "nonce": self.executor.plan.context.get(PLAN_CONTEXT_NONCE, None), + } + if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE, False): + credentials[PLAN_CONTEXT_CODE_CHALLENGE] = self.executor.plan.context.get( + PLAN_CONTEXT_CODE_CHALLENGE + ) + if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD, False): + credentials[ + PLAN_CONTEXT_CODE_CHALLENGE_METHOD + ] = self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD) + scopes = self.executor.plan.context.get(PLAN_CONTEXT_SCOPE) + + try: + uri, _headers, _body, _status = self.create_authorization_response( + request=self.request, + scopes=scopes, + credentials=credentials, + allow=True, + ) + LOGGER.debug("Success url for the request: {0}".format(uri)) + except OAuthToolkitError as error: + return self.error_response(error, provider) + + self.executor.stage_ok() + return HttpResponseRedirect(self.redirect(uri, provider).url) diff --git a/passbook/providers/oidc/apps.py b/passbook/providers/oidc/apps.py index ee4f50191..363366e17 100644 --- a/passbook/providers/oidc/apps.py +++ b/passbook/providers/oidc/apps.py @@ -1,6 +1,4 @@ """passbook auth oidc provider app config""" -from importlib import import_module - from django.apps import AppConfig from django.db.utils import InternalError, OperationalError, ProgrammingError from django.urls import include, path @@ -15,6 +13,7 @@ class PassbookProviderOIDCConfig(AppConfig): name = "passbook.providers.oidc" label = "passbook_providers_oidc" verbose_name = "passbook Providers.OIDC" + mountpoint = "application/oidc/" def ready(self): try: @@ -36,5 +35,3 @@ class PassbookProviderOIDCConfig(AppConfig): include("oidc_provider.urls", namespace="oidc_provider"), ), ) - - import_module("passbook.providers.oidc.signals") diff --git a/passbook/providers/oidc/auth.py b/passbook/providers/oidc/auth.py index 91a0b9dcf..0050463bf 100644 --- a/passbook/providers/oidc/auth.py +++ b/passbook/providers/oidc/auth.py @@ -10,6 +10,8 @@ from structlog import get_logger from passbook.audit.models import Event, EventAction from passbook.core.models import Application, Provider, User +from passbook.flows.planner import FlowPlan +from passbook.flows.views import SESSION_KEY_PLAN from passbook.policies.engine import PolicyEngine LOGGER = get_logger() @@ -46,7 +48,7 @@ def check_permissions( LOGGER.debug( "Checking permissions for application", user=user, application=application ) - policy_engine = PolicyEngine(application.policies.all(), user, request) + policy_engine = PolicyEngine(application, user, request) policy_engine.build() # Check permissions @@ -56,9 +58,10 @@ def check_permissions( messages.error(request, policy_message) return redirect("passbook_providers_oauth:oauth2-permission-denied") + plan: FlowPlan = request.session[SESSION_KEY_PLAN] Event.new( EventAction.AUTHORIZE_APPLICATION, authorized_application=application, - skipped_authorization=False, + flow=plan.flow_pk, ).from_http(request) return None diff --git a/passbook/providers/oidc/forms.py b/passbook/providers/oidc/forms.py index 857adeef6..46e56d16b 100644 --- a/passbook/providers/oidc/forms.py +++ b/passbook/providers/oidc/forms.py @@ -4,12 +4,17 @@ from django import forms from oauth2_provider.generators import generate_client_id, generate_client_secret from oidc_provider.models import Client +from passbook.flows.models import Flow, FlowDesignation from passbook.providers.oidc.models import OpenIDProvider class OIDCProviderForm(forms.ModelForm): """OpenID Client form""" + authorization_flow = forms.ModelChoiceField( + queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION) + ) + def __init__(self, *args, **kwargs): # Correctly load data from 1:1 rel if "instance" in kwargs and kwargs["instance"]: @@ -17,20 +22,35 @@ class OIDCProviderForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields["client_id"].initial = generate_client_id() self.fields["client_secret"].initial = generate_client_secret() + try: + self.fields[ + "authorization_flow" + ].initial = self.instance.openidprovider.authorization_flow + # pylint: disable=no-member + except Client.openidprovider.RelatedObjectDoesNotExist: + pass def save(self, *args, **kwargs): self.instance.reuse_consent = False # This is managed by passbook - self.instance.require_consent = True # This is managed by passbook + self.instance.require_consent = False # This is managed by passbook response = super().save(*args, **kwargs) # Check if openidprovider class instance exists if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists(): - OpenIDProvider.objects.create(oidc_client=self.instance) + OpenIDProvider.objects.create( + oidc_client=self.instance, + authorization_flow=self.cleaned_data.get("authorization_flow"), + ) + self.instance.openidprovider.authorization_flow = self.cleaned_data.get( + "authorization_flow" + ) + self.instance.openidprovider.save() return response class Meta: model = Client fields = [ "name", + "authorization_flow", "client_type", "client_id", "client_secret", diff --git a/passbook/providers/oidc/models.py b/passbook/providers/oidc/models.py index 16c6d9d34..e0f3feaf0 100644 --- a/passbook/providers/oidc/models.py +++ b/passbook/providers/oidc/models.py @@ -12,7 +12,7 @@ from passbook.lib.utils.template import render_to_string class OpenIDProvider(Provider): - """Proxy model for OIDC Client""" + """OpenID Connect Provider for applications that support OIDC.""" # Since oidc_provider doesn't currently support swappable models # (https://github.com/juanifioren/django-oidc-provider/pull/305) @@ -28,7 +28,7 @@ class OpenIDProvider(Provider): return self.oidc_client.name def __str__(self): - return "OpenID Connect Provider %s" % self.oidc_client.__str__() + return f"OpenID Connect Provider {self.oidc_client.__str__()}" def html_setup_urls(self, request: HttpRequest) -> Optional[str]: """return template and context modal with URLs for authorize, token, openid-config, etc""" @@ -37,14 +37,14 @@ class OpenIDProvider(Provider): { "provider": self, "authorize": request.build_absolute_uri( - reverse("oidc_provider:authorize") + reverse("passbook_providers_oidc:authorize") ), "token": request.build_absolute_uri(reverse("oidc_provider:token")), "userinfo": request.build_absolute_uri( reverse("oidc_provider:userinfo") ), "provider_info": request.build_absolute_uri( - reverse("oidc_provider:provider-info") + reverse("passbook_providers_oidc:provider-info") ), }, ) diff --git a/passbook/providers/oidc/signals.py b/passbook/providers/oidc/signals.py deleted file mode 100644 index a74550388..000000000 --- a/passbook/providers/oidc/signals.py +++ /dev/null @@ -1,15 +0,0 @@ -"""OIDC Provider signals""" -from django.db.models.signals import post_save -from django.dispatch import receiver - -from passbook.core.models import Application -from passbook.providers.oidc.models import OpenIDProvider - - -@receiver(post_save, sender=Application) -# pylint: disable=unused-argument -def on_application_save(sender, instance: Application, **_): - """Synchronize application's skip_authorization with oidc_client's require_consent""" - if isinstance(instance.provider, OpenIDProvider): - instance.provider.oidc_client.require_consent = not instance.skip_authorization - instance.provider.oidc_client.save() diff --git a/passbook/providers/oidc/urls.py b/passbook/providers/oidc/urls.py new file mode 100644 index 000000000..a7f317709 --- /dev/null +++ b/passbook/providers/oidc/urls.py @@ -0,0 +1,13 @@ +"""oidc provider URLs""" +from django.conf.urls import url + +from passbook.providers.oidc.views import AuthorizationFlowInitView, ProviderInfoView + +urlpatterns = [ + url(r"^authorize/?$", AuthorizationFlowInitView.as_view(), name="authorize"), + url( + r"^\.well-known/openid-configuration/?$", + ProviderInfoView.as_view(), + name="provider-info", + ), +] diff --git a/passbook/providers/oidc/views.py b/passbook/providers/oidc/views.py new file mode 100644 index 000000000..cd81f9c59 --- /dev/null +++ b/passbook/providers/oidc/views.py @@ -0,0 +1,127 @@ +"""passbook OIDC Views""" +from django.contrib import messages +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, reverse +from django.views import View +from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint +from oidc_provider.lib.utils.common import get_issuer, get_site_url +from oidc_provider.models import ResponseType +from oidc_provider.views import AuthorizeView +from structlog import get_logger + +from passbook.core.models import Application +from passbook.core.views.access import AccessMixin +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.urls import redirect_with_qs +from passbook.providers.oidc.models import OpenIDProvider + +LOGGER = get_logger() + +PLAN_CONTEXT_PARAMS = "params" + + +class AuthorizationFlowInitView(AccessMixin, View): + """OIDC Flow initializer, checks access to application and starts flow""" + + # pylint: disable=unused-argument + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Check access to application, start FlowPLanner, return to flow executor shell""" + client_id = request.GET.get("client_id") + provider = get_object_or_404(OpenIDProvider, oidc_client__client_id=client_id) + try: + application = self.provider_to_application(provider) + except Application.DoesNotExist: + return redirect("passbook_providers_oauth:oauth2-permission-denied") + # Check permissions + result = self.user_has_access(application, request.user) + if not result.passing: + for policy_message in result.messages: + messages.error(request, policy_message) + return redirect("passbook_providers_oauth:oauth2-permission-denied") + # Extract params so we can save them in the plan context + endpoint = AuthorizeEndpoint(request) + # Regardless, we start the planner and return to it + planner = FlowPlanner(provider.authorization_flow) + # planner.use_cache = False + planner.allow_empty_flows = True + plan = planner.plan( + self.request, + { + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_APPLICATION: application, + PLAN_CONTEXT_PARAMS: endpoint.params, + }, + ) + plan.stages.append(in_memory_stage(OIDCStage)) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "passbook_flows:flow-executor-shell", + self.request.GET, + flow_slug=provider.authorization_flow.slug, + ) + + +class FlowAuthorizeEndpoint(AuthorizeEndpoint): + """Restore params from flow context""" + + def _extract_params(self): + plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] + self.params = plan.context[PLAN_CONTEXT_PARAMS] + + +class OIDCStage(AuthorizeView, StageView): + """Finall stage, restores params from Flow.""" + + authorize_endpoint_class = FlowAuthorizeEndpoint + + +class ProviderInfoView(View): + """Custom ProviderInfo View which shows our URLs instead""" + + # pylint: disable=unused-argument + def get(self, request, *args, **kwargs): + """Custom ProviderInfo View which shows our URLs instead""" + dic = dict() + + site_url = get_site_url(request=request) + dic["issuer"] = get_issuer(site_url=site_url, request=request) + + dic["authorization_endpoint"] = site_url + reverse( + "passbook_providers_oidc:authorize" + ) + dic["token_endpoint"] = site_url + reverse("oidc_provider:token") + dic["userinfo_endpoint"] = site_url + reverse("oidc_provider:userinfo") + dic["end_session_endpoint"] = site_url + reverse("oidc_provider:end-session") + dic["introspection_endpoint"] = site_url + reverse( + "oidc_provider:token-introspection" + ) + + types_supported = [ + response_type.value for response_type in ResponseType.objects.all() + ] + dic["response_types_supported"] = types_supported + + dic["jwks_uri"] = site_url + reverse("oidc_provider:jwks") + + dic["id_token_signing_alg_values_supported"] = ["HS256", "RS256"] + + # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes + dic["subject_types_supported"] = ["public"] + + dic["token_endpoint_auth_methods_supported"] = [ + "client_secret_post", + "client_secret_basic", + ] + + response = JsonResponse(dic) + response["Access-Control-Allow-Origin"] = "*" + + return response diff --git a/passbook/providers/saml/forms.py b/passbook/providers/saml/forms.py index 3a5dd8904..864e36a74 100644 --- a/passbook/providers/saml/forms.py +++ b/passbook/providers/saml/forms.py @@ -5,6 +5,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.translation import gettext as _ from passbook.core.expression import PropertyMappingEvaluator +from passbook.flows.models import Flow, FlowDesignation from passbook.providers.saml.models import ( SAMLPropertyMapping, SAMLProvider, @@ -15,6 +16,9 @@ from passbook.providers.saml.models import ( class SAMLProviderForm(forms.ModelForm): """SAML Provider form""" + authorization_flow = forms.ModelChoiceField( + queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION) + ) processor_path = forms.ChoiceField( choices=get_provider_choices(), label="Processor" ) @@ -24,10 +28,12 @@ class SAMLProviderForm(forms.ModelForm): model = SAMLProvider fields = [ "name", + "authorization_flow", "processor_path", "acs_url", "audience", "issuer", + "sp_binding", "assertion_valid_not_before", "assertion_valid_not_on_or_after", "session_valid_not_on_or_after", diff --git a/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py b/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py index 38a67f5bd..9f1f13357 100644 --- a/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py +++ b/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py @@ -13,32 +13,32 @@ def create_default_property_mappings(apps, schema_editor): { "FriendlyName": "eduPersonPrincipalName", "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", - "Expression": "return user.get('email')", + "Expression": "return user.email", }, { "FriendlyName": "cn", "Name": "urn:oid:2.5.4.3", - "Expression": "return user.get('name')", + "Expression": "return user.name", }, { "FriendlyName": "mail", "Name": "urn:oid:0.9.2342.19200300.100.1.3", - "Expression": "return user.get('email')", + "Expression": "return user.email", }, { "FriendlyName": "displayName", "Name": "urn:oid:2.16.840.1.113730.3.1.241", - "Expression": "return user.get('username')", + "Expression": "return user.username", }, { "FriendlyName": "uid", "Name": "urn:oid:0.9.2342.19200300.100.1.1", - "Expression": "return user.get('pk')", + "Expression": "return user.pk", }, { "FriendlyName": "member-of", "Name": "member-of", - "Expression": "[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]", + "Expression": "for group in user.groups.all():\n yield group.name", }, ] for default in defaults: diff --git a/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py b/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py new file mode 100644 index 000000000..20ffbbf06 --- /dev/null +++ b/passbook/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 = [ + ("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/models.py b/passbook/providers/saml/models.py index 4472df8c7..26767fbbb 100644 --- a/passbook/providers/saml/models.py +++ b/passbook/providers/saml/models.py @@ -17,6 +17,13 @@ from passbook.providers.saml.utils.time import timedelta_string_validator LOGGER = get_logger() +class SAMLBindings(models.TextChoices): + """SAML Bindings supported by passbook""" + + REDIRECT = "redirect" + POST = "post" + + class SAMLProvider(Provider): """Model to save information about a Remote SAML Endpoint""" @@ -26,6 +33,9 @@ class SAMLProvider(Provider): acs_url = models.URLField(verbose_name=_("ACS URL")) audience = models.TextField(default="") issuer = models.TextField(help_text=_("Also known as EntityID")) + sp_binding = models.TextField( + choices=SAMLBindings.choices, default=SAMLBindings.REDIRECT + ) assertion_valid_not_before = models.TextField( default="minutes=-5", @@ -118,8 +128,8 @@ class SAMLProvider(Provider): try: # pylint: disable=no-member return reverse( - "passbook_providers_saml:saml-metadata", - kwargs={"application": self.application.slug}, + "passbook_providers_saml:metadata", + kwargs={"application_slug": self.application.slug}, ) except Provider.application.RelatedObjectDoesNotExist: return None diff --git a/passbook/providers/saml/templates/saml/idp/autosubmit_form.html b/passbook/providers/saml/templates/saml/idp/autosubmit_form.html index dff5c2dba..6202797b1 100644 --- a/passbook/providers/saml/templates/saml/idp/autosubmit_form.html +++ b/passbook/providers/saml/templates/saml/idp/autosubmit_form.html @@ -4,30 +4,26 @@ {% load i18n %} {% block card_title %} -{% trans 'Redirecting...' %} +{% blocktrans with app=application.name %} +Redirecting to {{ app }}... +{% endblocktrans %} {% endblock %} {% block card %} -
    + {% csrf_token %} {% for key, value in attrs.items %} {% endfor %} -