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:
Jens L 2020-06-07 16:35:08 +02:00 committed by GitHub
parent f91e02a0ec
commit 4915205678
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 1609 additions and 529 deletions

View File

@ -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",
]

View File

@ -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>

View File

@ -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 %}

View File

@ -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",

View File

@ -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):

View File

@ -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",

View File

@ -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",
),
),
]

View File

@ -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 = [

View File

@ -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()

View File

@ -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>

View File

@ -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 %}

View File

@ -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

View File

@ -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)

View File

@ -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",
), ),
] ]
) )

View File

@ -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,
),
),
]

View File

@ -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),
]

View File

@ -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)]

View File

@ -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

View File

@ -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

View File

@ -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 %}

View File

@ -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

View File

@ -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}"

View File

@ -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(),

View File

@ -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()

View File

@ -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"),
}

View File

@ -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"

View File

@ -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",

View File

@ -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 = [

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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",

View File

@ -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")
), ),
}, },
) )

View File

@ -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()

View File

@ -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",
),
]

View File

@ -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

View File

@ -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",

View File

@ -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:

View File

@ -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"
),
),
]

View File

@ -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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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",
), ),
] ]

View File

@ -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

View File

@ -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",

View File

@ -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",
),
),
]

View File

@ -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"):

View File

@ -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},
)
)

View File

@ -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],

View File

@ -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()

View File

@ -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 = {

View File

@ -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):

View File

@ -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")}

View File

@ -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",
),
),
]

View File

@ -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")
) )

View File

@ -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,
)

View File

@ -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))

View File

@ -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")},
)

View File

View File

@ -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

View File

@ -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"

View File

@ -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(),
}

View File

@ -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",),
),
]

View File

@ -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")

View File

@ -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()

View File

@ -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")},
)

View File

@ -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"""

View File

@ -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]

View File

@ -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))

View File

@ -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")},
)

View File

@ -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")},
)

View File

@ -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

View File

@ -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())

View File

@ -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"""

View File

@ -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"""

View File

@ -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"""

2
pyproject.toml Normal file
View File

@ -0,0 +1,2 @@
[tool.black]
target-version = ['py37']

View File

@ -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