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
This commit is contained in:
parent
f91e02a0ec
commit
4915205678
|
@ -1,4 +1,17 @@
|
||||||
"""passbook core source form fields"""
|
"""passbook core source form fields"""
|
||||||
|
|
||||||
SOURCE_FORM_FIELDS = ["name", "slug", "enabled"]
|
SOURCE_FORM_FIELDS = [
|
||||||
SOURCE_SERIALIZER_FIELDS = ["pk", "name", "slug", "enabled"]
|
"name",
|
||||||
|
"slug",
|
||||||
|
"enabled",
|
||||||
|
"authentication_flow",
|
||||||
|
"enrollment_flow",
|
||||||
|
]
|
||||||
|
SOURCE_SERIALIZER_FIELDS = [
|
||||||
|
"pk",
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"enabled",
|
||||||
|
"authentication_flow",
|
||||||
|
"enrollment_flow",
|
||||||
|
]
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
{% for type, name in types.items %}
|
{% for type, name in types.items %}
|
||||||
<li>
|
<li>
|
||||||
<a class="pf-c-dropdown__menu-item" href="{% url 'passbook_admin:provider-create' %}?type={{ type }}&back={{ request.get_full_path }}">
|
<a class="pf-c-dropdown__menu-item" href="{% url 'passbook_admin:provider-create' %}?type={{ type }}&back={{ request.get_full_path }}">
|
||||||
{{ name|verbose_name }}
|
{{ name|verbose_name }}<br>
|
||||||
<small>
|
<small>
|
||||||
{{ name|doc }}
|
{{ name|doc }}
|
||||||
</small>
|
</small>
|
||||||
|
|
|
@ -5,14 +5,14 @@
|
||||||
|
|
||||||
{% block above_form %}
|
{% block above_form %}
|
||||||
<h1>
|
<h1>
|
||||||
{% blocktrans with type=form|form_verbose_name|title %}
|
{% blocktrans with type=form|form_verbose_name %}
|
||||||
Create {{ type }}
|
Create {{ type }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</h1>
|
</h1>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block action %}
|
{% block action %}
|
||||||
{% blocktrans with type=form|form_verbose_name|title %}
|
{% blocktrans with type=form|form_verbose_name %}
|
||||||
Create {{ type }}
|
Create {{ type }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -15,7 +15,6 @@ class ApplicationSerializer(ModelSerializer):
|
||||||
"pk",
|
"pk",
|
||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"skip_authorization",
|
|
||||||
"provider",
|
"provider",
|
||||||
"meta_launch_url",
|
"meta_launch_url",
|
||||||
"meta_icon_url",
|
"meta_icon_url",
|
||||||
|
|
|
@ -17,7 +17,7 @@ class ProviderSerializer(ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Provider
|
model = Provider
|
||||||
fields = ["pk", "property_mappings", "__type__"]
|
fields = ["pk", "authorization_flow", "property_mappings", "__type__"]
|
||||||
|
|
||||||
|
|
||||||
class ProviderViewSet(ReadOnlyModelViewSet):
|
class ProviderViewSet(ReadOnlyModelViewSet):
|
||||||
|
|
|
@ -19,7 +19,6 @@ class ApplicationForm(forms.ModelForm):
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"skip_authorization",
|
|
||||||
"provider",
|
"provider",
|
||||||
"meta_launch_url",
|
"meta_launch_url",
|
||||||
"meta_icon_url",
|
"meta_icon_url",
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -12,7 +12,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
pbadmin = User.objects.create(
|
pbadmin = User.objects.create(
|
||||||
username="pbadmin", email="root@localhost", name="passbook Default Admin"
|
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_superuser = True
|
||||||
pbadmin.is_staff = True
|
pbadmin.is_staff = True
|
||||||
pbadmin.save()
|
pbadmin.save()
|
||||||
|
@ -21,7 +21,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("passbook_core", "0001_initial"),
|
("passbook_core", "0002_auto_20200523_1133"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
|
@ -16,6 +16,7 @@ from structlog import get_logger
|
||||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||||
from passbook.core.signals import password_changed
|
from passbook.core.signals import password_changed
|
||||||
from passbook.core.types import UILoginButton, UIUserSettings
|
from passbook.core.types import UILoginButton, UIUserSettings
|
||||||
|
from passbook.flows.models import Flow
|
||||||
from passbook.lib.models import CreatedUpdatedModel
|
from passbook.lib.models import CreatedUpdatedModel
|
||||||
from passbook.policies.models import PolicyBindingModel
|
from passbook.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
|
@ -75,6 +76,13 @@ class User(GuardianUserMixin, AbstractUser):
|
||||||
class Provider(models.Model):
|
class Provider(models.Model):
|
||||||
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
"""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(
|
property_mappings = models.ManyToManyField(
|
||||||
"PropertyMapping", default=None, blank=True
|
"PropertyMapping", default=None, blank=True
|
||||||
)
|
)
|
||||||
|
@ -95,7 +103,6 @@ class Application(PolicyBindingModel):
|
||||||
|
|
||||||
name = models.TextField(help_text=_("Application's display Name."))
|
name = models.TextField(help_text=_("Application's display Name."))
|
||||||
slug = models.SlugField(help_text=_("Internal application name, used in URLs."))
|
slug = models.SlugField(help_text=_("Internal application name, used in URLs."))
|
||||||
skip_authorization = models.BooleanField(default=False)
|
|
||||||
provider = models.OneToOneField(
|
provider = models.OneToOneField(
|
||||||
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
|
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
|
||||||
)
|
)
|
||||||
|
@ -128,6 +135,25 @@ class Source(PolicyBindingModel):
|
||||||
"PropertyMapping", default=None, blank=True
|
"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
|
form = "" # ModelForm-based class ued to create/edit instance
|
||||||
|
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
|
@ -20,3 +20,40 @@
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="pf-c-login__main-footer">
|
||||||
|
{% if config.login.subtext %}
|
||||||
|
<p>{{ config.login.subtext }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<ul class="pf-c-login__main-footer-links">
|
||||||
|
{% for source in sources %}
|
||||||
|
<li class="pf-c-login__main-footer-links-item">
|
||||||
|
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
|
||||||
|
{% if source.icon_path %}
|
||||||
|
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
|
||||||
|
{% elif source.icon_url %}
|
||||||
|
<img src="icon_url" alt="{{ source.name }}">
|
||||||
|
{% else %}
|
||||||
|
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if enroll_url or recovery_url %}
|
||||||
|
<div class="pf-c-login__main-footer-band">
|
||||||
|
{% if enroll_url %}
|
||||||
|
<p class="pf-c-login__main-footer-band-item">
|
||||||
|
{% trans 'Need an account?' %}
|
||||||
|
<a href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if recovery_url %}
|
||||||
|
<p class="pf-c-login__main-footer-band-item">
|
||||||
|
<a href="{{ recovery_url }}">
|
||||||
|
{% trans 'Forgot username or password?' %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</footer>
|
||||||
|
|
|
@ -28,6 +28,9 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="pf-c-form__horizontal-group">
|
<div class="pf-c-form__horizontal-group">
|
||||||
{{ field|css_class:"pf-c-form-control" }}
|
{{ field|css_class:"pf-c-form-control" }}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
|
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
|
||||||
<div class="pf-c-form__horizontal-group">
|
<div class="pf-c-form__horizontal-group">
|
||||||
|
@ -36,7 +39,7 @@
|
||||||
<label class="pf-c-check__label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ field.label }}</label>
|
<label class="pf-c-check__label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ field.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% if field.help_text %}
|
{% if field.help_text %}
|
||||||
<p class="pf-c-form__helper-text">{{ field.help_text }}</p>
|
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -49,7 +52,7 @@
|
||||||
<div class="c-form__horizontal-group">
|
<div class="c-form__horizontal-group">
|
||||||
{{ field|css_class:'pf-c-form-control' }}
|
{{ field|css_class:'pf-c-form-control' }}
|
||||||
{% if field.help_text %}
|
{% if field.help_text %}
|
||||||
<p class="pf-c-form__helper-text">{{ field.help_text }}</p>
|
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -35,6 +35,6 @@ class AccessMixin:
|
||||||
def user_has_access(self, application: Application, user: User) -> PolicyResult:
|
def user_has_access(self, application: Application, user: User) -> PolicyResult:
|
||||||
"""Check if user has access to application."""
|
"""Check if user has access to application."""
|
||||||
LOGGER.debug("Checking permissions", user=user, application=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()
|
policy_engine.build()
|
||||||
return policy_engine.result
|
return policy_engine.result
|
||||||
|
|
|
@ -16,9 +16,7 @@ class OverviewView(LoginRequiredMixin, TemplateView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs["applications"] = []
|
kwargs["applications"] = []
|
||||||
for application in Application.objects.all().order_by("name"):
|
for application in Application.objects.all().order_by("name"):
|
||||||
engine = PolicyEngine(
|
engine = PolicyEngine(application, self.request.user, self.request)
|
||||||
application.policies.all(), self.request.user, self.request
|
|
||||||
)
|
|
||||||
engine.build()
|
engine.build()
|
||||||
if engine.passing:
|
if engine.passing:
|
||||||
kwargs["applications"].append(application)
|
kwargs["applications"].append(application)
|
||||||
|
|
|
@ -36,11 +36,11 @@ class CertificateBuilder:
|
||||||
x509.Name(
|
x509.Name(
|
||||||
[
|
[
|
||||||
x509.NameAttribute(
|
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(
|
x509.NameAttribute(
|
||||||
NameOID.ORGANIZATIONAL_UNIT_NAME, u"Self-signed"
|
NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -49,7 +49,7 @@ class CertificateBuilder:
|
||||||
x509.Name(
|
x509.Name(
|
||||||
[
|
[
|
||||||
x509.NameAttribute(
|
x509.NameAttribute(
|
||||||
NameOID.COMMON_NAME, u"passbook Self-signed Certificate",
|
NameOID.COMMON_NAME, "passbook Self-signed Certificate",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
]
|
|
@ -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)]
|
|
@ -1,5 +1,5 @@
|
||||||
"""Flow models"""
|
"""Flow models"""
|
||||||
from typing import Optional
|
from typing import Callable, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
@ -9,6 +9,7 @@ from model_utils.managers import InheritanceManager
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.types import UIUserSettings
|
from passbook.core.types import UIUserSettings
|
||||||
|
from passbook.lib.utils.reflection import class_to_path
|
||||||
from passbook.policies.models import PolicyBindingModel
|
from passbook.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -19,6 +20,7 @@ class FlowDesignation(models.TextChoices):
|
||||||
should be replaced by a database entry."""
|
should be replaced by a database entry."""
|
||||||
|
|
||||||
AUTHENTICATION = "authentication"
|
AUTHENTICATION = "authentication"
|
||||||
|
AUTHORIZATION = "authorization"
|
||||||
INVALIDATION = "invalidation"
|
INVALIDATION = "invalidation"
|
||||||
ENROLLMENT = "enrollment"
|
ENROLLMENT = "enrollment"
|
||||||
UNRENOLLMENT = "unenrollment"
|
UNRENOLLMENT = "unenrollment"
|
||||||
|
@ -48,6 +50,14 @@ class Stage(models.Model):
|
||||||
return f"Stage {self.name}"
|
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):
|
class Flow(PolicyBindingModel):
|
||||||
"""Flow describes how a series of Stages should be executed to authenticate/enroll/recover
|
"""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
|
a user. Additionally, policies can be applied, to specify which users
|
||||||
|
|
|
@ -16,6 +16,7 @@ LOGGER = get_logger()
|
||||||
|
|
||||||
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
||||||
PLAN_CONTEXT_SSO = "is_sso"
|
PLAN_CONTEXT_SSO = "is_sso"
|
||||||
|
PLAN_CONTEXT_APPLICATION = "application"
|
||||||
|
|
||||||
|
|
||||||
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
|
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
|
||||||
|
@ -45,10 +46,13 @@ class FlowPlanner:
|
||||||
that should be applied."""
|
that should be applied."""
|
||||||
|
|
||||||
use_cache: bool
|
use_cache: bool
|
||||||
|
allow_empty_flows: bool
|
||||||
|
|
||||||
flow: Flow
|
flow: Flow
|
||||||
|
|
||||||
def __init__(self, flow: Flow):
|
def __init__(self, flow: Flow):
|
||||||
self.use_cache = True
|
self.use_cache = True
|
||||||
|
self.allow_empty_flows = False
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
|
|
||||||
def plan(
|
def plan(
|
||||||
|
@ -80,11 +84,13 @@ class FlowPlanner:
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
|
"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
|
return cached_plan
|
||||||
LOGGER.debug("f(plan): building plan", flow=self.flow)
|
LOGGER.debug("f(plan): building plan", flow=self.flow)
|
||||||
plan = self._build_plan(user, request, default_context)
|
plan = self._build_plan(user, request, default_context)
|
||||||
cache.set(cache_key(self.flow, user), plan)
|
cache.set(cache_key(self.flow, user), plan)
|
||||||
if not plan.stages:
|
if not plan.stages and not self.allow_empty_flows:
|
||||||
raise EmptyFlowException()
|
raise EmptyFlowException()
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
|
@ -113,19 +113,18 @@ const updateMessages = () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const updateCard = (response) => {
|
const updateCard = (data) => {
|
||||||
if (!response.ok) {
|
switch (data.type) {
|
||||||
console.log("well");
|
case "redirect":
|
||||||
}
|
window.location = data.to
|
||||||
if (response.redirected && !response.url.endsWith(flowBodyUrl)) {
|
break;
|
||||||
window.location = response.url;
|
case "template":
|
||||||
} else {
|
flowBody.innerHTML = data.body;
|
||||||
response.text().then(text => {
|
|
||||||
flowBody.innerHTML = text;
|
|
||||||
updateMessages();
|
updateMessages();
|
||||||
loadFormCode();
|
loadFormCode();
|
||||||
setFormSubmitHandlers();
|
setFormSubmitHandlers();
|
||||||
});
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const showSpinner = () => {
|
const showSpinner = () => {
|
||||||
|
@ -139,10 +138,28 @@ const loadFormCode = () => {
|
||||||
document.head.appendChild(newScript);
|
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 = () => {
|
const setFormSubmitHandlers = () => {
|
||||||
document.querySelectorAll("#flow-body form").forEach(form => {
|
document.querySelectorAll("#flow-body form").forEach(form => {
|
||||||
|
console.log(`Checking for autosubmit attribute ${form}`);
|
||||||
|
checkAutosubmit(form);
|
||||||
console.log(`Setting action for form ${form}`);
|
console.log(`Setting action for form ${form}`);
|
||||||
form.action = flowBodyUrl;
|
updateFormAction(form);
|
||||||
console.log(`Adding handler for form ${form}`);
|
console.log(`Adding handler for form ${form}`);
|
||||||
form.addEventListener('submit', (e) => {
|
form.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -150,19 +167,13 @@ const setFormSubmitHandlers = () => {
|
||||||
fetch(flowBodyUrl, {
|
fetch(flowBodyUrl, {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: formData,
|
body: formData,
|
||||||
}).then((response) => {
|
}).then(response => response.json()).then(data => {
|
||||||
showSpinner();
|
updateCard(data);
|
||||||
if (!response.url.endsWith(flowBodyUrl)) {
|
|
||||||
window.location = response.url;
|
|
||||||
} else {
|
|
||||||
updateCard(response);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
fetch(flowBodyUrl).then(updateCard);
|
fetch(flowBodyUrl).then(response => response.json()).then(data => updateCard(data));
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
"""passbook multi-stage authentication engine"""
|
"""passbook multi-stage authentication engine"""
|
||||||
from typing import Any, Dict, Optional
|
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.shortcuts import get_object_or_404, redirect, reverse
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
from django.views.generic import TemplateView, View
|
from django.views.generic import TemplateView, View
|
||||||
|
@ -81,6 +88,8 @@ class FlowExecutorView(View):
|
||||||
)
|
)
|
||||||
stage_cls = path_to_class(self.current_stage.type)
|
stage_cls = path_to_class(self.current_stage.type)
|
||||||
self.current_stage_view = stage_cls(self)
|
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
|
self.current_stage_view.request = request
|
||||||
return super().dispatch(request)
|
return super().dispatch(request)
|
||||||
|
|
||||||
|
@ -91,7 +100,8 @@ class FlowExecutorView(View):
|
||||||
view_class=class_to_path(self.current_stage_view.__class__),
|
view_class=class_to_path(self.current_stage_view.__class__),
|
||||||
flow_slug=self.flow.slug,
|
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:
|
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""pass post request to current stage"""
|
"""pass post request to current stage"""
|
||||||
|
@ -100,7 +110,8 @@ class FlowExecutorView(View):
|
||||||
view_class=class_to_path(self.current_stage_view.__class__),
|
view_class=class_to_path(self.current_stage_view.__class__),
|
||||||
flow_slug=self.flow.slug,
|
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:
|
def _initiate_plan(self) -> FlowPlan:
|
||||||
planner = FlowPlanner(self.flow)
|
planner = FlowPlanner(self.flow)
|
||||||
|
@ -191,3 +202,22 @@ class FlowExecutorShellView(TemplateView):
|
||||||
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
|
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
|
||||||
kwargs["msg_url"] = reverse("passbook_api:messages-list")
|
kwargs["msg_url"] = reverse("passbook_api:messages-list")
|
||||||
return kwargs
|
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
|
||||||
|
|
|
@ -38,6 +38,9 @@ class PolicyResult:
|
||||||
self.passing = passing
|
self.passing = passing
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.messages:
|
if self.messages:
|
||||||
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
||||||
|
|
|
@ -32,7 +32,7 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = ApplicationGatewayProvider
|
model = ApplicationGatewayProvider
|
||||||
fields = ["name", "internal_host", "external_host"]
|
fields = ["name", "authorization_flow", "internal_host", "external_host"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"name": forms.TextInput(),
|
||||||
"internal_host": forms.TextInput(),
|
"internal_host": forms.TextInput(),
|
||||||
|
|
|
@ -14,7 +14,8 @@ from passbook.lib.utils.template import render_to_string
|
||||||
|
|
||||||
|
|
||||||
class ApplicationGatewayProvider(Provider):
|
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()
|
name = models.TextField()
|
||||||
internal_host = models.TextField()
|
internal_host = models.TextField()
|
||||||
|
|
|
@ -1,21 +1,32 @@
|
||||||
"""passbook OAuth2 Provider Forms"""
|
"""passbook OAuth2 Provider Forms"""
|
||||||
|
|
||||||
from django import 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
|
from passbook.providers.oauth.models import OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
class OAuth2ProviderForm(forms.ModelForm):
|
class OAuth2ProviderForm(forms.ModelForm):
|
||||||
"""OAuth2 Provider form"""
|
"""OAuth2 Provider form"""
|
||||||
|
|
||||||
|
authorization_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = OAuth2Provider
|
model = OAuth2Provider
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
|
"authorization_flow",
|
||||||
"redirect_uris",
|
"redirect_uris",
|
||||||
"client_type",
|
"client_type",
|
||||||
"authorization_grant_type",
|
"authorization_grant_type",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
]
|
]
|
||||||
|
labels = {
|
||||||
|
"client_id": _("Client ID"),
|
||||||
|
"redirect_uris": _("Redirect URIs"),
|
||||||
|
}
|
||||||
|
|
|
@ -12,7 +12,8 @@ from passbook.lib.utils.template import render_to_string
|
||||||
|
|
||||||
|
|
||||||
class OAuth2Provider(Provider, AbstractApplication):
|
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"
|
form = "passbook.providers.oauth.forms.OAuth2ProviderForm"
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ OAUTH2_PROVIDER = {
|
||||||
"SCOPES": {
|
"SCOPES": {
|
||||||
"openid": "Access OpenID Userinfo",
|
"openid": "Access OpenID Userinfo",
|
||||||
"openid:userinfo": "Access OpenID Userinfo",
|
"openid:userinfo": "Access OpenID Userinfo",
|
||||||
|
"email": "Access OpenID E-Mail",
|
||||||
# 'write': 'Write scope',
|
# 'write': 'Write scope',
|
||||||
# 'groups': 'Access to your groups',
|
# 'groups': 'Access to your groups',
|
||||||
"user:email": "GitHub Compatibility: User E-Mail",
|
"user:email": "GitHub Compatibility: User E-Mail",
|
||||||
|
|
|
@ -9,14 +9,9 @@ oauth_urlpatterns = [
|
||||||
# Custom OAuth2 Authorize View
|
# Custom OAuth2 Authorize View
|
||||||
path(
|
path(
|
||||||
"authorize/",
|
"authorize/",
|
||||||
oauth2.PassbookAuthorizationView.as_view(),
|
oauth2.AuthorizationFlowInitView.as_view(),
|
||||||
name="oauth2-authorize",
|
name="oauth2-authorize",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"authorize/permission_denied/",
|
|
||||||
oauth2.OAuthPermissionDenied.as_view(),
|
|
||||||
name="oauth2-permission-denied",
|
|
||||||
),
|
|
||||||
# OAuth API
|
# OAuth API
|
||||||
path("token/", views.TokenView.as_view(), name="token"),
|
path("token/", views.TokenView.as_view(), name="token"),
|
||||||
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
|
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
|
||||||
|
@ -26,7 +21,7 @@ oauth_urlpatterns = [
|
||||||
github_urlpatterns = [
|
github_urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"login/oauth/authorize",
|
"login/oauth/authorize",
|
||||||
oauth2.PassbookAuthorizationView.as_view(),
|
oauth2.AuthorizationFlowInitView.as_view(),
|
||||||
name="github-authorize",
|
name="github-authorize",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
|
@ -35,6 +30,7 @@ github_urlpatterns = [
|
||||||
name="github-access-token",
|
name="github-access-token",
|
||||||
),
|
),
|
||||||
path("user", github.GitHubUserView.as_view(), name="github-user"),
|
path("user", github.GitHubUserView.as_view(), name="github-user"),
|
||||||
|
path("user/teams", github.GitHubUserTeamsView.as_view(), name="github-user-teams"),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
|
@ -1,21 +1,32 @@
|
||||||
"""passbook pretend GitHub Views"""
|
"""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.shortcuts import get_object_or_404
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from oauth2_provider.models import AccessToken
|
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"""
|
"""Emulate GitHub's /user API Endpoint"""
|
||||||
|
|
||||||
def verify_access_token(self):
|
def get(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""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):
|
|
||||||
"""Emulate GitHub's /user API Endpoint"""
|
"""Emulate GitHub's /user API Endpoint"""
|
||||||
user = self.verify_access_token()
|
user = self.verify_access_token()
|
||||||
return JsonResponse(
|
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)
|
||||||
|
|
|
@ -1,76 +1,124 @@
|
||||||
"""passbook OAuth2 Views"""
|
"""passbook OAuth2 Views"""
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.forms import Form
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
from django.views import View
|
||||||
|
from oauth2_provider.exceptions import OAuthToolkitError
|
||||||
from oauth2_provider.views.base import AuthorizationView
|
from oauth2_provider.views.base import AuthorizationView
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.core.models import Application
|
from passbook.core.models import Application
|
||||||
from passbook.core.views.access import AccessMixin
|
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
|
from passbook.providers.oauth.models import OAuth2Provider
|
||||||
|
|
||||||
LOGGER = get_logger()
|
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):
|
PLAN_CONTEXT_CODE_CHALLENGE = "code_challenge"
|
||||||
"""Show permission denied view"""
|
PLAN_CONTEXT_CODE_CHALLENGE_METHOD = "code_challenge_method"
|
||||||
|
PLAN_CONTEXT_SCOPE = "scope"
|
||||||
|
PLAN_CONTEXT_NONCE = "nonce"
|
||||||
|
|
||||||
|
|
||||||
class PassbookAuthorizationView(AccessMixin, AuthorizationView):
|
class AuthorizationFlowInitView(AccessMixin, View):
|
||||||
"""Custom OAuth2 Authorization View which checks policies, etc"""
|
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||||
|
|
||||||
_application: Optional[Application] = None
|
# pylint: disable=unused-argument
|
||||||
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
def _inject_response_type(self):
|
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||||
"""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
|
|
||||||
client_id = request.GET.get("client_id")
|
client_id = request.GET.get("client_id")
|
||||||
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||||
try:
|
try:
|
||||||
application = self.provider_to_application(provider)
|
application = self.provider_to_application(provider)
|
||||||
except Application.DoesNotExist:
|
except Application.DoesNotExist:
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
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
|
# Check permissions
|
||||||
result = self.user_has_access(self._application, request.user)
|
result = self.user_has_access(application, request.user)
|
||||||
if not result.passing:
|
if not result.passing:
|
||||||
for policy_message in result.messages:
|
for policy_message in result.messages:
|
||||||
messages.error(request, policy_message)
|
messages.error(request, policy_message)
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
# Some clients don't pass response_type, so we default to code
|
# Regardless, we start the planner and return to it
|
||||||
if "response_type" not in request.GET:
|
planner = FlowPlanner(provider.authorization_flow)
|
||||||
return self._inject_response_type()
|
# planner.use_cache = False
|
||||||
actual_response = AuthorizationView.dispatch(self, request, *args, **kwargs)
|
planner.allow_empty_flows = True
|
||||||
if actual_response.status_code == 400:
|
plan = planner.plan(
|
||||||
LOGGER.debug("Bad request", redirect_uri=request.GET.get("redirect_uri"))
|
self.request,
|
||||||
return actual_response
|
{
|
||||||
|
PLAN_CONTEXT_SSO: True,
|
||||||
def form_valid(self, form: Form):
|
PLAN_CONTEXT_APPLICATION: application,
|
||||||
# User has clicked on "Authorize"
|
PLAN_CONTEXT_CLIENT_ID: client_id,
|
||||||
Event.new(
|
PLAN_CONTEXT_REDIRECT_URI: request.GET.get(PLAN_CONTEXT_REDIRECT_URI),
|
||||||
EventAction.AUTHORIZE_APPLICATION, authorized_application=self._application,
|
PLAN_CONTEXT_RESPONSE_TYPE: request.GET.get(PLAN_CONTEXT_RESPONSE_TYPE),
|
||||||
).from_http(self.request)
|
PLAN_CONTEXT_STATE: request.GET.get(PLAN_CONTEXT_STATE),
|
||||||
LOGGER.debug(
|
PLAN_CONTEXT_SCOPE: request.GET.get(PLAN_CONTEXT_SCOPE),
|
||||||
"User authorized Application",
|
PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE),
|
||||||
user=self.request.user,
|
},
|
||||||
application=self._application,
|
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
"""passbook auth oidc provider app config"""
|
"""passbook auth oidc provider app config"""
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db.utils import InternalError, OperationalError, ProgrammingError
|
from django.db.utils import InternalError, OperationalError, ProgrammingError
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
@ -15,6 +13,7 @@ class PassbookProviderOIDCConfig(AppConfig):
|
||||||
name = "passbook.providers.oidc"
|
name = "passbook.providers.oidc"
|
||||||
label = "passbook_providers_oidc"
|
label = "passbook_providers_oidc"
|
||||||
verbose_name = "passbook Providers.OIDC"
|
verbose_name = "passbook Providers.OIDC"
|
||||||
|
mountpoint = "application/oidc/"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
try:
|
try:
|
||||||
|
@ -36,5 +35,3 @@ class PassbookProviderOIDCConfig(AppConfig):
|
||||||
include("oidc_provider.urls", namespace="oidc_provider"),
|
include("oidc_provider.urls", namespace="oidc_provider"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
import_module("passbook.providers.oidc.signals")
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.core.models import Application, Provider, User
|
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
|
from passbook.policies.engine import PolicyEngine
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -46,7 +48,7 @@ def check_permissions(
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Checking permissions for application", user=user, application=application
|
"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()
|
policy_engine.build()
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
|
@ -56,9 +58,10 @@ def check_permissions(
|
||||||
messages.error(request, policy_message)
|
messages.error(request, policy_message)
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
|
|
||||||
|
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||||
Event.new(
|
Event.new(
|
||||||
EventAction.AUTHORIZE_APPLICATION,
|
EventAction.AUTHORIZE_APPLICATION,
|
||||||
authorized_application=application,
|
authorized_application=application,
|
||||||
skipped_authorization=False,
|
flow=plan.flow_pk,
|
||||||
).from_http(request)
|
).from_http(request)
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -4,12 +4,17 @@ from django import forms
|
||||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||||
from oidc_provider.models import Client
|
from oidc_provider.models import Client
|
||||||
|
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
from passbook.providers.oidc.models import OpenIDProvider
|
from passbook.providers.oidc.models import OpenIDProvider
|
||||||
|
|
||||||
|
|
||||||
class OIDCProviderForm(forms.ModelForm):
|
class OIDCProviderForm(forms.ModelForm):
|
||||||
"""OpenID Client form"""
|
"""OpenID Client form"""
|
||||||
|
|
||||||
|
authorization_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Correctly load data from 1:1 rel
|
# Correctly load data from 1:1 rel
|
||||||
if "instance" in kwargs and kwargs["instance"]:
|
if "instance" in kwargs and kwargs["instance"]:
|
||||||
|
@ -17,20 +22,35 @@ class OIDCProviderForm(forms.ModelForm):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["client_id"].initial = generate_client_id()
|
self.fields["client_id"].initial = generate_client_id()
|
||||||
self.fields["client_secret"].initial = generate_client_secret()
|
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):
|
def save(self, *args, **kwargs):
|
||||||
self.instance.reuse_consent = False # This is managed by passbook
|
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)
|
response = super().save(*args, **kwargs)
|
||||||
# Check if openidprovider class instance exists
|
# Check if openidprovider class instance exists
|
||||||
if not OpenIDProvider.objects.filter(oidc_client=self.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
|
return response
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Client
|
model = Client
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
|
"authorization_flow",
|
||||||
"client_type",
|
"client_type",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
|
|
|
@ -12,7 +12,7 @@ from passbook.lib.utils.template import render_to_string
|
||||||
|
|
||||||
|
|
||||||
class OpenIDProvider(Provider):
|
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
|
# Since oidc_provider doesn't currently support swappable models
|
||||||
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
|
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
|
||||||
|
@ -28,7 +28,7 @@ class OpenIDProvider(Provider):
|
||||||
return self.oidc_client.name
|
return self.oidc_client.name
|
||||||
|
|
||||||
def __str__(self):
|
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]:
|
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||||
|
@ -37,14 +37,14 @@ class OpenIDProvider(Provider):
|
||||||
{
|
{
|
||||||
"provider": self,
|
"provider": self,
|
||||||
"authorize": request.build_absolute_uri(
|
"authorize": request.build_absolute_uri(
|
||||||
reverse("oidc_provider:authorize")
|
reverse("passbook_providers_oidc:authorize")
|
||||||
),
|
),
|
||||||
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
|
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
|
||||||
"userinfo": request.build_absolute_uri(
|
"userinfo": request.build_absolute_uri(
|
||||||
reverse("oidc_provider:userinfo")
|
reverse("oidc_provider:userinfo")
|
||||||
),
|
),
|
||||||
"provider_info": request.build_absolute_uri(
|
"provider_info": request.build_absolute_uri(
|
||||||
reverse("oidc_provider:provider-info")
|
reverse("passbook_providers_oidc:provider-info")
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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()
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
|
@ -5,6 +5,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from passbook.core.expression import PropertyMappingEvaluator
|
from passbook.core.expression import PropertyMappingEvaluator
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
from passbook.providers.saml.models import (
|
from passbook.providers.saml.models import (
|
||||||
SAMLPropertyMapping,
|
SAMLPropertyMapping,
|
||||||
SAMLProvider,
|
SAMLProvider,
|
||||||
|
@ -15,6 +16,9 @@ from passbook.providers.saml.models import (
|
||||||
class SAMLProviderForm(forms.ModelForm):
|
class SAMLProviderForm(forms.ModelForm):
|
||||||
"""SAML Provider form"""
|
"""SAML Provider form"""
|
||||||
|
|
||||||
|
authorization_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
|
||||||
|
)
|
||||||
processor_path = forms.ChoiceField(
|
processor_path = forms.ChoiceField(
|
||||||
choices=get_provider_choices(), label="Processor"
|
choices=get_provider_choices(), label="Processor"
|
||||||
)
|
)
|
||||||
|
@ -24,10 +28,12 @@ class SAMLProviderForm(forms.ModelForm):
|
||||||
model = SAMLProvider
|
model = SAMLProvider
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
|
"authorization_flow",
|
||||||
"processor_path",
|
"processor_path",
|
||||||
"acs_url",
|
"acs_url",
|
||||||
"audience",
|
"audience",
|
||||||
"issuer",
|
"issuer",
|
||||||
|
"sp_binding",
|
||||||
"assertion_valid_not_before",
|
"assertion_valid_not_before",
|
||||||
"assertion_valid_not_on_or_after",
|
"assertion_valid_not_on_or_after",
|
||||||
"session_valid_not_on_or_after",
|
"session_valid_not_on_or_after",
|
||||||
|
|
|
@ -13,32 +13,32 @@ def create_default_property_mappings(apps, schema_editor):
|
||||||
{
|
{
|
||||||
"FriendlyName": "eduPersonPrincipalName",
|
"FriendlyName": "eduPersonPrincipalName",
|
||||||
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
|
"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",
|
"FriendlyName": "cn",
|
||||||
"Name": "urn:oid:2.5.4.3",
|
"Name": "urn:oid:2.5.4.3",
|
||||||
"Expression": "return user.get('name')",
|
"Expression": "return user.name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"FriendlyName": "mail",
|
"FriendlyName": "mail",
|
||||||
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
|
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
|
||||||
"Expression": "return user.get('email')",
|
"Expression": "return user.email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"FriendlyName": "displayName",
|
"FriendlyName": "displayName",
|
||||||
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
|
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
|
||||||
"Expression": "return user.get('username')",
|
"Expression": "return user.username",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"FriendlyName": "uid",
|
"FriendlyName": "uid",
|
||||||
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
|
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
|
||||||
"Expression": "return user.get('pk')",
|
"Expression": "return user.pk",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"FriendlyName": "member-of",
|
"FriendlyName": "member-of",
|
||||||
"Name": "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:
|
for default in defaults:
|
||||||
|
|
|
@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -17,6 +17,13 @@ from passbook.providers.saml.utils.time import timedelta_string_validator
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class SAMLBindings(models.TextChoices):
|
||||||
|
"""SAML Bindings supported by passbook"""
|
||||||
|
|
||||||
|
REDIRECT = "redirect"
|
||||||
|
POST = "post"
|
||||||
|
|
||||||
|
|
||||||
class SAMLProvider(Provider):
|
class SAMLProvider(Provider):
|
||||||
"""Model to save information about a Remote SAML Endpoint"""
|
"""Model to save information about a Remote SAML Endpoint"""
|
||||||
|
|
||||||
|
@ -26,6 +33,9 @@ class SAMLProvider(Provider):
|
||||||
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
||||||
audience = models.TextField(default="")
|
audience = models.TextField(default="")
|
||||||
issuer = models.TextField(help_text=_("Also known as EntityID"))
|
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(
|
assertion_valid_not_before = models.TextField(
|
||||||
default="minutes=-5",
|
default="minutes=-5",
|
||||||
|
@ -118,8 +128,8 @@ class SAMLProvider(Provider):
|
||||||
try:
|
try:
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
return reverse(
|
return reverse(
|
||||||
"passbook_providers_saml:saml-metadata",
|
"passbook_providers_saml:metadata",
|
||||||
kwargs={"application": self.application.slug},
|
kwargs={"application_slug": self.application.slug},
|
||||||
)
|
)
|
||||||
except Provider.application.RelatedObjectDoesNotExist:
|
except Provider.application.RelatedObjectDoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -4,30 +4,26 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block card_title %}
|
{% block card_title %}
|
||||||
{% trans 'Redirecting...' %}
|
{% blocktrans with app=application.name %}
|
||||||
|
Redirecting to {{ app }}...
|
||||||
|
{% endblocktrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card %}
|
{% block card %}
|
||||||
<form method="POST" action="{{ url }}">
|
<form method="POST" action="{{ url }}" autosubmit>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for key, value in attrs.items %}
|
{% for key, value in attrs.items %}
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="login-group">
|
<div class="pf-c-form__group">
|
||||||
<p>
|
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
|
||||||
{% blocktrans with user=user %}
|
<span class="pf-c-spinner__clipper"></span>
|
||||||
You are logged in as {{ user }}.
|
<span class="pf-c-spinner__lead-ball"></span>
|
||||||
{% endblocktrans %}
|
<span class="pf-c-spinner__tail-ball"></span>
|
||||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
|
</span>
|
||||||
</p>
|
</div>
|
||||||
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
|
<div class="pf-c-form__group pf-m-action">
|
||||||
|
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{{ block.super }}
|
|
||||||
<script>
|
|
||||||
document.querySelector("form").submit();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
{% extends "login/base.html" %}
|
|
||||||
|
|
||||||
{% load passbook_utils %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block card %}
|
|
||||||
<form method="POST" class="pf-c-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<h3>
|
|
||||||
{% blocktrans with provider=provider.application.name %}
|
|
||||||
You're about to sign into {{ provider }}
|
|
||||||
{% endblocktrans %}
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
{% blocktrans with user=user %}
|
|
||||||
You are logged in as {{ user }}.
|
|
||||||
{% endblocktrans %}
|
|
||||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-form__group pf-m-action">
|
|
||||||
<input class="pf-c-button pf-m-primary pf-m-block" type="submit" value="{% trans 'Continue' %}" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
|
@ -11,8 +11,7 @@
|
||||||
</md:KeyDescriptor>
|
</md:KeyDescriptor>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
|
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
|
||||||
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/>
|
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_binding_post }}"/>
|
||||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_post_url }}"/>
|
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_binding_redirect }}"/>
|
||||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_redirect_url }}"/>
|
|
||||||
</md:IDPSSODescriptor>
|
</md:IDPSSODescriptor>
|
||||||
</md:EntityDescriptor>
|
</md:EntityDescriptor>
|
||||||
|
|
|
@ -4,31 +4,26 @@ from django.urls import path
|
||||||
from passbook.providers.saml import views
|
from passbook.providers.saml import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# This view is used to initiate a Login-flow from the IDP
|
# SSO Bindings
|
||||||
path(
|
path(
|
||||||
"<slug:application>/login/initiate/",
|
"<slug:application_slug>/sso/binding/redirect/",
|
||||||
views.InitiateLoginView.as_view(),
|
views.SAMLSSOBindingRedirectView.as_view(),
|
||||||
name="saml-login-initiate",
|
name="sso-redirect",
|
||||||
),
|
|
||||||
# This view is the endpoint a SP would redirect to, and saves data into the session
|
|
||||||
# this is required as the process view which it redirects to might have to login first.
|
|
||||||
path(
|
|
||||||
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
|
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"<slug:application>/login/authorize/",
|
"<slug:application_slug>/sso/binding/post/",
|
||||||
views.AuthorizeView.as_view(),
|
views.SAMLSSOBindingPOSTView.as_view(),
|
||||||
name="saml-login-authorize",
|
name="sso-post",
|
||||||
),
|
),
|
||||||
path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"),
|
# SSO IdP Initiated
|
||||||
path(
|
path(
|
||||||
"<slug:application>/logout/slo/",
|
"<slug:application_slug>/sso/binding/init/",
|
||||||
views.SLOLogout.as_view(),
|
views.SAMLSSOBindingInitView.as_view(),
|
||||||
name="saml-logout-slo",
|
name="sso-init",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"<slug:application>/metadata/",
|
"<slug:application_slug>/metadata/",
|
||||||
views.DescriptorDownloadView.as_view(),
|
views.DescriptorDownloadView.as_view(),
|
||||||
name="saml-metadata",
|
name="metadata",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
"""passbook SAML IDP Views"""
|
"""passbook SAML IDP Views"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib.auth import logout
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import AccessMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
||||||
from django.utils.datastructures import MultiValueDictKeyError
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.html import mark_safe
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
@ -18,11 +17,20 @@ from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.core.models import Application, Provider
|
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.template import render_to_string
|
from passbook.lib.utils.template import render_to_string
|
||||||
|
from passbook.lib.utils.urls import redirect_with_qs
|
||||||
from passbook.lib.views import bad_request_message
|
from passbook.lib.views import bad_request_message
|
||||||
from passbook.policies.engine import PolicyEngine
|
from passbook.policies.engine import PolicyEngine
|
||||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||||
from passbook.providers.saml.models import SAMLProvider
|
from passbook.providers.saml.models import SAMLBindings, SAMLProvider
|
||||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -33,69 +41,82 @@ SESSION_KEY_RELAY_STATE = "RelayState"
|
||||||
SESSION_KEY_PARAMS = "SAMLParams"
|
SESSION_KEY_PARAMS = "SAMLParams"
|
||||||
|
|
||||||
|
|
||||||
class AccessRequiredView(AccessMixin, View):
|
class SAMLAccessMixin:
|
||||||
"""Mixin class for Views using a provider instance"""
|
"""SAML base access mixin, checks access to an application based on its policies"""
|
||||||
|
|
||||||
_provider: Optional[SAMLProvider] = None
|
request: HttpRequest
|
||||||
|
application: Application
|
||||||
@property
|
provider: SAMLProvider
|
||||||
def provider(self) -> SAMLProvider:
|
|
||||||
"""Get provider instance"""
|
|
||||||
if not self._provider:
|
|
||||||
application = get_object_or_404(
|
|
||||||
Application, slug=self.kwargs["application"]
|
|
||||||
)
|
|
||||||
provider: SAMLProvider = get_object_or_404(
|
|
||||||
SAMLProvider, pk=application.provider_id
|
|
||||||
)
|
|
||||||
self._provider = provider
|
|
||||||
return self._provider
|
|
||||||
return self._provider
|
|
||||||
|
|
||||||
def _has_access(self) -> bool:
|
def _has_access(self) -> bool:
|
||||||
"""Check if user has access to application"""
|
"""Check if user has access to application, add an error if not"""
|
||||||
policy_engine = PolicyEngine(
|
policy_engine = PolicyEngine(self.application, self.request.user, self.request)
|
||||||
self.provider.application.policies.all(), self.request.user, self.request
|
|
||||||
)
|
|
||||||
policy_engine.build()
|
policy_engine.build()
|
||||||
passing = policy_engine.passing
|
result = policy_engine.result
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"saml_has_access",
|
"SAMLFlowInit _has_access",
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
app=self.provider.application,
|
app=self.application,
|
||||||
passing=passing,
|
result=result,
|
||||||
)
|
)
|
||||||
return passing
|
if not result.passing:
|
||||||
|
for message in result.messages:
|
||||||
|
messages.error(self.request, _(message))
|
||||||
|
return result.passing
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
|
||||||
if not request.user.is_authenticated:
|
class SAMLSSOView(LoginRequiredMixin, SAMLAccessMixin, View):
|
||||||
return self.handle_no_permission()
|
""""SAML SSO Base View, which plans a flow and injects our final stage.
|
||||||
|
Calls get/post handler."""
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self, request: HttpRequest, *args, application_slug: str, **kwargs
|
||||||
|
) -> HttpResponse:
|
||||||
|
self.application = get_object_or_404(Application, slug=application_slug)
|
||||||
|
self.provider: SAMLProvider = get_object_or_404(
|
||||||
|
SAMLProvider, pk=self.application.provider_id
|
||||||
|
)
|
||||||
if not self._has_access():
|
if not self._has_access():
|
||||||
return render(
|
raise PermissionDenied()
|
||||||
request,
|
# Call the method handler, which checks the SAML Request
|
||||||
"login/denied.html",
|
method_response = super().dispatch(request, *args, application_slug, **kwargs)
|
||||||
{"title": _("You don't have access to this application")},
|
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(
|
||||||
|
self.request,
|
||||||
|
{PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_APPLICATION: self.application},
|
||||||
|
)
|
||||||
|
plan.stages.append(in_memory_stage(SAMLFlowFinalView))
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginBeginView(AccessRequiredView):
|
class SAMLSSOBindingRedirectView(SAMLSSOView):
|
||||||
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and
|
"""SAML Handler for SSO/Redirect bindings, which are sent via GET"""
|
||||||
stores it in the session prior to enforcing login."""
|
|
||||||
|
|
||||||
def handler(self, source, application: str) -> HttpResponse:
|
# pylint: disable=unused-argument
|
||||||
"""Handle SAML Request whether its a POST or a Redirect binding"""
|
def get(
|
||||||
|
self, request: HttpRequest, application_slug: str
|
||||||
|
) -> Optional[HttpResponse]:
|
||||||
|
"""Handle REDIRECT bindings"""
|
||||||
# Store these values now, because Django's login cycle won't preserve them.
|
# Store these values now, because Django's login cycle won't preserve them.
|
||||||
try:
|
if SESSION_KEY_SAML_REQUEST not in request.GET:
|
||||||
self.request.session[SESSION_KEY_SAML_REQUEST] = source[
|
LOGGER.info("handle_saml_request: SAML payload missing")
|
||||||
SESSION_KEY_SAML_REQUEST
|
|
||||||
]
|
|
||||||
except (KeyError, MultiValueDictKeyError):
|
|
||||||
return bad_request_message(
|
return bad_request_message(
|
||||||
self.request, "The SAML request payload is missing."
|
self.request, "The SAML request payload is missing."
|
||||||
)
|
)
|
||||||
|
|
||||||
self.request.session[SESSION_KEY_RELAY_STATE] = source.get(
|
self.request.session[SESSION_KEY_SAML_REQUEST] = request.GET[
|
||||||
|
SESSION_KEY_SAML_REQUEST
|
||||||
|
]
|
||||||
|
self.request.session[SESSION_KEY_RELAY_STATE] = request.GET.get(
|
||||||
SESSION_KEY_RELAY_STATE, ""
|
SESSION_KEY_RELAY_STATE, ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -105,104 +126,89 @@ class LoginBeginView(AccessRequiredView):
|
||||||
self.request.session[SESSION_KEY_PARAMS] = params
|
self.request.session[SESSION_KEY_PARAMS] = params
|
||||||
except CannotHandleAssertion as exc:
|
except CannotHandleAssertion as exc:
|
||||||
LOGGER.info(exc)
|
LOGGER.info(exc)
|
||||||
did_you_mean_link = self.request.build_absolute_uri(
|
return bad_request_message(self.request, str(exc))
|
||||||
reverse(
|
return None
|
||||||
"passbook_providers_saml:saml-login-initiate",
|
|
||||||
kwargs={"application": application},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
did_you_mean_message = (
|
|
||||||
f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
|
|
||||||
)
|
|
||||||
return bad_request_message(
|
|
||||||
self.request, mark_safe(str(exc) + did_you_mean_message)
|
|
||||||
)
|
|
||||||
|
|
||||||
return redirect(
|
|
||||||
reverse(
|
|
||||||
"passbook_providers_saml:saml-login-authorize",
|
|
||||||
kwargs={"application": application},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt)
|
|
||||||
def dispatch(self, *args, **kwargs):
|
|
||||||
return super().dispatch(*args, **kwargs)
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt)
|
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Handle REDIRECT bindings"""
|
|
||||||
return self.handler(request.GET, application)
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt)
|
|
||||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Handle POST Bindings"""
|
|
||||||
return self.handler(request.POST, application)
|
|
||||||
|
|
||||||
|
|
||||||
class InitiateLoginView(AccessRequiredView):
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
"""IdP-initiated Login"""
|
class SAMLSSOBindingPOSTView(SAMLSSOView):
|
||||||
|
"""SAML Handler for SSO/POST bindings"""
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
|
|
||||||
self.provider.processor.is_idp_initiated = True
|
|
||||||
self.provider.processor.init_deep_link(request)
|
|
||||||
params = self.provider.processor.generate_response()
|
|
||||||
request.session[SESSION_KEY_PARAMS] = params
|
|
||||||
return redirect(
|
|
||||||
reverse(
|
|
||||||
"passbook_providers_saml:saml-login-authorize",
|
|
||||||
kwargs={"application": application},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorizeView(AccessRequiredView):
|
|
||||||
"""Ask the user for authorization to continue to the SP.
|
|
||||||
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
|
|
||||||
|
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Handle get request, i.e. render form"""
|
|
||||||
# User access gets checked in dispatch
|
|
||||||
|
|
||||||
# Otherwise we generate the IdP initiated session
|
|
||||||
try:
|
|
||||||
# application.skip_authorization is set so we directly redirect the user
|
|
||||||
if self.provider.application.skip_authorization:
|
|
||||||
LOGGER.debug("skipping authz", application=self.provider.application)
|
|
||||||
return self.post(request, application)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"saml/idp/login.html",
|
|
||||||
{"provider": self.provider, "title": "Authorize Application"},
|
|
||||||
)
|
|
||||||
|
|
||||||
except KeyError:
|
|
||||||
return bad_request_message(request, "Missing SAML Payload")
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
def post(
|
||||||
"""Handle post request, return back to ACS"""
|
self, request: HttpRequest, application_slug: str
|
||||||
# User access gets checked in dispatch
|
) -> Optional[HttpResponse]:
|
||||||
|
"""Handle POST bindings"""
|
||||||
|
# Store these values now, because Django's login cycle won't preserve them.
|
||||||
|
if SESSION_KEY_SAML_REQUEST not in request.POST:
|
||||||
|
LOGGER.info("handle_saml_request: SAML payload missing")
|
||||||
|
return bad_request_message(
|
||||||
|
self.request, "The SAML request payload is missing."
|
||||||
|
)
|
||||||
|
|
||||||
# we get here when skip_authorization is True, and after the user accepted
|
self.request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
|
||||||
# the authorization form
|
SESSION_KEY_SAML_REQUEST
|
||||||
|
]
|
||||||
|
self.request.session[SESSION_KEY_RELAY_STATE] = request.POST.get(
|
||||||
|
SESSION_KEY_RELAY_STATE, ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.provider.processor.can_handle(self.request)
|
||||||
|
params = self.provider.processor.generate_response()
|
||||||
|
self.request.session[SESSION_KEY_PARAMS] = params
|
||||||
|
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"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def get(
|
||||||
|
self, request: HttpRequest, application_slug: str
|
||||||
|
) -> Optional[HttpResponse]:
|
||||||
|
"""Create saml params from scratch"""
|
||||||
|
LOGGER.debug(
|
||||||
|
"handle_saml_no_request: No SAML Request, using IdP-initiated flow."
|
||||||
|
)
|
||||||
|
self.provider.processor.is_idp_initiated = True
|
||||||
|
self.provider.processor.init_deep_link(self.request)
|
||||||
|
params = self.provider.processor.generate_response()
|
||||||
|
self.request.session[SESSION_KEY_PARAMS] = params
|
||||||
|
|
||||||
|
|
||||||
|
# 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 = application.provider
|
||||||
# Log Application Authorization
|
# Log Application Authorization
|
||||||
Event.new(
|
Event.new(
|
||||||
EventAction.AUTHORIZE_APPLICATION,
|
EventAction.AUTHORIZE_APPLICATION,
|
||||||
authorized_application=self.provider.application,
|
authorized_application=application,
|
||||||
skipped_authorization=self.provider.application.skip_authorization,
|
flow=self.executor.plan.flow_pk,
|
||||||
).from_http(self.request)
|
).from_http(self.request)
|
||||||
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
|
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
|
||||||
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
|
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
|
||||||
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
|
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
|
||||||
|
if SESSION_KEY_PARAMS not in self.request.session:
|
||||||
|
return self.executor.stage_invalid()
|
||||||
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
|
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
|
||||||
|
|
||||||
|
if provider.sp_binding == SAMLBindings.POST:
|
||||||
return render(
|
return render(
|
||||||
self.request,
|
self.request,
|
||||||
"saml/idp/autosubmit_form.html",
|
"saml/idp/autosubmit_form.html",
|
||||||
{
|
{
|
||||||
"url": response.acs_url,
|
"url": response.acs_url,
|
||||||
|
"application": application,
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"ACSUrl": response.acs_url,
|
"ACSUrl": response.acs_url,
|
||||||
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
||||||
|
@ -210,77 +216,41 @@ class AuthorizeView(AccessRequiredView):
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if provider.sp_binding == SAMLBindings.REDIRECT:
|
||||||
|
querystring = urlencode(
|
||||||
|
{
|
||||||
|
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
||||||
|
SESSION_KEY_RELAY_STATE: response.relay_state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return redirect(f"{response.acs_url}?{querystring}")
|
||||||
|
return bad_request_message(request, "Invalid sp_binding specified")
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
class DescriptorDownloadView(LoginRequiredMixin, SAMLAccessMixin, View):
|
||||||
class LogoutView(AccessRequiredView):
|
|
||||||
"""Allows a non-SAML 2.0 URL to log out the user and
|
|
||||||
returns a standard logged-out page. (SalesForce and others use this method,
|
|
||||||
though it's technically not SAML 2.0)."""
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Perform logout"""
|
|
||||||
logout(request)
|
|
||||||
|
|
||||||
redirect_url = request.GET.get("redirect_to", "")
|
|
||||||
|
|
||||||
try:
|
|
||||||
URL_VALIDATOR(redirect_url)
|
|
||||||
except ValidationError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return redirect(redirect_url)
|
|
||||||
|
|
||||||
return render(request, "saml/idp/logged_out.html")
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
|
||||||
class SLOLogout(AccessRequiredView):
|
|
||||||
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
|
|
||||||
logs out the user and returns a standard logged-out page."""
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Perform logout"""
|
|
||||||
request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
|
|
||||||
SESSION_KEY_SAML_REQUEST
|
|
||||||
]
|
|
||||||
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
|
|
||||||
# TODO: Modify the base processor to handle logouts?
|
|
||||||
# TODO: Combine this with login_process(), since they are so very similar?
|
|
||||||
# TODO: Format a LogoutResponse and return it to the browser.
|
|
||||||
# XXX: For now, simply log out without validating the request.
|
|
||||||
logout(request)
|
|
||||||
return render(request, "saml/idp/logged_out.html")
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptorDownloadView(AccessRequiredView):
|
|
||||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
||||||
"""Return rendered XML Metadata"""
|
"""Return rendered XML Metadata"""
|
||||||
entity_id = provider.issuer
|
entity_id = provider.issuer
|
||||||
slo_url = request.build_absolute_uri(
|
saml_sso_binding_post = request.build_absolute_uri(
|
||||||
reverse(
|
reverse(
|
||||||
"passbook_providers_saml:saml-logout",
|
"passbook_providers_saml:sso-post",
|
||||||
kwargs={"application": provider.application.slug},
|
kwargs={"application_slug": provider.application.slug},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sso_post_url = request.build_absolute_uri(
|
saml_sso_binding_redirect = request.build_absolute_uri(
|
||||||
reverse(
|
reverse(
|
||||||
"passbook_providers_saml:saml-login",
|
"passbook_providers_saml:sso-redirect",
|
||||||
kwargs={"application": provider.application.slug},
|
kwargs={"application_slug": provider.application.slug},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
subject_format = provider.processor.subject_format
|
subject_format = provider.processor.subject_format
|
||||||
ctx = {
|
ctx = {
|
||||||
|
"saml_sso_binding_post": saml_sso_binding_post,
|
||||||
|
"saml_sso_binding_redirect": saml_sso_binding_redirect,
|
||||||
"entity_id": entity_id,
|
"entity_id": entity_id,
|
||||||
"slo_url": slo_url,
|
|
||||||
# Currently, the same endpoint accepts POST and REDIRECT
|
|
||||||
"sso_post_url": sso_post_url,
|
|
||||||
"sso_redirect_url": sso_post_url,
|
|
||||||
"subject_format": subject_format,
|
"subject_format": subject_format,
|
||||||
}
|
}
|
||||||
if provider.signing_kp:
|
if provider.signing_kp:
|
||||||
|
@ -289,9 +259,14 @@ class DescriptorDownloadView(AccessRequiredView):
|
||||||
).replace("\n", "")
|
).replace("\n", "")
|
||||||
return render_to_string("saml/xml/metadata.xml", ctx)
|
return render_to_string("saml/xml/metadata.xml", ctx)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||||
|
self.application = get_object_or_404(Application, slug=application_slug)
|
||||||
|
self.provider: SAMLProvider = get_object_or_404(
|
||||||
|
SAMLProvider, pk=self.application.provider_id
|
||||||
|
)
|
||||||
|
if not self._has_access():
|
||||||
|
raise PermissionDenied()
|
||||||
try:
|
try:
|
||||||
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
|
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
|
||||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||||
|
|
|
@ -97,6 +97,7 @@ INSTALLED_APPS = [
|
||||||
"passbook.sources.oauth.apps.PassbookSourceOAuthConfig",
|
"passbook.sources.oauth.apps.PassbookSourceOAuthConfig",
|
||||||
"passbook.sources.saml.apps.PassbookSourceSAMLConfig",
|
"passbook.sources.saml.apps.PassbookSourceSAMLConfig",
|
||||||
"passbook.stages.captcha.apps.PassbookStageCaptchaConfig",
|
"passbook.stages.captcha.apps.PassbookStageCaptchaConfig",
|
||||||
|
"passbook.stages.consent.apps.PassbookStageConsentConfig",
|
||||||
"passbook.stages.dummy.apps.PassbookStageDummyConfig",
|
"passbook.stages.dummy.apps.PassbookStageDummyConfig",
|
||||||
"passbook.stages.email.apps.PassbookStageEmailConfig",
|
"passbook.stages.email.apps.PassbookStageEmailConfig",
|
||||||
"passbook.stages.prompt.apps.PassbookStagPromptConfig",
|
"passbook.stages.prompt.apps.PassbookStagPromptConfig",
|
||||||
|
|
|
@ -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 = [
|
||||||
|
("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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -3,6 +3,7 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
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.models import OAuthSource
|
||||||
from passbook.sources.oauth.types.manager import MANAGER
|
from passbook.sources.oauth.types.manager import MANAGER
|
||||||
|
|
||||||
|
@ -10,6 +11,13 @@ from passbook.sources.oauth.types.manager import MANAGER
|
||||||
class OAuthSourceForm(forms.ModelForm):
|
class OAuthSourceForm(forms.ModelForm):
|
||||||
"""OAuthSource Form"""
|
"""OAuthSource Form"""
|
||||||
|
|
||||||
|
authentication_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHENTICATION)
|
||||||
|
)
|
||||||
|
enrollment_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.ENROLLMENT)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if hasattr(self.Meta, "overrides"):
|
if hasattr(self.Meta, "overrides"):
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""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},
|
||||||
|
)
|
||||||
|
)
|
|
@ -1,6 +1,9 @@
|
||||||
"""AzureAD OAuth2 Views"""
|
"""AzureAD OAuth2 Views"""
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||||
from passbook.sources.oauth.utils import user_get_or_create
|
from passbook.sources.oauth.utils import user_get_or_create
|
||||||
from passbook.sources.oauth.views.core import OAuthCallback
|
from passbook.sources.oauth.views.core import OAuthCallback
|
||||||
|
@ -10,10 +13,15 @@ from passbook.sources.oauth.views.core import OAuthCallback
|
||||||
class AzureADOAuthCallback(OAuthCallback):
|
class AzureADOAuthCallback(OAuthCallback):
|
||||||
"""AzureAD OAuth2 Callback"""
|
"""AzureAD OAuth2 Callback"""
|
||||||
|
|
||||||
def get_user_id(self, source, info):
|
def get_user_id(self, source: OAuthSource, info: Dict[str, Any]) -> str:
|
||||||
return uuid.UUID(info.get("objectId")).int
|
return str(uuid.UUID(info.get("objectId")).int)
|
||||||
|
|
||||||
def get_or_create_user(self, source, access, info):
|
def get_or_create_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> User:
|
||||||
user_data = {
|
user_data = {
|
||||||
"username": info.get("displayName"),
|
"username": info.get("displayName"),
|
||||||
"email": info.get("mail", None) or info.get("otherMails")[0],
|
"email": info.get("mail", None) or info.get("otherMails")[0],
|
||||||
|
|
|
@ -54,7 +54,9 @@ class SourceTypeManager:
|
||||||
return OAuthCallback
|
return OAuthCallback
|
||||||
if kind.value == RequestKind.redirect:
|
if kind.value == RequestKind.redirect:
|
||||||
return OAuthRedirect
|
return OAuthRedirect
|
||||||
raise KeyError
|
raise KeyError(
|
||||||
|
f"Provider Type {source.provider_type} (type {kind.value}) not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MANAGER = SourceTypeManager()
|
MANAGER = SourceTypeManager()
|
||||||
|
|
|
@ -21,8 +21,8 @@ class OpenIDConnectOAuthRedirect(OAuthRedirect):
|
||||||
class OpenIDConnectOAuth2Callback(OAuthCallback):
|
class OpenIDConnectOAuth2Callback(OAuthCallback):
|
||||||
"""OpenIDConnect OAuth2 Callback"""
|
"""OpenIDConnect OAuth2 Callback"""
|
||||||
|
|
||||||
def get_user_id(self, source: OAuthSource, info: Dict[str, str]):
|
def get_user_id(self, source: OAuthSource, info: Dict[str, str]) -> str:
|
||||||
return info.get("sub")
|
return info.get("sub", "")
|
||||||
|
|
||||||
def get_or_create_user(self, source: OAuthSource, access, info: Dict[str, str]):
|
def get_or_create_user(self, source: OAuthSource, access, info: Dict[str, str]):
|
||||||
user_data = {
|
user_data = {
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
"""Core OAauth Views"""
|
"""Core OAauth Views"""
|
||||||
from typing import Callable, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import Http404
|
from django.http import Http404, HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
@ -13,7 +13,8 @@ from django.views.generic import RedirectView, View
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.flows.models import Flow, FlowDesignation
|
from passbook.core.models import User
|
||||||
|
from passbook.flows.models import Flow
|
||||||
from passbook.flows.planner import (
|
from passbook.flows.planner import (
|
||||||
PLAN_CONTEXT_PENDING_USER,
|
PLAN_CONTEXT_PENDING_USER,
|
||||||
PLAN_CONTEXT_SSO,
|
PLAN_CONTEXT_SSO,
|
||||||
|
@ -49,18 +50,18 @@ class OAuthRedirect(OAuthClientMixin, RedirectView):
|
||||||
params = None
|
params = None
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_additional_parameters(self, source):
|
def get_additional_parameters(self, source: OAuthSource) -> Dict[str, Any]:
|
||||||
"Return additional redirect parameters for this source."
|
"Return additional redirect parameters for this source."
|
||||||
return self.params or {}
|
return self.params or {}
|
||||||
|
|
||||||
def get_callback_url(self, source):
|
def get_callback_url(self, source: OAuthSource) -> str:
|
||||||
"Return the callback url for this source."
|
"Return the callback url for this source."
|
||||||
return reverse(
|
return reverse(
|
||||||
"passbook_sources_oauth:oauth-client-callback",
|
"passbook_sources_oauth:oauth-client-callback",
|
||||||
kwargs={"source_slug": source.slug},
|
kwargs={"source_slug": source.slug},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_redirect_url(self, **kwargs):
|
def get_redirect_url(self, **kwargs) -> str:
|
||||||
"Build redirect url for a given source."
|
"Build redirect url for a given source."
|
||||||
slug = kwargs.get("source_slug", "")
|
slug = kwargs.get("source_slug", "")
|
||||||
try:
|
try:
|
||||||
|
@ -84,7 +85,7 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
source_id = None
|
source_id = None
|
||||||
source = None
|
source = None
|
||||||
|
|
||||||
def get(self, request, *_, **kwargs):
|
def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse:
|
||||||
"""View Get handler"""
|
"""View Get handler"""
|
||||||
slug = kwargs.get("source_slug", "")
|
slug = kwargs.get("source_slug", "")
|
||||||
try:
|
try:
|
||||||
|
@ -143,38 +144,38 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
return self.handle_existing_user(self.source, user, connection, info)
|
return self.handle_existing_user(self.source, user, connection, info)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_callback_url(self, source):
|
def get_callback_url(self, source: OAuthSource) -> str:
|
||||||
"Return callback url if different than the current url."
|
"Return callback url if different than the current url."
|
||||||
return False
|
return ""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_error_redirect(self, source, reason):
|
def get_error_redirect(self, source: OAuthSource, reason: str) -> str:
|
||||||
"Return url to redirect on login failure."
|
"Return url to redirect on login failure."
|
||||||
return settings.LOGIN_URL
|
return settings.LOGIN_URL
|
||||||
|
|
||||||
def get_or_create_user(self, source, access, info):
|
def get_or_create_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> User:
|
||||||
"Create a shell auth.User."
|
"Create a shell auth.User."
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_user_id(self, source, info):
|
def get_user_id(
|
||||||
"Return unique identifier from the profile info."
|
self, source: UserOAuthSourceConnection, info: Dict[str, Any]
|
||||||
id_key = self.source_id or "id"
|
) -> Optional[str]:
|
||||||
result = info
|
"""Return unique identifier from the profile info."""
|
||||||
try:
|
if "id" in info:
|
||||||
for key in id_key.split("."):
|
return info["id"]
|
||||||
result = result[key]
|
|
||||||
return result
|
|
||||||
except KeyError:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def handle_login(self, user, source, access):
|
def handle_login_flow(self, flow: Optional[Flow], user: User) -> HttpResponse:
|
||||||
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
||||||
user = authenticate(
|
if not flow:
|
||||||
source=access.source, identifier=access.identifier, request=self.request
|
raise Http404
|
||||||
)
|
|
||||||
# We run the Flow planner here so we can pass the Pending user in the context
|
# We run the Flow planner here so we can pass the Pending user in the context
|
||||||
flow = get_object_or_404(Flow, designation=FlowDesignation.AUTHENTICATION)
|
|
||||||
planner = FlowPlanner(flow)
|
planner = FlowPlanner(flow)
|
||||||
plan = planner.plan(
|
plan = planner.plan(
|
||||||
self.request,
|
self.request,
|
||||||
|
@ -186,11 +187,17 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
)
|
)
|
||||||
self.request.session[SESSION_KEY_PLAN] = plan
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
return redirect_with_qs(
|
return redirect_with_qs(
|
||||||
"passbook_flows:flow-executor", self.request.GET, flow_slug=flow.slug,
|
"passbook_flows:flow-executor-shell", self.request.GET, flow_slug=flow.slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def handle_existing_user(self, source, user, access, info):
|
def handle_existing_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
user: User,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> HttpResponse:
|
||||||
"Login user and redirect."
|
"Login user and redirect."
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
|
@ -199,15 +206,23 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
% {"source": self.source.name}
|
% {"source": self.source.name}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return self.handle_login(user, source, access)
|
user = authenticate(
|
||||||
|
source=access.source, identifier=access.identifier, request=self.request
|
||||||
|
)
|
||||||
|
return self.handle_login_flow(source.authentication_flow, user)
|
||||||
|
|
||||||
def handle_login_failure(self, source, reason):
|
def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse:
|
||||||
"Message user and redirect on error."
|
"Message user and redirect on error."
|
||||||
LOGGER.warning("Authentication Failure", reason=reason)
|
LOGGER.warning("Authentication Failure", reason=reason)
|
||||||
messages.error(self.request, _("Authentication Failed."))
|
messages.error(self.request, _("Authentication Failed."))
|
||||||
return redirect(self.get_error_redirect(source, reason))
|
return redirect(self.get_error_redirect(source, reason))
|
||||||
|
|
||||||
def handle_new_user(self, source, access, info):
|
def handle_new_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> HttpResponse:
|
||||||
"Create a shell auth.User and redirect."
|
"Create a shell auth.User and redirect."
|
||||||
was_authenticated = False
|
was_authenticated = False
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
|
@ -244,7 +259,7 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
% {"source": self.source.name}
|
% {"source": self.source.name}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return self.handle_login(user, source, access)
|
return self.handle_login_flow(source.enrollment_flow, user)
|
||||||
|
|
||||||
|
|
||||||
class DisconnectView(LoginRequiredMixin, View):
|
class DisconnectView(LoginRequiredMixin, View):
|
||||||
|
|
|
@ -28,3 +28,4 @@ class SAMLSourceForm(forms.ModelForm):
|
||||||
"idp_url": forms.TextInput(),
|
"idp_url": forms.TextInput(),
|
||||||
"idp_logout_url": forms.TextInput(),
|
"idp_logout_url": forms.TextInput(),
|
||||||
}
|
}
|
||||||
|
labels = {"signing_kp": _("Singing Keypair")}
|
||||||
|
|
|
@ -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 = [
|
||||||
|
("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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -8,6 +8,13 @@ from passbook.core.types import UILoginButton
|
||||||
from passbook.crypto.models import CertificateKeyPair
|
from passbook.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
|
|
||||||
|
class SAMLBindingTypes(models.TextChoices):
|
||||||
|
"""SAML Binding types"""
|
||||||
|
|
||||||
|
Redirect = "REDIRECT"
|
||||||
|
POST = "POST"
|
||||||
|
|
||||||
|
|
||||||
class SAMLSource(Source):
|
class SAMLSource(Source):
|
||||||
"""SAML Source"""
|
"""SAML Source"""
|
||||||
|
|
||||||
|
@ -18,7 +25,18 @@ class SAMLSource(Source):
|
||||||
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
||||||
)
|
)
|
||||||
|
|
||||||
idp_url = models.URLField(verbose_name=_("IDP URL"))
|
idp_url = models.URLField(
|
||||||
|
verbose_name=_("IDP URL"),
|
||||||
|
help_text=_(
|
||||||
|
"URL that the initial SAML Request is sent to. Also known as a Binding."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
binding_type = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
choices=SAMLBindingTypes.choices,
|
||||||
|
default=SAMLBindingTypes.Redirect,
|
||||||
|
)
|
||||||
|
|
||||||
idp_logout_url = models.URLField(
|
idp_logout_url = models.URLField(
|
||||||
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
|
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,21 +2,31 @@
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from defusedxml import ElementTree
|
from defusedxml import ElementTree
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest, HttpResponse
|
||||||
from signxml import XMLVerifier
|
from signxml import XMLVerifier
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
|
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.providers.saml.utils.encoding import decode_base64_and_inflate
|
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||||
from passbook.sources.saml.exceptions import (
|
from passbook.sources.saml.exceptions import (
|
||||||
MissingSAMLResponse,
|
MissingSAMLResponse,
|
||||||
UnsupportedNameIDFormat,
|
UnsupportedNameIDFormat,
|
||||||
)
|
)
|
||||||
from passbook.sources.saml.models import SAMLSource
|
from passbook.sources.saml.models import SAMLSource
|
||||||
|
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||||
|
from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from xml.etree.ElementTree import Element # nosec
|
from xml.etree.ElementTree import Element # nosec
|
||||||
|
DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend"
|
||||||
|
|
||||||
|
|
||||||
class Processor:
|
class Processor:
|
||||||
|
@ -46,7 +56,9 @@ class Processor:
|
||||||
def _verify_signed(self):
|
def _verify_signed(self):
|
||||||
"""Verify SAML Response's Signature"""
|
"""Verify SAML Response's Signature"""
|
||||||
verifier = XMLVerifier()
|
verifier = XMLVerifier()
|
||||||
verifier.verify(self._root_xml, x509_cert=self._source.signing_kp.certificate)
|
verifier.verify(
|
||||||
|
self._root_xml, x509_cert=self._source.signing_kp.certificate_data
|
||||||
|
)
|
||||||
|
|
||||||
def _get_email(self) -> Optional[str]:
|
def _get_email(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
@ -69,18 +81,32 @@ class Processor:
|
||||||
)
|
)
|
||||||
return name_id.text
|
return name_id.text
|
||||||
|
|
||||||
def get_user(self) -> User:
|
def prepare_flow(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""
|
"""Prepare flow plan depending on whether or not the user exists"""
|
||||||
Gets info out of the response and locally logs in this user.
|
|
||||||
May create a local user account first.
|
|
||||||
Returns the user object that was created.
|
|
||||||
"""
|
|
||||||
email = self._get_email()
|
email = self._get_email()
|
||||||
try:
|
matching_users = User.objects.filter(email=email)
|
||||||
user = User.objects.get(email=email)
|
if matching_users.exists():
|
||||||
except User.DoesNotExist:
|
# User exists already, switch to authentication flow
|
||||||
user = User.objects.create_user(username=email, email=email)
|
flow = self._source.authentication_flow
|
||||||
# TODO: Property Mappings
|
request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(
|
||||||
user.set_unusable_password()
|
request,
|
||||||
user.save()
|
{
|
||||||
return user
|
# Data for authentication
|
||||||
|
PLAN_CONTEXT_PENDING_USER: matching_users.first(),
|
||||||
|
PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND,
|
||||||
|
PLAN_CONTEXT_SSO: True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flow = self._source.enrollment_flow
|
||||||
|
request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
# Data for enrollment
|
||||||
|
PLAN_CONTEXT_PROMPT: {"username": email, "email": email},
|
||||||
|
PLAN_CONTEXT_SSO: True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return redirect_with_qs(
|
||||||
|
"passbook_flows:flow-executor-shell", request.GET, flow_slug=flow.slug,
|
||||||
|
)
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
"""saml sp views"""
|
"""saml sp views"""
|
||||||
from django.contrib.auth import login, logout
|
from django.contrib.auth import logout
|
||||||
from django.http import Http404, HttpRequest, HttpResponse
|
from django.http import Http404, HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.utils.http import urlencode
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from signxml.util import strip_pem_header
|
from signxml.util import strip_pem_header
|
||||||
|
@ -15,7 +16,7 @@ from passbook.sources.saml.exceptions import (
|
||||||
MissingSAMLResponse,
|
MissingSAMLResponse,
|
||||||
UnsupportedNameIDFormat,
|
UnsupportedNameIDFormat,
|
||||||
)
|
)
|
||||||
from passbook.sources.saml.models import SAMLSource
|
from passbook.sources.saml.models import SAMLBindingTypes, SAMLSource
|
||||||
from passbook.sources.saml.processors.base import Processor
|
from passbook.sources.saml.processors.base import Processor
|
||||||
from passbook.sources.saml.utils import build_full_url, get_issuer
|
from passbook.sources.saml.utils import build_full_url, get_issuer
|
||||||
from passbook.sources.saml.xml_render import get_authnrequest_xml
|
from passbook.sources.saml.xml_render import get_authnrequest_xml
|
||||||
|
@ -40,6 +41,9 @@ class InitiateView(View):
|
||||||
}
|
}
|
||||||
authn_req = get_authnrequest_xml(parameters, signed=False)
|
authn_req = get_authnrequest_xml(parameters, signed=False)
|
||||||
_request = nice64(str.encode(authn_req))
|
_request = nice64(str.encode(authn_req))
|
||||||
|
if source.binding_type == SAMLBindingTypes.Redirect:
|
||||||
|
return redirect(source.idp_url + "?" + urlencode({"SAMLRequest": _request}))
|
||||||
|
if source.binding_type == SAMLBindingTypes.POST:
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"saml/sp/login.html",
|
"saml/sp/login.html",
|
||||||
|
@ -50,6 +54,7 @@ class InitiateView(View):
|
||||||
"source": source,
|
"source": source,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
@ -68,9 +73,7 @@ class ACSView(View):
|
||||||
return bad_request_message(request, str(exc))
|
return bad_request_message(request, str(exc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = processor.get_user()
|
return processor.prepare_flow(request)
|
||||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
|
||||||
return redirect(reverse("passbook_core:overview"))
|
|
||||||
except UnsupportedNameIDFormat as exc:
|
except UnsupportedNameIDFormat as exc:
|
||||||
return bad_request_message(request, str(exc))
|
return bad_request_message(request, str(exc))
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -44,5 +45,8 @@ class TestCaptchaStage(TestCase):
|
||||||
),
|
),
|
||||||
{"g-recaptcha-response": "PASSED"},
|
{"g-recaptcha-response": "PASSED"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""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"]
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentStageViewSet(ModelViewSet):
|
||||||
|
"""ConsentStage Viewset"""
|
||||||
|
|
||||||
|
queryset = ConsentStage.objects.all()
|
||||||
|
serializer_class = ConsentStageSerializer
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""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"
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""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"]
|
||||||
|
widgets = {
|
||||||
|
"name": forms.TextInput(),
|
||||||
|
}
|
|
@ -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 = [
|
||||||
|
("passbook_flows", "0004_source_flows"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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",),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""passbook consent stage"""
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from passbook.flows.models import Stage
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentStage(Stage):
|
||||||
|
"""Consent Stage instance"""
|
||||||
|
|
||||||
|
type = "passbook.stages.consent.stage.ConsentStage"
|
||||||
|
form = "passbook.stages.consent.forms.ConsentStageForm"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Consent Stage {self.name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
verbose_name = _("Consent Stage")
|
||||||
|
verbose_name_plural = _("Consent Stages")
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""passbook consent stage"""
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from django.views.generic import FormView
|
||||||
|
|
||||||
|
from passbook.flows.stage import StageView
|
||||||
|
from passbook.lib.utils.template import render_to_string
|
||||||
|
from passbook.stages.consent.forms import ConsentForm
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentStage(FormView, StageView):
|
||||||
|
"""Simple consent checker."""
|
||||||
|
|
||||||
|
body_template_name: str
|
||||||
|
|
||||||
|
form_class = ConsentForm
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
if self.body_template_name:
|
||||||
|
kwargs["body"] = render_to_string(self.body_template_name, kwargs)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
return self.executor.stage_ok()
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""consent tests"""
|
||||||
|
from django.shortcuts import reverse
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
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.consent.models import ConsentStage
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsentStage(TestCase):
|
||||||
|
"""Consent 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-consent",
|
||||||
|
slug="test-consent",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
self.stage = ConsentStage.objects.create(name="consent",)
|
||||||
|
FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
|
||||||
|
|
||||||
|
def test_valid(self):
|
||||||
|
"""Test valid consent"""
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
|
||||||
|
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_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
|
@ -1,6 +1,7 @@
|
||||||
"""dummy tests"""
|
"""dummy tests"""
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -41,8 +42,11 @@ class TestDummyStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
response = self.client.post(url, {})
|
response = self.client.post(url, {})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_form(self):
|
def test_form(self):
|
||||||
"""Test Form"""
|
"""Test Form"""
|
||||||
|
|
|
@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import Token, User
|
from passbook.core.models import Token, User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -93,8 +94,12 @@ class TestEmailStage(TestCase):
|
||||||
token = Token.objects.get(user=self.user)
|
token = Token.objects.get(user=self.user)
|
||||||
url += f"?{QS_KEY_TOKEN}={token.pk.hex}"
|
url += f"?{QS_KEY_TOKEN}={token.pk.hex}"
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
session = self.client.session
|
session = self.client.session
|
||||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""identification tests"""
|
"""identification tests"""
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -53,8 +54,11 @@ class TestIdentificationStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
response = self.client.post(url, form_data)
|
response = self.client.post(url, form_data)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_invalid_with_username(self):
|
def test_invalid_with_username(self):
|
||||||
"""Test invalid with username (user exists but stage only allows e-mail)"""
|
"""Test invalid with username (user exists but stage only allows e-mail)"""
|
||||||
|
@ -97,7 +101,7 @@ class TestIdentificationStage(TestCase):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.slug, response.rendered_content)
|
self.assertIn(flow.slug, force_text(response.content))
|
||||||
|
|
||||||
def test_recovery_flow(self):
|
def test_recovery_flow(self):
|
||||||
"""Test that recovery flow is linked correctly"""
|
"""Test that recovery flow is linked correctly"""
|
||||||
|
@ -118,4 +122,4 @@ class TestIdentificationStage(TestCase):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.slug, response.rendered_content)
|
self.assertIn(flow.slug, force_text(response.content))
|
||||||
|
|
|
@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
|
@ -52,8 +53,12 @@ class TestUserLoginStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_flows:denied"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_without_invitation_continue(self):
|
def test_without_invitation_continue(self):
|
||||||
"""Test without any invitation, continue_flow_without_invitation is set."""
|
"""Test without any invitation, continue_flow_without_invitation is set."""
|
||||||
|
@ -73,8 +78,13 @@ class TestUserLoginStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
self.stage.continue_flow_without_invitation = False
|
self.stage.continue_flow_without_invitation = False
|
||||||
self.stage.save()
|
self.stage.save()
|
||||||
|
|
||||||
|
@ -106,5 +116,8 @@ class TestUserLoginStage(TestCase):
|
||||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||||
self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data)
|
self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
|
@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -54,8 +55,12 @@ class TestPasswordStage(TestCase):
|
||||||
# Still have to send the password so the form is valid
|
# Still have to send the password so the form is valid
|
||||||
{"password": self.password},
|
{"password": self.password},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_flows:denied"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_recovery_flow_link(self):
|
def test_recovery_flow_link(self):
|
||||||
"""Test link to the default recovery flow"""
|
"""Test link to the default recovery flow"""
|
||||||
|
@ -74,7 +79,7 @@ class TestPasswordStage(TestCase):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(flow.slug, response.rendered_content)
|
self.assertIn(flow.slug, force_text(response.content))
|
||||||
|
|
||||||
def test_valid_password(self):
|
def test_valid_password(self):
|
||||||
"""Test with a valid pending user and valid password"""
|
"""Test with a valid pending user and valid password"""
|
||||||
|
@ -91,8 +96,12 @@ class TestPasswordStage(TestCase):
|
||||||
# Form data
|
# Form data
|
||||||
{"password": self.password},
|
{"password": self.password},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_invalid_password(self):
|
def test_invalid_password(self):
|
||||||
"""Test with a valid pending user and invalid password"""
|
"""Test with a valid pending user and invalid password"""
|
||||||
|
@ -131,5 +140,9 @@ class TestPasswordStage(TestCase):
|
||||||
# Form data
|
# Form data
|
||||||
{"password": self.password + "test"},
|
{"password": self.password + "test"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_flows:denied"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||||
|
)
|
||||||
|
|
|
@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -107,9 +108,9 @@ class TestPromptStage(TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
for prompt in self.stage.fields.all():
|
for prompt in self.stage.fields.all():
|
||||||
self.assertIn(prompt.field_key, response.rendered_content)
|
self.assertIn(prompt.field_key, force_text(response.content))
|
||||||
self.assertIn(prompt.label, response.rendered_content)
|
self.assertIn(prompt.label, force_text(response.content))
|
||||||
self.assertIn(prompt.placeholder, response.rendered_content)
|
self.assertIn(prompt.placeholder, force_text(response.content))
|
||||||
|
|
||||||
def test_valid_form_with_policy(self) -> PromptForm:
|
def test_valid_form_with_policy(self) -> PromptForm:
|
||||||
"""Test form validation"""
|
"""Test form validation"""
|
||||||
|
@ -151,8 +152,11 @@ class TestPromptStage(TestCase):
|
||||||
),
|
),
|
||||||
form.cleaned_data,
|
form.cleaned_data,
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
# Check that valid data has been saved
|
# Check that valid data has been saved
|
||||||
session = self.client.session
|
session = self.client.session
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""delete tests"""
|
"""delete tests"""
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -38,8 +39,11 @@ class TestUserDeleteStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.url, reverse("passbook_flows:denied"))
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_user_delete_get(self):
|
def test_user_delete_get(self):
|
||||||
"""Test Form render"""
|
"""Test Form render"""
|
||||||
|
@ -70,5 +74,10 @@ class TestUserDeleteStage(TestCase):
|
||||||
),
|
),
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
self.assertFalse(User.objects.filter(username=self.username).exists())
|
self.assertFalse(User.objects.filter(username=self.username).exists())
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""login tests"""
|
"""login tests"""
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -43,8 +44,12 @@ class TestUserLoginStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_without_user(self):
|
def test_without_user(self):
|
||||||
"""Test a plan without any pending user, resulting in a denied"""
|
"""Test a plan without any pending user, resulting in a denied"""
|
||||||
|
@ -58,8 +63,12 @@ class TestUserLoginStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_flows:denied"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_without_backend(self):
|
def test_without_backend(self):
|
||||||
"""Test a plan with pending user, without backend, resulting in a denied"""
|
"""Test a plan with pending user, without backend, resulting in a denied"""
|
||||||
|
@ -74,8 +83,12 @@ class TestUserLoginStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_flows:denied"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_form(self):
|
def test_form(self):
|
||||||
"""Test Form"""
|
"""Test Form"""
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""logout tests"""
|
"""logout tests"""
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -43,8 +44,12 @@ class TestUserLogoutStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_form(self):
|
def test_form(self):
|
||||||
"""Test Form"""
|
"""Test Form"""
|
||||||
|
|
|
@ -4,6 +4,7 @@ from random import SystemRandom
|
||||||
|
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
from passbook.core.models import User
|
from passbook.core.models import User
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
|
@ -52,7 +53,12 @@ class TestUserWriteStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
user_qs = User.objects.filter(
|
user_qs = User.objects.filter(
|
||||||
username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
|
username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
|
||||||
)
|
)
|
||||||
|
@ -83,7 +89,12 @@ class TestUserWriteStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||||
|
)
|
||||||
user_qs = User.objects.filter(
|
user_qs = User.objects.filter(
|
||||||
username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
|
username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
|
||||||
)
|
)
|
||||||
|
@ -103,8 +114,12 @@ class TestUserWriteStage(TestCase):
|
||||||
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.url, reverse("passbook_flows:denied"))
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
force_text(response.content),
|
||||||
|
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||||
|
)
|
||||||
|
|
||||||
def test_form(self):
|
def test_form(self):
|
||||||
"""Test Form"""
|
"""Test Form"""
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
[tool.black]
|
||||||
|
target-version = ['py37']
|
47
swagger.yaml
47
swagger.yaml
|
@ -4875,9 +4875,6 @@ definitions:
|
||||||
pattern: ^[-a-zA-Z0-9_]+$
|
pattern: ^[-a-zA-Z0-9_]+$
|
||||||
maxLength: 50
|
maxLength: 50
|
||||||
minLength: 1
|
minLength: 1
|
||||||
skip_authorization:
|
|
||||||
title: Skip authorization
|
|
||||||
type: boolean
|
|
||||||
provider:
|
provider:
|
||||||
title: Provider
|
title: Provider
|
||||||
type: integer
|
type: integer
|
||||||
|
@ -5048,6 +5045,7 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- authentication
|
- authentication
|
||||||
|
- authorization
|
||||||
- invalidation
|
- invalidation
|
||||||
- enrollment
|
- enrollment
|
||||||
- unenrollment
|
- unenrollment
|
||||||
|
@ -5335,12 +5333,19 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
Provider:
|
Provider:
|
||||||
|
required:
|
||||||
|
- authorization_flow
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
pk:
|
pk:
|
||||||
title: ID
|
title: ID
|
||||||
type: integer
|
type: integer
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
authorization_flow:
|
||||||
|
title: Authorization flow
|
||||||
|
description: Flow used when authorizing this provider.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
property_mappings:
|
property_mappings:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
@ -5594,6 +5599,18 @@ definitions:
|
||||||
enabled:
|
enabled:
|
||||||
title: Enabled
|
title: Enabled
|
||||||
type: boolean
|
type: boolean
|
||||||
|
authentication_flow:
|
||||||
|
title: Authentication flow
|
||||||
|
description: Flow to use when authenticating existing users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
|
enrollment_flow:
|
||||||
|
title: Enrollment flow
|
||||||
|
description: Flow to use when enrolling new users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
__type__:
|
__type__:
|
||||||
title: 'type '
|
title: 'type '
|
||||||
type: string
|
type: string
|
||||||
|
@ -5629,6 +5646,18 @@ definitions:
|
||||||
enabled:
|
enabled:
|
||||||
title: Enabled
|
title: Enabled
|
||||||
type: boolean
|
type: boolean
|
||||||
|
authentication_flow:
|
||||||
|
title: Authentication flow
|
||||||
|
description: Flow to use when authenticating existing users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
|
enrollment_flow:
|
||||||
|
title: Enrollment flow
|
||||||
|
description: Flow to use when enrolling new users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
server_uri:
|
server_uri:
|
||||||
title: Server URI
|
title: Server URI
|
||||||
type: string
|
type: string
|
||||||
|
@ -5726,6 +5755,18 @@ definitions:
|
||||||
enabled:
|
enabled:
|
||||||
title: Enabled
|
title: Enabled
|
||||||
type: boolean
|
type: boolean
|
||||||
|
authentication_flow:
|
||||||
|
title: Authentication flow
|
||||||
|
description: Flow to use when authenticating existing users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
|
enrollment_flow:
|
||||||
|
title: Enrollment flow
|
||||||
|
description: Flow to use when enrolling new users.
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
x-nullable: true
|
||||||
provider_type:
|
provider_type:
|
||||||
title: Provider type
|
title: Provider type
|
||||||
type: string
|
type: string
|
||||||
|
|
Reference in New Issue