Revert "*: providers and sources -> channels, PolicyModel to PolicyBindingModel that uses custom M2M through"

This reverts commit 7ed3ceb960.
This commit is contained in:
Jens Langhammer
2020-05-16 16:02:42 +02:00
parent 7ed3ceb960
commit 406f69080b
293 changed files with 4692 additions and 3244 deletions

View File

View File

View File

@ -0,0 +1,45 @@
"""ApplicationGatewayProvider API Views"""
from oauth2_provider.generators import generate_client_id, generate_client_secret
from oidc_provider.models import Client
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.providers.app_gw.models import ApplicationGatewayProvider
from passbook.providers.oidc.api import OpenIDProviderSerializer
class ApplicationGatewayProviderSerializer(ModelSerializer):
"""ApplicationGatewayProvider Serializer"""
client = OpenIDProviderSerializer()
def create(self, validated_data):
instance = super().create(validated_data)
instance.client = Client.objects.create(
client_id=generate_client_id(), client_secret=generate_client_secret()
)
instance.save()
return instance
def update(self, instance, validated_data):
self.instance.client.name = self.instance.name
self.instance.client.redirect_uris = [
f"http://{self.instance.host}/oauth2/callback",
f"https://{self.instance.host}/oauth2/callback",
]
self.instance.client.scope = ["openid", "email"]
self.instance.client.save()
return super().update(instance, validated_data)
class Meta:
model = ApplicationGatewayProvider
fields = ["pk", "name", "internal_host", "external_host", "client"]
read_only_fields = ["client"]
class ApplicationGatewayProviderViewSet(ModelViewSet):
"""ApplicationGatewayProvider Viewset"""
queryset = ApplicationGatewayProvider.objects.all()
serializer_class = ApplicationGatewayProviderSerializer

View File

@ -0,0 +1,11 @@
"""passbook Application Security Gateway app"""
from django.apps import AppConfig
class PassbookApplicationApplicationGatewayConfig(AppConfig):
"""passbook app_gw app"""
name = "passbook.providers.app_gw"
label = "passbook_providers_app_gw"
verbose_name = "passbook Providers.Application Security Gateway"
mountpoint = "application/gateway/"

View File

@ -0,0 +1,40 @@
"""passbook Application Security Gateway Forms"""
from django import forms
from oauth2_provider.generators import generate_client_id, generate_client_secret
from oidc_provider.models import Client, ResponseType
from passbook.providers.app_gw.models import ApplicationGatewayProvider
class ApplicationGatewayProviderForm(forms.ModelForm):
"""Security Gateway Provider form"""
def save(self, *args, **kwargs):
if not self.instance.pk:
# New instance, so we create a new OIDC client with random keys
self.instance.client = Client.objects.create(
client_id=generate_client_id(), client_secret=generate_client_secret()
)
self.instance.client.name = self.instance.name
self.instance.client.response_types.set(
[ResponseType.objects.get_by_natural_key("code")]
)
self.instance.client.redirect_uris = [
f"http://{self.instance.external_host}/oauth2/callback",
f"https://{self.instance.external_host}/oauth2/callback",
f"http://{self.instance.internal_host}/oauth2/callback",
f"https://{self.instance.internal_host}/oauth2/callback",
]
self.instance.client.scope = ["openid", "email"]
self.instance.client.save()
return super().save(*args, **kwargs)
class Meta:
model = ApplicationGatewayProvider
fields = ["name", "internal_host", "external_host"]
widgets = {
"name": forms.TextInput(),
"internal_host": forms.TextInput(),
"external_host": forms.TextInput(),
}

View File

@ -0,0 +1,99 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="ApplicationGatewayProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.Provider",
),
),
(
"server_name",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), size=None
),
),
(
"upstream",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), size=None
),
),
("enabled", models.BooleanField(default=True)),
(
"authentication_header",
models.TextField(blank=True, default="X-Remote-User"),
),
(
"default_content_type",
models.TextField(default="application/octet-stream"),
),
("upstream_ssl_verification", models.BooleanField(default=True)),
],
options={
"verbose_name": "Application Gateway Provider",
"verbose_name_plural": "Application Gateway Providers",
},
bases=("passbook_core.provider",),
),
migrations.CreateModel(
name="RewriteRule",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.PropertyMapping",
),
),
("match", models.TextField()),
("halt", models.BooleanField(default=False)),
("replacement", models.TextField()),
(
"redirect",
models.CharField(
choices=[
("internal", "Internal"),
(301, "Moved Permanently"),
(302, "Found"),
],
max_length=50,
),
),
(
"conditions",
models.ManyToManyField(blank=True, to="passbook_core.Policy"),
),
],
options={
"verbose_name": "Rewrite Rule",
"verbose_name_plural": "Rewrite Rules",
},
bases=("passbook_core.propertymapping",),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2019-11-11 17:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0005_merge_20191025_2022"),
("passbook_providers_app_gw", "0001_initial"),
]
operations = [
migrations.RemoveField(model_name="rewriterule", name="conditions",),
migrations.RemoveField(model_name="rewriterule", name="propertymapping_ptr",),
migrations.DeleteModel(name="ApplicationGatewayProvider",),
migrations.DeleteModel(name="RewriteRule",),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 2.2.7 on 2019-11-11 17:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0005_merge_20191025_2022"),
("oidc_provider", "0026_client_multiple_response_types"),
("passbook_providers_app_gw", "0002_auto_20191111_1703"),
]
operations = [
migrations.CreateModel(
name="ApplicationGatewayProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.Provider",
),
),
("name", models.TextField()),
("host", models.TextField()),
(
"client",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="oidc_provider.Client",
),
),
],
options={
"verbose_name": "Application Gateway Provider",
"verbose_name_plural": "Application Gateway Providers",
},
bases=("passbook_core.provider",),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.9 on 2020-01-02 15:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_app_gw", "0003_applicationgatewayprovider"),
]
operations = [
migrations.RenameField(
model_name="applicationgatewayprovider",
old_name="host",
new_name="external_host",
),
migrations.AddField(
model_name="applicationgatewayprovider",
name="internal_host",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -0,0 +1,44 @@
"""passbook app_gw models"""
import string
from random import SystemRandom
from typing import Optional
from django.db import models
from django.http import HttpRequest
from django.utils.translation import gettext as _
from oidc_provider.models import Client
from passbook import __version__
from passbook.core.models import Provider
from passbook.lib.utils.template import render_to_string
class ApplicationGatewayProvider(Provider):
"""This provider uses oauth2_proxy with the OIDC Provider."""
name = models.TextField()
internal_host = models.TextField()
external_host = models.TextField()
client = models.ForeignKey(Client, on_delete=models.CASCADE)
form = "passbook.providers.app_gw.forms.ApplicationGatewayProviderForm"
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
cookie_secret = "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(50)
)
return render_to_string(
"app_gw/setup_modal.html",
{"provider": self, "cookie_secret": cookie_secret, "version": __version__},
)
def __str__(self):
return f"Application Gateway {self.name}"
class Meta:
verbose_name = _("Application Gateway Provider")
verbose_name_plural = _("Application Gateway Providers")

View File

@ -0,0 +1,15 @@
version: "3.5"
services:
passbook_gatekeeper:
container_name: gatekeeper
image: beryju/passbook-gatekeeper:{{ version }}
ports:
- 4180:4180
environment:
OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }}
OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }}
OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.external_host }}/oauth2/callback
OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.HTTP_HOST }}/application/oidc
OAUTH2_PROXY_COOKIE_SECRET: {{ cookie_secret }}
OAUTH2_PROXY_UPSTREAMS: http://{{ provider.internal_host }}

View File

@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
k8s-app: passbook-gatekeeper
name: passbook-gatekeeper
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
k8s-app: passbook-gatekeeper
template:
metadata:
labels:
k8s-app: passbook-gatekeeper
spec:
containers:
- args:
- --upstream=file:///dev/null
env:
- name: OAUTH2_PROXY_CLIENT_ID
value: {{ provider.client.client_id }}
- name: OAUTH2_PROXY_CLIENT_SECRET
value: {{ provider.client.client_secret }}
- name: OAUTH2_PROXY_COOKIE_SECRET
value: {{ cookie_secret }}
image: beryju/passbook-gatekeeper:{{ version }}
imagePullPolicy: Always
name: passbook-gatekeeper
ports:
- containerPort: 4180
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
labels:
k8s-app: passbook-gatekeeper
name: passbook-gatekeeper
namespace: kube-system
spec:
ports:
- name: http
port: 4180
protocol: TCP
targetPort: 4180
selector:
k8s-app: passbook-gatekeeper
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: passbook-gatekeeper
namespace: kube-system
spec:
rules:
- host: {{ provider.external_host }}
http:
paths:
- backend:
serviceName: passbook-gatekeeper
servicePort: 4180
path: /oauth2

View File

@ -0,0 +1,57 @@
{% load i18n %}
{% load static %}
<div class="pf-c-dropdown">
<button class="pf-c-button pf-m-tertiary pf-c-dropdown__toggle" type="button">
<span class="pf-c-dropdown__toggle-text">{% trans 'Setup with...' %}</span>
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
</button>
<ul class="pf-c-dropdown__menu" hidden>
<li>
<button class="pf-c-dropdown__menu-item" data-target="modal" data-modal="docker-compose-{{ provider.pk }}">{% trans 'docker-compose' %}</button>
</li>
<li>
<button class="pf-c-dropdown__menu-item" data-target="modal" data-modal="k8s-{{ provider.pk }}">{% trans 'Kubernetes' %}</button>
</li>
</ul>
</div>
<div class="pf-c-backdrop" id="docker-compose-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<h1 class="pf-c-title pf-m-2xl">{% trans 'Setup with docker-compose' %}</h1>
<div class="pf-c-modal-box__body">
{% trans 'Add the following snippet to your docker-compose file.' %}
<textarea class="codemirror" readonly data-cm-mode="yaml">{% include 'app_gw/docker-compose.yml' %}</textarea>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>
<div class="pf-c-backdrop" id="k8s-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<h1 class="pf-c-title pf-m-2xl">{% trans 'Setup with Kubernetes' %}</h1>
<div class="pf-c-modal-box__body">
<p>{% trans 'Download the manifest to create the Gatekeeper deployment and service:' %}</p>
<a href="{% url 'passbook_providers_app_gw:k8s-manifest' provider=provider.pk %}">{% trans 'Here' %}</a>
<p>{% trans 'Afterwards, add the following annotations to the Ingress you want to secure:' %}</p>
<textarea class="codemirror" readonly data-cm-mode="yaml">
nginx.ingress.kubernetes.io/auth-url: "https://{{ provider.external_host }}/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://{{ provider.external_host }}/oauth2/start?rd=$escaped_request_uri"
</textarea>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>

View File

@ -0,0 +1,10 @@
"""passbook app_gw urls"""
from django.urls import path
from passbook.providers.app_gw.views import K8sManifestView
urlpatterns = [
path(
"<int:provider>/k8s-manifest/", K8sManifestView.as_view(), name="k8s-manifest"
),
]

View File

@ -0,0 +1,40 @@
"""passbook app_gw views"""
import string
from random import SystemRandom
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views import View
from structlog import get_logger
from passbook import __version__
from passbook.providers.app_gw.models import ApplicationGatewayProvider
ORIGINAL_URL = "HTTP_X_ORIGINAL_URL"
LOGGER = get_logger()
def get_cookie_secret():
"""Generate random 50-character string for cookie-secret"""
return "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(50)
)
class K8sManifestView(LoginRequiredMixin, View):
"""Generate K8s Deployment and SVC for gatekeeper"""
def get(self, request: HttpRequest, provider: int) -> HttpResponse:
"""Render deployment template"""
provider = get_object_or_404(ApplicationGatewayProvider, pk=provider)
return render(
request,
"app_gw/k8s-manifest.yaml",
{
"provider": provider,
"cookie_secret": get_cookie_secret(),
"version": __version__,
},
content_type="text/yaml",
)

View File

View File

@ -0,0 +1,29 @@
"""OAuth2Provider API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.providers.oauth.models import OAuth2Provider
class OAuth2ProviderSerializer(ModelSerializer):
"""OAuth2Provider Serializer"""
class Meta:
model = OAuth2Provider
fields = [
"pk",
"name",
"redirect_uris",
"client_type",
"authorization_grant_type",
"client_id",
"client_secret",
]
class OAuth2ProviderViewSet(ModelViewSet):
"""OAuth2Provider Viewset"""
queryset = OAuth2Provider.objects.all()
serializer_class = OAuth2ProviderSerializer

View File

@ -0,0 +1,12 @@
"""passbook auth oauth provider app config"""
from django.apps import AppConfig
class PassbookProviderOAuthConfig(AppConfig):
"""passbook auth oauth provider app config"""
name = "passbook.providers.oauth"
label = "passbook_providers_oauth"
verbose_name = "passbook Providers.OAuth"
mountpoint = ""

View File

@ -0,0 +1,21 @@
"""passbook OAuth2 Provider Forms"""
from django import forms
from passbook.providers.oauth.models import OAuth2Provider
class OAuth2ProviderForm(forms.ModelForm):
"""OAuth2 Provider form"""
class Meta:
model = OAuth2Provider
fields = [
"name",
"redirect_uris",
"client_type",
"authorization_grant_type",
"client_id",
"client_secret",
]

View File

@ -0,0 +1,104 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
import django.db.models.deletion
import oauth2_provider.generators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
run_before = [
("oauth2_provider", "0001_initial"),
]
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("passbook_core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="OAuth2Provider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.Provider",
),
),
(
"client_id",
models.CharField(
db_index=True,
default=oauth2_provider.generators.generate_client_id,
max_length=100,
unique=True,
),
),
(
"redirect_uris",
models.TextField(
blank=True, help_text="Allowed URIs list, space separated"
),
),
(
"client_type",
models.CharField(
choices=[
("confidential", "Confidential"),
("public", "Public"),
],
max_length=32,
),
),
(
"authorization_grant_type",
models.CharField(
choices=[
("authorization-code", "Authorization code"),
("implicit", "Implicit"),
("password", "Resource owner password-based"),
("client-credentials", "Client credentials"),
],
max_length=32,
),
),
(
"client_secret",
models.CharField(
blank=True,
db_index=True,
default=oauth2_provider.generators.generate_client_secret,
max_length=255,
),
),
("name", models.CharField(blank=True, max_length=255)),
("skip_authorization", models.BooleanField(default=False)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="passbook_providers_oauth_oauth2provider",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "OAuth2 Provider",
"verbose_name_plural": "OAuth2 Providers",
},
bases=("passbook_core.provider", models.Model),
),
]

View File

@ -0,0 +1,43 @@
"""Oauth2 provider product extension"""
from typing import Optional
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils.translation import gettext as _
from oauth2_provider.models import AbstractApplication
from passbook.core.models import Provider
from passbook.lib.utils.template import render_to_string
class OAuth2Provider(Provider, AbstractApplication):
"""Associate an OAuth2 Application with a Product"""
form = "passbook.providers.oauth.forms.OAuth2ProviderForm"
def __str__(self):
return f"OAuth2 Provider {self.name}"
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
return render_to_string(
"oauth2_provider/setup_url_modal.html",
{
"provider": self,
"authorize_url": request.build_absolute_uri(
reverse("passbook_providers_oauth:oauth2-authorize")
),
"token_url": request.build_absolute_uri(
reverse("passbook_providers_oauth:token")
),
"userinfo_url": request.build_absolute_uri(
reverse("passbook_api:openid")
),
},
)
class Meta:
verbose_name = _("OAuth2 Provider")
verbose_name_plural = _("OAuth2 Providers")

View File

@ -0,0 +1,32 @@
"""passbook OAuth_Provider"""
from django.conf import settings
CORS_ORIGIN_ALLOW_ALL = settings.DEBUG
REQUEST_APPROVAL_PROMPT = "auto"
INSTALLED_APPS = [
"oauth2_provider",
"corsheaders",
]
MIDDLEWARE = [
"oauth2_provider.middleware.OAuth2TokenMiddleware",
"corsheaders.middleware.CorsMiddleware",
]
AUTHENTICATION_BACKENDS = [
"oauth2_provider.backends.OAuth2Backend",
]
OAUTH2_PROVIDER_APPLICATION_MODEL = "passbook_providers_oauth.OAuth2Provider"
OAUTH2_PROVIDER = {
# this is the list of available scopes
"SCOPES": {
"openid": "Access OpenID Userinfo",
"openid:userinfo": "Access OpenID Userinfo",
# 'write': 'Write scope',
# 'groups': 'Access to your groups',
"user:email": "GitHub Compatibility: User E-Mail",
"read:org": "GitHub Compatibility: User Groups",
}
}

View File

@ -0,0 +1,73 @@
{% extends "login/base.html" %}
{% load passbook_utils %}
{% load i18n %}
{% block card_title %}
{% trans 'Authorize Application' %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
{% csrf_token %}
{% if not error %}
{% csrf_token %}
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% endif %}
{% endfor %}
<div class="pf-c-form__group">
<p class="subtitle">
{% blocktrans with remote=application.name %}
You're about to sign into {{ remote }}.
{% endblocktrans %}
</p>
<p>{% trans "Application requires following permissions" %}</p>
<ul class="pf-c-list">
{% for scope in scopes_descriptions %}
<li>{{ scope }}</li>
{% endfor %}
</ul>
{{ form.errors }}
{{ form.non_field_errors }}
</div>
<div class="pf-c-form__group">
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}. Not you?
{% endblocktrans %}
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Logout' %}</a>
</p>
</div>
<div class="pf-c-form__group pf-m-action">
<input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
</div>
<div class="pf-c-form__group" style="display: none;" id="loading">
<div class="pf-c-form__horizontal-group">
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
</div>
{% else %}
<div class="login-group">
<p class="subtitle">
{% blocktrans with err=error.error %}Error: {{ err }}{% endblocktrans %}
</p>
<p>{{ error.description }}</p>
</div>
{% endif %}
</form>
{% endblock %}
{% block scripts %}
<script>
document.querySelector("form").addEventListener("submit", (e) => {
document.getElementById("loading").removeAttribute("style");
});
</script>
{% endblock %}

View File

@ -0,0 +1 @@
{% extends "base/skeleton.html" %}

View File

@ -0,0 +1,38 @@
{% load i18n %}
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oauth-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
<div class="pf-c-backdrop" id="oauth-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
<div class="pf-c-modal-box__body" id="modal-description">
<form class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ authorize_url }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ token_url }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo_url }}" />
</div>
</form>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>

View File

@ -0,0 +1,48 @@
"""passbook oauth_provider urls"""
from django.urls import include, path
from oauth2_provider import views
from passbook.providers.oauth.views import github, oauth2
oauth_urlpatterns = [
# Custom OAuth 2 Authorize View
path(
"authorize/",
oauth2.PassbookAuthorizationLoadingView.as_view(),
name="oauth2-authorize",
),
path(
"authorize/permission_ok/",
oauth2.PassbookAuthorizationView.as_view(),
name="oauth2-ok-authorize",
),
path(
"authorize/permission_denied/",
oauth2.OAuthPermissionDenied.as_view(),
name="oauth2-permission-denied",
),
# OAuth API
path("token/", views.TokenView.as_view(), name="token"),
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"),
]
github_urlpatterns = [
path(
"login/oauth/authorize",
oauth2.PassbookAuthorizationView.as_view(),
name="github-authorize",
),
path(
"login/oauth/access_token",
views.TokenView.as_view(),
name="github-access-token",
),
path("user", github.GitHubUserView.as_view(), name="github-user"),
]
urlpatterns = [
path("", include(github_urlpatterns)),
path("application/oauth/", include(oauth_urlpatterns)),
]

View File

@ -0,0 +1,67 @@
"""passbook pretend GitHub Views"""
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views import View
from oauth2_provider.models import AccessToken
class GitHubUserView(View):
"""Emulate GitHub's /user API Endpoint"""
def verify_access_token(self):
"""Verify access token manually since github uses /user?access_token=..."""
token = get_object_or_404(
AccessToken, token=self.request.GET.get("access_token", "")
)
return token.user
def get(self, request):
"""Emulate GitHub's /user API Endpoint"""
user = self.verify_access_token()
return JsonResponse(
{
"login": user.username,
"id": user.pk,
"node_id": "",
"avatar_url": "",
"gravatar_id": "",
"url": "",
"html_url": "",
"followers_url": "",
"following_url": "",
"gists_url": "",
"starred_url": "",
"subscriptions_url": "",
"organizations_url": "",
"repos_url": "",
"events_url": "",
"received_events_url": "",
"type": "User",
"site_admin": False,
"name": user.name,
"company": "",
"blog": "",
"location": "",
"email": user.email,
"hireable": False,
"bio": "",
"public_repos": 0,
"public_gists": 0,
"followers": 0,
"following": 0,
"created_at": user.date_joined,
"updated_at": user.date_joined,
"private_gists": 0,
"total_private_repos": 0,
"owned_private_repos": 0,
"disk_usage": 0,
"collaborators": 0,
"two_factor_authentication": True,
"plan": {
"name": "None",
"space": 0,
"private_repos": 0,
"collaborators": 0,
},
}
)

View File

@ -0,0 +1,90 @@
"""passbook OAuth2 Views"""
from typing import Optional
from urllib.parse import urlencode
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.forms import Form
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.translation import ugettext as _
from oauth2_provider.views.base import AuthorizationView
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application
from passbook.core.views.access import AccessMixin
from passbook.core.views.utils import LoadingView, PermissionDeniedView
from passbook.providers.oauth.models import OAuth2Provider
LOGGER = get_logger()
class PassbookAuthorizationLoadingView(LoginRequiredMixin, LoadingView):
"""Show loading view for permission checks"""
title = _("Checking permissions...")
def get_url(self):
querystring = urlencode(self.request.GET)
return (
reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring
)
class OAuthPermissionDenied(PermissionDeniedView):
"""Show permission denied view"""
class PassbookAuthorizationView(AccessMixin, AuthorizationView):
"""Custom OAuth2 Authorization View which checks policies, etc"""
_application: Optional[Application] = None
def _inject_response_type(self):
"""Inject response_type into querystring if not set"""
LOGGER.debug("response_type not set, defaulting to 'code'")
querystring = urlencode(self.request.GET)
querystring += "&response_type=code"
return redirect(
reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring
)
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Update OAuth2Provider's skip_authorization state"""
# Get client_id to get provider, so we can update skip_authorization field
client_id = request.GET.get("client_id")
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
try:
application = self.provider_to_application(provider)
except Application.DoesNotExist:
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Update field here so oauth-toolkit does work for us
provider.skip_authorization = application.skip_authorization
provider.save()
self._application = application
# Check permissions
passing, policy_messages = self.user_has_access(self._application, request.user)
if not passing:
for policy_message in policy_messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Some clients don't pass response_type, so we default to code
if "response_type" not in request.GET:
return self._inject_response_type()
actual_response = AuthorizationView.dispatch(self, request, *args, **kwargs)
if actual_response.status_code == 400:
LOGGER.debug("Bad request", redirect_uri=request.GET.get("redirect_uri"))
return actual_response
def form_valid(self, form: Form):
# User has clicked on "Authorize"
Event.new(
EventAction.AUTHORIZE_APPLICATION, authorized_application=self._application,
).from_http(self.request)
LOGGER.debug(
"User authorized Application",
user=self.request.user,
application=self._application,
)
return AuthorizationView.form_valid(self, form)

View File

View File

@ -0,0 +1,34 @@
"""OpenIDProvider API Views"""
from oidc_provider.models import Client
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
# from passbook.providers.oidc.models import OpenIDProvider
class OpenIDProviderSerializer(ModelSerializer):
"""OpenIDProvider Serializer"""
class Meta:
model = Client
fields = [
"pk",
"name",
"client_type",
"client_id",
"client_secret",
"response_types",
"jwt_alg",
"reuse_consent",
"require_consent",
"_redirect_uris",
"_scope",
]
class OpenIDProviderViewSet(ModelViewSet):
"""OpenIDProvider Viewset"""
queryset = Client.objects.all()
serializer_class = OpenIDProviderSerializer

View File

@ -0,0 +1,40 @@
"""passbook auth oidc provider app config"""
from importlib import import_module
from django.apps import AppConfig
from django.db.utils import InternalError, OperationalError, ProgrammingError
from django.urls import include, path
from structlog import get_logger
LOGGER = get_logger()
class PassbookProviderOIDCConfig(AppConfig):
"""passbook auth oidc provider app config"""
name = "passbook.providers.oidc"
label = "passbook_providers_oidc"
verbose_name = "passbook Providers.OIDC"
def ready(self):
try:
from Cryptodome.PublicKey import RSA
from oidc_provider.models import RSAKey
if not RSAKey.objects.exists():
key = RSA.generate(2048)
rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8"))
rsakey.save()
LOGGER.info("Created key")
except (OperationalError, ProgrammingError, InternalError):
pass
from passbook.root import urls
urls.urlpatterns.append(
path(
"application/oidc/",
include("oidc_provider.urls", namespace="oidc_provider"),
),
)
import_module("passbook.providers.oidc.signals")

View File

@ -0,0 +1,64 @@
"""OIDC Permission checking"""
from typing import Optional
from django.contrib import messages
from django.db.models.deletion import Collector
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from oidc_provider.models import Client
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application, Provider, User
from passbook.policies.engine import PolicyEngine
LOGGER = get_logger()
def client_related_provider(client: Client) -> Optional[Provider]:
"""Lookup related Application from Client"""
# because oidc_provider is also used by app_gw, we can't be
# sure an OpenIDPRovider instance exists. hence we look through all related models
# and choose the one that inherits from Provider, which is guaranteed to
# have the application property
collector = Collector(using="default")
collector.collect([client])
for _, related in collector.data.items():
related_object = next(iter(related))
if isinstance(related_object, Provider):
return related_object
return None
def check_permissions(
request: HttpRequest, user: User, client: Client
) -> Optional[HttpResponse]:
"""Check permissions, used for
https://django-oidc-provider.readthedocs.io/en/latest/
sections/settings.html#oidc-after-userlogin-hook"""
provider = client_related_provider(client)
if not provider:
return redirect("passbook_providers_oauth:oauth2-permission-denied")
try:
application = provider.application
except Application.DoesNotExist:
return redirect("passbook_providers_oauth:oauth2-permission-denied")
LOGGER.debug(
"Checking permissions for application", user=user, application=application
)
policy_engine = PolicyEngine(application.policies.all(), user, request)
policy_engine.build()
# Check permissions
passing, policy_messages = policy_engine.result
if not passing:
for policy_message in policy_messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=application,
skipped_authorization=False,
).from_http(request)
return None

View File

@ -0,0 +1,14 @@
"""passbook oidc claim helpers"""
from typing import Any, Dict
from passbook.core.models import User
def userinfo(claims: Dict[str, Any], user: User) -> Dict[str, Any]:
"""Populate claims from userdata"""
claims["name"] = user.name
claims["given_name"] = user.name
claims["family_name"] = user.name
claims["email"] = user.email
return claims

View File

@ -0,0 +1,42 @@
"""passbook OIDC IDP Forms"""
from django import forms
from oauth2_provider.generators import generate_client_id, generate_client_secret
from oidc_provider.models import Client
from passbook.providers.oidc.models import OpenIDProvider
class OIDCProviderForm(forms.ModelForm):
"""OpenID Client form"""
def __init__(self, *args, **kwargs):
# Correctly load data from 1:1 rel
if "instance" in kwargs and kwargs["instance"]:
kwargs["instance"] = kwargs["instance"].oidc_client
super().__init__(*args, **kwargs)
self.fields["client_id"].initial = generate_client_id()
self.fields["client_secret"].initial = generate_client_secret()
def save(self, *args, **kwargs):
self.instance.reuse_consent = False # This is managed by passbook
self.instance.require_consent = True # This is managed by passbook
response = super().save(*args, **kwargs)
# Check if openidprovider class instance exists
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():
OpenIDProvider.objects.create(oidc_client=self.instance)
return response
class Meta:
model = Client
fields = [
"name",
"client_type",
"client_id",
"client_secret",
"response_types",
"jwt_alg",
"_redirect_uris",
"_scope",
]
labels = {"client_secret": "Client Secret"}

View File

@ -0,0 +1,45 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
("oidc_provider", "0026_client_multiple_response_types"),
]
operations = [
migrations.CreateModel(
name="OpenIDProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.Provider",
),
),
(
"oidc_client",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to="oidc_provider.Client",
),
),
],
options={
"verbose_name": "OpenID Provider",
"verbose_name_plural": "OpenID Providers",
},
bases=("passbook_core.provider",),
),
]

View File

@ -0,0 +1,55 @@
"""oidc models"""
from typing import Optional
from django.db import models
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils.translation import gettext as _
from oidc_provider.models import Client
from passbook.core.models import Provider
from passbook.lib.utils.template import render_to_string
class OpenIDProvider(Provider):
"""Proxy model for OIDC Client"""
# Since oidc_provider doesn't currently support swappable models
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
# we have a 1:1 relationship, and update oidc_client when the form is saved.
oidc_client = models.OneToOneField(Client, on_delete=models.CASCADE)
form = "passbook.providers.oidc.forms.OIDCProviderForm"
@property
def name(self):
"""Name property for UI"""
return self.oidc_client.name
def __str__(self):
return "OpenID Connect Provider %s" % self.oidc_client.__str__()
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
return render_to_string(
"oidc_provider/setup_url_modal.html",
{
"provider": self,
"authorize": request.build_absolute_uri(
reverse("oidc_provider:authorize")
),
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
"userinfo": request.build_absolute_uri(
reverse("oidc_provider:userinfo")
),
"provider_info": request.build_absolute_uri(
reverse("oidc_provider:provider-info")
),
},
)
class Meta:
verbose_name = _("OpenID Provider")
verbose_name_plural = _("OpenID Providers")

View File

@ -0,0 +1,9 @@
"""passbook OIDC Provider"""
INSTALLED_APPS = [
"oidc_provider",
]
OIDC_AFTER_USERLOGIN_HOOK = "passbook.providers.oidc.auth.check_permissions"
OIDC_IDTOKEN_INCLUDE_CLAIMS = True
OIDC_USERINFO = "passbook.providers.oidc.claims.userinfo"

View File

@ -0,0 +1,15 @@
"""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,74 @@
{% extends "login/base.html" %}
{% load passbook_utils %}
{% load i18n %}
{% block card_title %}
{% trans 'Authorize Application' %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
{% csrf_token %}
{% if not error %}
{% csrf_token %}
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% endif %}
{% endfor %}
<div class="pf-c-form__group">
<p class="subtitle">
{% blocktrans with remote=client.name %}
You're about to sign into {{ remote }}.
{% endblocktrans %}
</p>
<p>{% trans "Application requires following permissions" %}</p>
<ul class="pf-c-list">
{% for scope in scopes %}
<li>{{ scope.name }}</li>
{% endfor %}
</ul>
{{ hidden_inputs }}
{{ form.errors }}
{{ form.non_field_errors }}
</div>
<div class="pf-c-form__group">
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}. Not you?
{% endblocktrans %}
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Logout' %}</a>
</p>
</div>
<div class="pf-c-form__group pf-m-action">
<input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
</div>
<div class="pf-c-form__group" style="display: none;" id="loading">
<div class="pf-c-form__horizontal-group">
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
</div>
{% else %}
<div class="login-group">
<p class="subtitle">
{% blocktrans with err=error.error %}Error: {{ err }}{% endblocktrans %}
</p>
<p>{{ error.description }}</p>
</div>
{% endif %}
</form>
{% endblock %}
{% block scripts %}
<script>
document.querySelector("form").addEventListener("submit", (e) => {
document.getElementById("loading").removeAttribute("style");
});
</script>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% load passbook_utils %}
{% block card_title %}
{% trans error %}
{% endblock %}
{% block card %}
<form>
<h3>{% trans description %}</h3>
{% if 'back' in request.GET %}
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
{% endif %}
</form>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% load i18n %}
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oidc-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
<div class="pf-c-backdrop" id="oidc-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
<div class="pf-c-modal-box__body" id="modal-description">
<form class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ authorize }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ token }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo }}" />
</div>
<hr>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
</div>
</form>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>

View File

View File

@ -0,0 +1,52 @@
"""SAMLProvider API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider
class SAMLProviderSerializer(ModelSerializer):
"""SAMLProvider Serializer"""
class Meta:
model = SAMLProvider
fields = [
"pk",
"name",
"processor_path",
"acs_url",
"audience",
"issuer",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
"property_mappings",
"digest_algorithm",
"signature_algorithm",
"signing_kp",
"require_signing",
]
class SAMLProviderViewSet(ModelViewSet):
"""SAMLProvider Viewset"""
queryset = SAMLProvider.objects.all()
serializer_class = SAMLProviderSerializer
class SAMLPropertyMappingSerializer(ModelSerializer):
"""SAMLPropertyMapping Serializer"""
class Meta:
model = SAMLPropertyMapping
fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
class SAMLPropertyMappingViewSet(ModelViewSet):
"""SAMLPropertyMapping Viewset"""
queryset = SAMLPropertyMapping.objects.all()
serializer_class = SAMLPropertyMappingSerializer

View File

@ -0,0 +1,26 @@
"""passbook mod saml_idp app config"""
from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
from structlog import get_logger
LOGGER = get_logger()
class PassbookProviderSAMLConfig(AppConfig):
"""passbook saml_idp app config"""
name = "passbook.providers.saml"
label = "passbook_providers_saml"
verbose_name = "passbook Providers.SAML"
mountpoint = "application/saml/"
def ready(self):
"""Load source_types from config file"""
for source_type in settings.PASSBOOK_PROVIDERS_SAML_PROCESSORS:
try:
import_module(source_type)
LOGGER.info("Loaded SAML Processor", processor_class=source_type)
except ImportError as exc:
LOGGER.debug(exc)

View File

@ -0,0 +1,6 @@
"""passbook SAML IDP Exceptions"""
from passbook.lib.sentry import SentryIgnoredException
class CannotHandleAssertion(SentryIgnoredException):
"""This processor does not handle this assertion."""

View File

@ -0,0 +1,63 @@
"""passbook SAML IDP Forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.providers.saml.models import (
SAMLPropertyMapping,
SAMLProvider,
get_provider_choices,
)
class SAMLProviderForm(forms.ModelForm):
"""SAML Provider form"""
processor_path = forms.ChoiceField(
choices=get_provider_choices(), label="Processor"
)
class Meta:
model = SAMLProvider
fields = [
"name",
"processor_path",
"acs_url",
"audience",
"issuer",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
"property_mappings",
"digest_algorithm",
"require_signing",
"signature_algorithm",
"signing_kp",
]
widgets = {
"name": forms.TextInput(),
"audience": forms.TextInput(),
"issuer": forms.TextInput(),
"assertion_valid_not_before": forms.TextInput(),
"assertion_valid_not_on_or_after": forms.TextInput(),
"session_valid_not_on_or_after": forms.TextInput(),
"property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
}
class SAMLPropertyMappingForm(forms.ModelForm):
"""SAML Property Mapping form"""
template_name = "saml/idp/property_mapping_form.html"
class Meta:
model = SAMLPropertyMapping
fields = ["name", "saml_name", "friendly_name", "expression"]
widgets = {
"name": forms.TextInput(),
"saml_name": forms.TextInput(),
"friendly_name": forms.TextInput(),
}

View File

@ -0,0 +1,79 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="SAMLPropertyMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.PropertyMapping",
),
),
("saml_name", models.TextField()),
(
"friendly_name",
models.TextField(blank=True, default=None, null=True),
),
(
"values",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), size=None
),
),
],
options={
"verbose_name": "SAML Property Mapping",
"verbose_name_plural": "SAML Property Mappings",
},
bases=("passbook_core.propertymapping",),
),
migrations.CreateModel(
name="SAMLProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.Provider",
),
),
("name", models.TextField()),
("acs_url", models.URLField()),
("audience", models.TextField(default="")),
("processor_path", models.CharField(max_length=255)),
("issuer", models.TextField()),
("assertion_valid_for", models.IntegerField(default=86400)),
("signing", models.BooleanField(default=True)),
("signing_cert", models.TextField()),
("signing_key", models.TextField()),
],
options={
"verbose_name": "SAML Provider",
"verbose_name_plural": "SAML Providers",
},
bases=("passbook_core.provider",),
),
]

View File

@ -0,0 +1,61 @@
# Generated by Django 2.2.9 on 2020-02-14 13:54
from django.db import migrations, models
import passbook.providers.saml.utils.time
def migrate_valid_for(apps, schema_editor):
"""Migrate from single number standing for minutes to 'minutes=3'"""
SAMLProvider = apps.get_model("passbook_providers_saml", "SAMLProvider")
db_alias = schema_editor.connection.alias
for provider in SAMLProvider.objects.using(db_alias).all():
provider.assertion_valid_not_on_or_after = (
f"minutes={provider.assertion_valid_for}"
)
provider.save()
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="samlprovider",
name="assertion_valid_not_before",
field=models.TextField(
default="minutes=5",
help_text="Assertion valid not before current time - this value (Format: hours=1;minutes=2;seconds=3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
migrations.AddField(
model_name="samlprovider",
name="assertion_valid_not_on_or_after",
field=models.TextField(
default="minutes=5",
help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
migrations.RunPython(migrate_valid_for),
migrations.RemoveField(model_name="samlprovider", name="assertion_valid_for",),
migrations.AddField(
model_name="samlprovider",
name="session_valid_not_on_or_after",
field=models.TextField(
default="minutes=86400",
help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 2.2.9 on 2020-02-16 11:09
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0002_auto_20200214_1354"),
]
operations = [
migrations.AlterField(
model_name="samlpropertymapping",
name="saml_name",
field=models.TextField(verbose_name="SAML Name"),
),
migrations.AlterField(
model_name="samlpropertymapping",
name="values",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
help_text="This string can contain string substitutions delimited by {}. The following Variables are available: user, request",
size=None,
),
),
migrations.AlterField(
model_name="samlprovider",
name="acs_url",
field=models.URLField(verbose_name="ACS URL"),
),
migrations.AlterField(
model_name="samlprovider",
name="signing_cert",
field=models.TextField(verbose_name="Singing Certificate"),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 3.0.3 on 2020-02-17 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0003_auto_20200216_1109"),
]
operations = [
migrations.AddField(
model_name="samlprovider",
name="digest_algorithm",
field=models.CharField(
choices=[("sha1", "SHA1"), ("sha256", "SHA256")],
default="sha256",
max_length=50,
),
),
migrations.AddField(
model_name="samlprovider",
name="signature_algorithm",
field=models.CharField(
choices=[
("rsa-sha1", "RSA-SHA1"),
("rsa-sha256", "RSA-SHA256"),
("ecdsa-sha256", "ECDSA-SHA256"),
("dsa-sha1", "DSA-SHA1"),
],
default="rsa-sha256",
max_length=50,
),
),
migrations.AlterField(
model_name="samlprovider",
name="processor_path",
field=models.CharField(choices=[], max_length=255),
),
]

View File

@ -0,0 +1,76 @@
# Generated by Django 3.0.3 on 2020-02-17 16:15
from django.db import migrations
def cleanup_old_autogenerated(apps, schema_editor):
SAMLPropertyMapping = apps.get_model(
"passbook_providers_saml", "SAMLPropertyMapping"
)
db_alias = schema_editor.connection.alias
SAMLPropertyMapping.objects.using(db_alias).filter(
name__startswith="Autogenerated"
).delete()
def create_default_property_mappings(apps, schema_editor):
"""Create default SAML Property Mappings"""
SAMLPropertyMapping = apps.get_model(
"passbook_providers_saml", "SAMLPropertyMapping"
)
db_alias = schema_editor.connection.alias
defaults = [
{
"FriendlyName": "eduPersonPrincipalName",
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
"Expression": "{{ user.email }}",
},
{
"FriendlyName": "cn",
"Name": "urn:oid:2.5.4.3",
"Expression": "{{ user.name }}",
},
{
"FriendlyName": "mail",
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
"Expression": "{{ user.email }}",
},
{
"FriendlyName": "displayName",
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
"Expression": "{{ user.username }}",
},
{
"FriendlyName": "uid",
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
"Expression": "{{ user.pk }}",
},
{
"FriendlyName": "member-of",
"Name": "member-of",
"Expression": "[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]",
},
]
for default in defaults:
SAMLPropertyMapping.objects.using(db_alias).get_or_create(
saml_name=default["Name"],
friendly_name=default["FriendlyName"],
expression=default["Expression"],
defaults={
"name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
},
)
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0004_auto_20200217_1526"),
("passbook_core", "0007_auto_20200217_1934"),
]
operations = [
migrations.RunPython(cleanup_old_autogenerated),
migrations.RemoveField(model_name="samlpropertymapping", name="values",),
migrations.RunPython(create_default_property_mappings),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.0.3 on 2020-02-17 20:31
from django.db import migrations, models
import passbook.providers.saml.utils.time
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0005_remove_samlpropertymapping_values"),
]
operations = [
migrations.AlterField(
model_name="samlprovider",
name="assertion_valid_not_before",
field=models.TextField(
default="minutes=-5",
help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.3 on 2020-03-03 21:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_crypto", "0001_initial"),
("passbook_providers_saml", "0006_auto_20200217_2031"),
]
operations = [
migrations.RemoveField(model_name="samlprovider", name="signing",),
migrations.RemoveField(model_name="samlprovider", name="signing_cert",),
migrations.RemoveField(model_name="samlprovider", name="signing_key",),
migrations.AddField(
model_name="samlprovider",
name="singing_kp",
field=models.ForeignKey(
default=None,
help_text="Singing is enabled upon selection of a Key Pair.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="passbook_crypto.CertificateKeyPair",
),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 3.0.3 on 2020-03-05 16:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0007_auto_20200303_2157"),
]
operations = [
migrations.RenameField(
model_name="samlprovider", old_name="singing_kp", new_name="signing_kp",
),
]

View File

@ -0,0 +1,40 @@
# Generated by Django 3.0.3 on 2020-05-06 15:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_crypto", "0001_initial"),
("passbook_providers_saml", "0008_auto_20200305_1606"),
]
operations = [
migrations.AddField(
model_name="samlprovider",
name="require_signing",
field=models.BooleanField(
default=False,
help_text="Require Requests to be signed by an X509 Certificate. Must match the Certificate selected in `Singing Keypair`.",
),
),
migrations.AlterField(
model_name="samlprovider",
name="issuer",
field=models.TextField(help_text="Also known as EntityID"),
),
migrations.AlterField(
model_name="samlprovider",
name="signing_kp",
field=models.ForeignKey(
default=None,
help_text="Singing is enabled upon selection of a Key Pair.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="passbook_crypto.CertificateKeyPair",
verbose_name="Signing Keypair",
),
),
]

View File

@ -0,0 +1,168 @@
"""passbook saml_idp Models"""
from typing import Optional
from django.db import models
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils.translation import ugettext_lazy as _
from structlog import get_logger
from passbook.core.models import PropertyMapping, Provider
from passbook.crypto.models import CertificateKeyPair
from passbook.lib.utils.reflection import class_to_path, path_to_class
from passbook.lib.utils.template import render_to_string
from passbook.providers.saml.processors.base import Processor
from passbook.providers.saml.utils.time import timedelta_string_validator
LOGGER = get_logger()
class SAMLProvider(Provider):
"""Model to save information about a Remote SAML Endpoint"""
name = models.TextField()
processor_path = models.CharField(max_length=255, choices=[])
acs_url = models.URLField(verbose_name=_("ACS URL"))
audience = models.TextField(default="")
issuer = models.TextField(help_text=_("Also known as EntityID"))
assertion_valid_not_before = models.TextField(
default="minutes=-5",
validators=[timedelta_string_validator],
help_text=_(
(
"Assertion valid not before current time + this value "
"(Format: hours=-1;minutes=-2;seconds=-3)."
)
),
)
assertion_valid_not_on_or_after = models.TextField(
default="minutes=5",
validators=[timedelta_string_validator],
help_text=_(
(
"Assertion not valid on or after current time + this value "
"(Format: hours=1;minutes=2;seconds=3)."
)
),
)
session_valid_not_on_or_after = models.TextField(
default="minutes=86400",
validators=[timedelta_string_validator],
help_text=_(
(
"Session not valid on or after current time + this value "
"(Format: hours=1;minutes=2;seconds=3)."
)
),
)
digest_algorithm = models.CharField(
max_length=50,
choices=(("sha1", _("SHA1")), ("sha256", _("SHA256")),),
default="sha256",
)
signature_algorithm = models.CharField(
max_length=50,
choices=(
("rsa-sha1", _("RSA-SHA1")),
("rsa-sha256", _("RSA-SHA256")),
("ecdsa-sha256", _("ECDSA-SHA256")),
("dsa-sha1", _("DSA-SHA1")),
),
default="rsa-sha256",
)
signing_kp = models.ForeignKey(
CertificateKeyPair,
default=None,
null=True,
help_text=_("Singing is enabled upon selection of a Key Pair."),
on_delete=models.SET_NULL,
verbose_name=_("Signing Keypair"),
)
require_signing = models.BooleanField(
default=False,
help_text=_(
"Require Requests to be signed by an X509 Certificate. "
"Must match the Certificate selected in `Singing Keypair`."
),
)
form = "passbook.providers.saml.forms.SAMLProviderForm"
_processor = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._meta.get_field("processor_path").choices = get_provider_choices()
@property
def processor(self) -> Optional[Processor]:
"""Return selected processor as instance"""
if not self._processor:
try:
self._processor = path_to_class(self.processor_path)(self)
except ImportError as exc:
LOGGER.warning(exc)
self._processor = None
return self._processor
def __str__(self):
return f"SAML Provider {self.name}"
def link_download_metadata(self):
"""Get link to download XML metadata for admin interface"""
try:
# pylint: disable=no-member
return reverse(
"passbook_providers_saml:saml-metadata",
kwargs={"application": self.application.slug},
)
except Provider.application.RelatedObjectDoesNotExist:
return None
def html_metadata_view(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with to view Metadata without downloading it"""
from passbook.providers.saml.views import DescriptorDownloadView
try:
# pylint: disable=no-member
metadata = DescriptorDownloadView.get_metadata(request, self)
return render_to_string(
"saml/idp/admin_metadata_modal.html",
{"provider": self, "metadata": metadata,},
)
except Provider.application.RelatedObjectDoesNotExist:
return None
class Meta:
verbose_name = _("SAML Provider")
verbose_name_plural = _("SAML Providers")
class SAMLPropertyMapping(PropertyMapping):
"""SAML Property mapping, allowing Name/FriendlyName mapping to a list of strings"""
saml_name = models.TextField(verbose_name="SAML Name")
friendly_name = models.TextField(default=None, blank=True, null=True)
form = "passbook.providers.saml.forms.SAMLPropertyMappingForm"
def __str__(self):
return f"SAML Property Mapping {self.saml_name}"
class Meta:
verbose_name = _("SAML Property Mapping")
verbose_name_plural = _("SAML Property Mappings")
def get_provider_choices():
"""Return tuple of class_path, class name of all providers."""
return [
(class_to_path(x), x.__name__) for x in getattr(Processor, "__subclasses__")()
]

View File

@ -0,0 +1,239 @@
"""Basic SAML Processor"""
from typing import TYPE_CHECKING, Dict, List, Union
from cryptography.exceptions import InvalidSignature
from defusedxml import ElementTree
from django.http import HttpRequest
from signxml import XMLVerifier
from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.processors.types import SAMLResponseParams
from passbook.providers.saml.utils import get_random_id
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate, nice64
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
from passbook.providers.saml.utils.xml_render import get_assertion_xml, get_response_xml
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
# pylint: disable=too-many-instance-attributes
class Processor:
"""Base SAML 2.0 AuthnRequest to Response Processor.
Sub-classes should provide Service Provider-specific functionality."""
is_idp_initiated = False
_remote: "SAMLProvider"
_http_request: HttpRequest
_assertion_xml: str
_response_xml: str
_saml_response: str
_relay_state: str
_saml_request: str
_assertion_params: Dict[str, Union[str, List[Dict[str, str]]]]
_request_params: Dict[str, str]
_response_params: Dict[str, str]
@property
def subject_format(self) -> str:
"""Get subject Format"""
return "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
def __init__(self, remote: "SAMLProvider"):
self.name = remote.name
self._remote = remote
self._logger = get_logger()
def _build_assertion(self):
"""Builds _assertion_params."""
self._assertion_params = {
"ASSERTION_ID": get_random_id(),
"ASSERTION_SIGNATURE": "", # it's unsigned
"AUDIENCE": self._remote.audience,
"AUTH_INSTANT": get_time_string(),
"ISSUE_INSTANT": get_time_string(),
"NOT_BEFORE": get_time_string(
timedelta_from_string(self._remote.assertion_valid_not_before)
),
"NOT_ON_OR_AFTER": get_time_string(
timedelta_from_string(self._remote.assertion_valid_not_on_or_after)
),
"SESSION_INDEX": self._http_request.session.session_key,
"SESSION_NOT_ON_OR_AFTER": get_time_string(
timedelta_from_string(self._remote.session_valid_not_on_or_after)
),
"SP_NAME_QUALIFIER": self._remote.audience,
"SUBJECT": self._http_request.user.email,
"SUBJECT_FORMAT": self.subject_format,
"ISSUER": self._remote.issuer,
}
self._assertion_params.update(self._request_params)
def _build_response(self):
"""Builds _response_params."""
self._response_params = {
"ASSERTION": self._assertion_xml,
"ISSUE_INSTANT": get_time_string(),
"RESPONSE_ID": get_random_id(),
"RESPONSE_SIGNATURE": "", # initially unsigned
"ISSUER": self._remote.issuer,
}
self._response_params.update(self._request_params)
def _encode_response(self):
"""Encodes _response_xml to _encoded_xml."""
self._saml_response = nice64(str.encode(self._response_xml))
def _extract_saml_request(self):
"""Retrieves the _saml_request AuthnRequest from the _http_request."""
self._saml_request = self._http_request.session["SAMLRequest"]
self._relay_state = self._http_request.session["RelayState"]
def _format_assertion(self):
"""Formats _assertion_params as _assertion_xml."""
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
attributes = []
from passbook.providers.saml.models import SAMLPropertyMapping
for mapping in self._remote.property_mappings.all().select_subclasses():
if not isinstance(mapping, SAMLPropertyMapping):
continue
try:
mapping: SAMLPropertyMapping
value = mapping.evaluate(
user=self._http_request.user,
request=self._http_request,
provider=self._remote,
)
mapping_payload = {
"Name": mapping.saml_name,
"FriendlyName": mapping.friendly_name,
}
# Normal values and arrays need different dict keys as they are handeled
# differently in the template
if isinstance(value, list):
mapping_payload["ValueArray"] = value
else:
mapping_payload["Value"] = value
attributes.append(mapping_payload)
except PropertyMappingExpressionException as exc:
self._logger.warning(exc)
continue
self._assertion_params["ATTRIBUTES"] = attributes
self._assertion_xml = get_assertion_xml(
"saml/xml/assertions/generic.xml", self._assertion_params, signed=True
)
def _format_response(self):
"""Formats _response_params as _response_xml."""
assertion_id = self._assertion_params["ASSERTION_ID"]
self._response_xml = get_response_xml(
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
)
def _get_saml_response_params(self) -> SAMLResponseParams:
"""Returns a dictionary of parameters for the response template."""
return SAMLResponseParams(
acs_url=self._request_params["ACS_URL"],
saml_response=self._saml_response,
relay_state=self._relay_state,
)
def _decode_and_parse_request(self):
"""Parses various parameters from _request_xml into _request_params."""
decoded_xml = decode_base64_and_inflate(self._saml_request)
if self._remote.require_signing and self._remote.signing_kp:
self._logger.debug("Verifying Request signature")
try:
XMLVerifier().verify(
decoded_xml, x509_cert=self._remote.signing_kp.certificate_data
)
except InvalidSignature as exc:
raise CannotHandleAssertion("Failed to verify signature") from exc
root = ElementTree.fromstring(decoded_xml)
params = {}
params["ACS_URL"] = root.attrib.get(
"AssertionConsumerServiceURL", self._remote.acs_url
)
params["REQUEST_ID"] = root.attrib["ID"]
params["DESTINATION"] = root.attrib.get("Destination", "")
params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
self._request_params = params
def _validate_request(self):
"""
Validates the SAML request against the SP configuration of this
processor. Sub-classes should override this and raise a
`CannotHandleAssertion` exception if the validation fails.
Raises:
CannotHandleAssertion: if the ACS URL specified in the SAML request
doesn't match the one specified in the processor config.
"""
request_acs_url = self._request_params["ACS_URL"]
if self._remote.acs_url != request_acs_url:
msg = (
f"ACS URL of {request_acs_url} doesn't match Provider "
f"ACS URL of {self._remote.acs_url}."
)
self._logger.info(msg)
raise CannotHandleAssertion(msg)
def can_handle(self, request: HttpRequest) -> bool:
"""Returns true if this processor can handle this request."""
self._http_request = request
# Read the request.
try:
self._extract_saml_request()
except KeyError:
raise CannotHandleAssertion(f"Couldn't find SAML request in user session")
try:
self._decode_and_parse_request()
except Exception as exc:
raise CannotHandleAssertion(f"Couldn't parse SAML request: {exc}") from exc
self._validate_request()
return True
def generate_response(self) -> SAMLResponseParams:
"""Processes request and returns template variables suitable for a response."""
# Build the assertion and response.
# Only call can_handle if SP initiated Request, otherwise we have no Request
if not self.is_idp_initiated:
self.can_handle(self._http_request)
self._build_assertion()
self._format_assertion()
self._build_response()
self._format_response()
self._encode_response()
# Return proper template params.
return self._get_saml_response_params()
def init_deep_link(self, request: HttpRequest):
"""Initialize this Processor to make an IdP-initiated call to the SP's
deep-linked URL."""
self._http_request = request
acs_url = self._remote.acs_url
# NOTE: The following request params are made up. Some are blank,
# because they comes over in the AuthnRequest, but we don't have an
# AuthnRequest in this case:
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
# - ProviderName: According to the spec, this is optional.
self._request_params = {
"ACS_URL": acs_url,
"DESTINATION": "",
"PROVIDER_NAME": "",
}
self._relay_state = ""

View File

@ -0,0 +1,7 @@
"""Generic Processor"""
from passbook.providers.saml.processors.base import Processor
class GenericProcessor(Processor):
"""Generic SAML2 Processor"""

View File

@ -0,0 +1,14 @@
"""Salesforce Processor"""
from passbook.providers.saml.processors.generic import GenericProcessor
from passbook.providers.saml.utils.xml_render import get_assertion_xml
class SalesForceProcessor(GenericProcessor):
"""SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
def _format_assertion(self):
super()._format_assertion()
self._assertion_xml = get_assertion_xml(
"saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True
)

View File

@ -0,0 +1,11 @@
"""passbook saml provider types"""
from dataclasses import dataclass
@dataclass
class SAMLResponseParams:
"""Class to keep track of SAML Response Parameters"""
acs_url: str
saml_response: str
relay_state: str

View File

@ -0,0 +1,6 @@
"""saml provider settings"""
PASSBOOK_PROVIDERS_SAML_PROCESSORS = [
"passbook.providers.saml.processors.generic",
"passbook.providers.saml.processors.salesforce",
]

View File

@ -0,0 +1,22 @@
{% load i18n %}
{% load static %}
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="saml-{{ provider.pk }}">{% trans 'View Metadata' %}</button>
<div class="pf-c-backdrop" id="saml-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Metadata' %}</h1>
<div class="pf-c-modal-box__body" id="modal-description">
<form method="post">
<textarea class="codemirror" readonly data-cm-mode="xml">{{ metadata }}</textarea>
</form>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>

View File

@ -0,0 +1,33 @@
{% extends "login/base.html" %}
{% load passbook_utils %}
{% load i18n %}
{% block card_title %}
{% trans 'Redirecting...' %}
{% endblock %}
{% block card %}
<form method="POST" action="{{ url }}">
{% csrf_token %}
{% for key, value in attrs.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<div class="login-group">
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}.
{% endblocktrans %}
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
</p>
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
</div>
</form>
{% endblock %}
{% block scripts %}
{{ block.super }}
<script>
document.querySelector("form").submit();
</script>
{% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "login/base.html" %}
{% load i18n %}
{% block card %}
<p>
{% trans "You have successfully logged out of the Identity Provider." %}
</p>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% 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

@ -0,0 +1,19 @@
{% extends "generic/form.html" %}
{% load i18n %}
{% block beneath_form %}
<div class="pf-c-form__group ">
<label for="" class="pf-c-form__label"></label>
<div class="c-form__horizontal-group">
<p>
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
<ul class="pf-c-list">
<li><code>user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/reference/property-mappings/user-object/">Reference</a>)</li>
<li><code>request</code>: Django HTTP Request Object (<a href="https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects">Reference</a>) </li>
<li><code>provider</code>: Passbook SAML Provider Object (<a href="https://github.com/BeryJu/passbook/blob/master/passbook/providers/saml/models.py#L16">Reference</a>) </li>
</ul>
</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{% include 'saml/xml/signature.xml' %}
{{ SUBJECT_STATEMENT }}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
<saml:AudienceRestriction>
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{{ NOT_BEFORE }}" SessionIndex="{{ ASSERTION_ID }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT }}
</saml:Assertion>

View File

@ -0,0 +1,15 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer>{{ ISSUER }}</saml:Issuer>
{% include 'saml/xml/signature.xml' %}
{% include 'saml/xml/subject.xml' %}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" />
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT }}
</saml:Assertion>

View File

@ -0,0 +1,19 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{{ ASSERTION_SIGNATURE|safe }}
{% include 'saml/xml/subject.xml' %}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
<saml:AudienceRestriction>
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT|safe }}
</saml:Assertion>

View File

@ -0,0 +1,14 @@
<saml:AttributeStatement>
{% for attr in attributes %}
<saml:Attribute {% if attr.FriendlyName %}FriendlyName="{{ attr.FriendlyName }}" {% endif %}Name="{{ attr.Name }}">
{% if attr.Value %}
<saml:AttributeValue>{{ attr.Value }}</saml:AttributeValue>
{% endif %}
{% if attr.ValueArray %}
{% for value in attr.ValueArray %}
<saml:AttributeValue>{{ value }}</saml:AttributeValue>
{% endfor %}
{% endif %}
</saml:Attribute>
{% endfor %}
</saml:AttributeStatement>

View File

@ -0,0 +1,18 @@
<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{{ entity_id }}">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
{% if cert_public_key %}
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{{ cert_public_key }}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
{% endif %}
<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_post_url }}"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_redirect_url }}"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>

View File

@ -0,0 +1,14 @@
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
Destination="{{ ACS_URL }}"
ID="{{ RESPONSE_ID }}"
{{ IN_RESPONSE_TO|safe }}
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{{ ASSERTION_SIGNATURE }}
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
{{ ASSERTION }}
</samlp:Response>

View File

@ -0,0 +1 @@
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>

View File

@ -0,0 +1,6 @@
<saml:Subject>
<saml:NameID Format="{{ SUBJECT_FORMAT }}">{{ SUBJECT }}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData {{ IN_RESPONSE_TO|safe }} NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" Recipient="{{ ACS_URL }}" />
</saml:SubjectConfirmation>
</saml:Subject>

View File

@ -0,0 +1,30 @@
"""Test time utils"""
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.test import TestCase
from passbook.providers.saml.utils.time import (
timedelta_from_string,
timedelta_string_validator,
)
class TestTimeUtils(TestCase):
"""Test time-utils"""
def test_valid(self):
"""Test valid expression"""
expr = "hours=3;minutes=1"
expected = timedelta(hours=3, minutes=1)
self.assertEqual(timedelta_from_string(expr), expected)
def test_invalid(self):
"""Test invalid expression"""
with self.assertRaises(ValueError):
timedelta_from_string("foo")
def test_validation(self):
"""Test Django model field validator"""
with self.assertRaises(ValidationError):
timedelta_string_validator("foo")

View File

@ -0,0 +1,34 @@
"""passbook SAML IDP URLs"""
from django.urls import path
from passbook.providers.saml import views
urlpatterns = [
# This view is used to initiate a Login-flow from the IDP
path(
"<slug:application>/login/initiate/",
views.InitiateLoginView.as_view(),
name="saml-login-initiate",
),
# 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(
"<slug:application>/login/authorize/",
views.AuthorizeView.as_view(),
name="saml-login-authorize",
),
path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"),
path(
"<slug:application>/logout/slo/",
views.SLOLogout.as_view(),
name="saml-logout-slo",
),
path(
"<slug:application>/metadata/",
views.DescriptorDownloadView.as_view(),
name="saml-metadata",
),
]

View File

@ -0,0 +1,18 @@
"""Small helper functions"""
import uuid
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template.context import Context
def render_xml(request: HttpRequest, template: str, ctx: Context) -> HttpResponse:
"""Render template with content_type application/xml"""
return render(request, template, context=ctx, content_type="application/xml")
def get_random_id() -> str:
"""Random hex id"""
# It is very important that these random IDs NOT start with a number.
random_id = "_" + uuid.uuid4().hex
return random_id

View File

@ -0,0 +1,24 @@
"""Wrappers to de/encode and de/inflate strings"""
import base64
import zlib
def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
"""Base64 decode and ZLib decompress b64string"""
decoded_data = base64.b64decode(encoded)
try:
return zlib.decompress(decoded_data, -15).decode(encoding)
except zlib.error:
return decoded_data.decode(encoding)
def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"):
"""Base64 and ZLib Compress b64string"""
zlibbed_str = zlib.compress(inflated)
compressed_string = zlibbed_str[2:-4]
return base64.b64encode(compressed_string).decode(encoding)
def nice64(src):
""" Returns src base64-encoded and formatted nicely for our XML. """
return base64.b64encode(src).decode("utf-8").replace("\n", "")

View File

@ -0,0 +1,48 @@
"""Time utilities"""
import datetime
from typing import Optional
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
ALLOWED_KEYS = (
"days",
"seconds",
"microseconds",
"milliseconds",
"minutes",
"hours",
"weeks",
)
def timedelta_string_validator(value: str):
"""Validator for Django that checks if value can be parsed with `timedelta_from_string`"""
try:
timedelta_from_string(value)
except ValueError as exc:
raise ValidationError(
_("%(value)s is not in the correct format of 'hours=3;minutes=1'."),
params={"value": value},
) from exc
def timedelta_from_string(expr: str) -> datetime.timedelta:
"""Convert a string with the format of 'hours=1;minute=3;seconds=5' to a
`datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5"""
kwargs = {}
for duration_pair in expr.split(";"):
key, value = duration_pair.split("=")
if key.lower() not in ALLOWED_KEYS:
continue
kwargs[key.lower()] = float(value)
return datetime.timedelta(**kwargs)
def get_time_string(delta: Optional[datetime.timedelta] = None) -> str:
"""Get Data formatted in SAML format"""
if delta is None:
delta = datetime.timedelta()
now = datetime.datetime.now()
final = now + delta
return final.strftime("%Y-%m-%dT%H:%M:%SZ")

View File

@ -0,0 +1,92 @@
"""Functions for creating XML output."""
from __future__ import annotations
from typing import TYPE_CHECKING
from structlog import get_logger
from passbook.lib.utils.template import render_to_string
from passbook.providers.saml.utils.xml_signing import (
get_signature_xml,
sign_with_signxml,
)
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
LOGGER = get_logger()
def _get_attribute_statement(params):
"""Inserts AttributeStatement, if we have any attributes.
Modifies the params dict.
PRE-REQ: params['SUBJECT'] has already been created (usually by a call to
_get_subject()."""
attributes = params.get("ATTRIBUTES", [])
if not attributes:
params["ATTRIBUTE_STATEMENT"] = ""
return
# Build complete AttributeStatement.
params["ATTRIBUTE_STATEMENT"] = render_to_string(
"saml/xml/attributes.xml", {"attributes": attributes}
)
def _get_in_response_to(params):
"""Insert InResponseTo if we have a RequestID.
Modifies the params dict."""
# NOTE: I don't like this. We're mixing templating logic here, but the
# current design requires this; maybe refactor using better templates, or
# just bite the bullet and use elementtree to produce the XML; see comments
# in xml_templates about Canonical XML.
request_id = params.get("REQUEST_ID", None)
if request_id:
params["IN_RESPONSE_TO"] = 'InResponseTo="%s" ' % request_id
else:
params["IN_RESPONSE_TO"] = ""
def _get_subject(params):
"""Insert Subject. Modifies the params dict."""
params["SUBJECT_STATEMENT"] = render_to_string("saml/xml/subject.xml", params)
def get_assertion_xml(template, parameters, signed=False):
"""Get XML for Assertion"""
# Reset signature.
params = {}
params.update(parameters)
params["ASSERTION_SIGNATURE"] = ""
_get_in_response_to(params)
_get_subject(params) # must come before _get_attribute_statement()
_get_attribute_statement(params)
unsigned = render_to_string(template, params)
if not signed:
return unsigned
# Sign it.
signature_xml = get_signature_xml()
params["ASSERTION_SIGNATURE"] = signature_xml
return render_to_string(template, params)
def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=""):
"""Returns XML for response, with signatures, if signed is True."""
# Reset signatures.
params = {}
params.update(parameters)
params["RESPONSE_SIGNATURE"] = ""
_get_in_response_to(params)
raw_response = render_to_string("saml/xml/response.xml", params)
if not saml_provider.signing_kp:
return raw_response
signature_xml = get_signature_xml()
params["RESPONSE_SIGNATURE"] = signature_xml
signed = sign_with_signxml(raw_response, saml_provider, reference_uri=assertion_id,)
return signed

View File

@ -0,0 +1,38 @@
"""Signing code goes here."""
from typing import TYPE_CHECKING
from lxml import etree # nosec
from signxml import XMLSigner, XMLVerifier
from structlog import get_logger
from passbook.lib.utils.template import render_to_string
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
LOGGER = get_logger()
def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) -> str:
"""Sign Data with signxml"""
# defused XML is not used here because it messes up XML namespaces
# Data is trusted, so lxml is ok
root = etree.fromstring(data) # nosec
signer = XMLSigner(
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
signature_algorithm=provider.signature_algorithm,
digest_algorithm=provider.digest_algorithm,
)
signed = signer.sign(
root,
key=provider.signing_kp.private_key,
cert=[provider.signing_kp.certificate_data],
reference_uri=reference_uri,
)
XMLVerifier().verify(signed, x509_cert=provider.signing_kp.certificate_data)
return etree.tostring(signed).decode("utf-8") # nosec
def get_signature_xml() -> str:
"""Returns XML Signature for subject."""
return render_to_string("saml/xml/signature.xml", {})

View File

@ -0,0 +1,306 @@
"""passbook SAML IDP Views"""
from typing import Optional
from django.contrib.auth import logout
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.http import HttpRequest, HttpResponse
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.html import mark_safe
from django.utils.translation import gettext as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application, Provider
from passbook.lib.utils.template import render_to_string
from passbook.lib.views import bad_request_message
from passbook.policies.engine import PolicyEngine
from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.models import SAMLProvider
from passbook.providers.saml.processors.types import SAMLResponseParams
LOGGER = get_logger()
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
SESSION_KEY_SAML_REQUEST = "SAMLRequest"
SESSION_KEY_SAML_RESPONSE = "SAMLResponse"
SESSION_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_PARAMS = "SAMLParams"
class AccessRequiredView(AccessMixin, View):
"""Mixin class for Views using a provider instance"""
_provider: Optional[SAMLProvider] = None
@property
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:
"""Check if user has access to application"""
policy_engine = PolicyEngine(
self.provider.application.policies.all(), self.request.user, self.request
)
policy_engine.build()
passing = policy_engine.passing
LOGGER.debug(
"saml_has_access",
user=self.request.user,
app=self.provider.application,
passing=passing,
)
return passing
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if not request.user.is_authenticated:
return self.handle_no_permission()
if not self._has_access():
return render(
request,
"login/denied.html",
{"title": _("You don't have access to this application"),},
)
return super().dispatch(request, *args, **kwargs)
class LoginBeginView(AccessRequiredView):
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and
stores it in the session prior to enforcing login."""
def handler(self, source, application: str) -> HttpResponse:
"""Handle SAML Request whether its a POST or a Redirect binding"""
# Store these values now, because Django's login cycle won't preserve them.
try:
self.request.session[SESSION_KEY_SAML_REQUEST] = source[
SESSION_KEY_SAML_REQUEST
]
except (KeyError, MultiValueDictKeyError):
return bad_request_message(
self.request, "The SAML request payload is missing."
)
self.request.session[SESSION_KEY_RELAY_STATE] = source.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)
did_you_mean_link = self.request.build_absolute_uri(
reverse(
"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):
"""IdP-initiated Login"""
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
def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle post request, return back to ACS"""
# User access gets checked in dispatch
# we get here when skip_authorization is True, and after the user accepted
# the authorization form
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=self.provider.application.skip_authorization,
).from_http(self.request)
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": response.acs_url,
"attrs": {
"ACSUrl": response.acs_url,
SESSION_KEY_SAML_RESPONSE: response.saml_response,
SESSION_KEY_RELAY_STATE: response.relay_state,
},
},
)
@method_decorator(csrf_exempt, name="dispatch")
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."""
@staticmethod
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
"""Return rendered XML Metadata"""
entity_id = provider.issuer
slo_url = request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-logout",
kwargs={"application": provider.application.slug},
)
)
sso_post_url = request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-login",
kwargs={"application": provider.application.slug},
)
)
subject_format = provider.processor.subject_format
ctx = {
"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,
}
if provider.signing_kp:
ctx["cert_public_key"] = strip_pem_header(
provider.signing_kp.certificate_data.replace("\r", "")
).replace("\n", "")
return render_to_string("saml/xml/metadata.xml", ctx)
# pylint: disable=unused-argument
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Replies with the XML Metadata IDSSODescriptor."""
try:
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
return bad_request_message(
request, "Provider is not assigned to an application."
)
else:
response = HttpResponse(metadata, content_type="application/xml")
response[
"Content-Disposition"
] = f'attachment; filename="{self.provider.name}_passbook_meta.xml"'
return response

View File

View File

@ -0,0 +1,11 @@
"""passbook saml provider app config"""
from django.apps import AppConfig
class PassbookProviderSAMLv2Config(AppConfig):
"""passbook samlv2 provider app config"""
name = "passbook.providers.samlv2"
label = "passbook_providers_samlv2"
verbose_name = "passbook Providers.SAMLv2"
mountpoint = "application/samlv2/"

View File

View File

@ -0,0 +1,15 @@
"""SAML-related constants"""
NS_SAML_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
NS_SAML_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
NS_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#"
REQ_KEY_REQUEST = "SAMLRequest"
REQ_KEY_SIGNATURE = "Signature"
SESSION_KEY = "passbook_saml_request"
SAML_ATTRIB_ACS_URL = "AssertionConsumerServiceURL"
SAML_ATTRIB_DESTINATION = "Destination"
SAML_ATTRIB_ID = "ID"
SAML_ATTRIB_ISSUE_INSTANT = "IssueInstant"
SAML_ATTRIB_PROTOCOL_BINDING = "ProtocolBinding"

View File

@ -0,0 +1,83 @@
"""SAML Request Parse/builder"""
from typing import TYPE_CHECKING, Optional
from defusedxml import ElementTree
from signxml import XMLVerifier
from passbook.crypto.models import CertificateKeyPair
from passbook.providers.samlv2.saml.constants import (
NS_SAML_ASSERTION,
NS_SAML_PROTOCOL,
SAML_ATTRIB_ACS_URL,
SAML_ATTRIB_DESTINATION,
SAML_ATTRIB_ID,
SAML_ATTRIB_ISSUE_INSTANT,
SAML_ATTRIB_PROTOCOL_BINDING,
)
from passbook.providers.samlv2.saml.utils import decode_base64_and_inflate
if TYPE_CHECKING:
from xml.etree.ElementTree import Element # nosec
# pylint: disable=too-many-instance-attributes
class SAMLRequest:
"""SAML Request data class, parse raw base64-encoded data, checks signature and more"""
_root: "Element"
acs_url: str
destination: str
id: str
issue_instant: str
protocol_binding: str
issuer: str
is_signed: bool
_detached_signature: str
def __init__(self):
self.acs_url = ""
self.destination = ""
# pylint: disable=invalid-name
self.id = ""
self.issue_instant = ""
self.protocol_binding = ""
@staticmethod
def parse(raw: str, detached_signature: Optional[str] = None) -> "SAMLRequest":
"""Prase SAML request from raw string, which can be base64 encoded and deflated.
Optionally accepts a detached_signature, as from a REDIRECT request."""
decoded_xml = decode_base64_and_inflate(raw)
root = ElementTree.fromstring(decoded_xml)
req = SAMLRequest()
req._root = root # pylint: disable=protected-access
# Verify the root element's tag
_expected_tag = f"{{{NS_SAML_PROTOCOL}}}AuthnRequest"
if root.tag != _expected_tag:
raise ValueError(
f"Invalid root tag, got '{root.tag}', expected '{_expected_tag}."
)
req.acs_url = root.attrib[SAML_ATTRIB_ACS_URL]
req.destination = root.attrib[SAML_ATTRIB_DESTINATION]
req.id = root.attrib[SAML_ATTRIB_ID]
req.issue_instant = root.attrib[SAML_ATTRIB_ISSUE_INSTANT]
req.protocol_binding = root.attrib[SAML_ATTRIB_PROTOCOL_BINDING]
req.issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer").text
# Check if this Request is signed
if detached_signature:
# pylint: disable=protected-access
req._detached_signature = detached_signature
return req
def verify_signature(self, keypair: CertificateKeyPair):
"""Verify signature of SAML Request.
Raises `cryptography.exceptions.InvalidSignature` on validaton failure."""
verifier = XMLVerifier()
if self._detached_signature:
verifier.verify(
self._detached_signature, x509_cert=keypair.certificate_data
)
else:
verifier.verify(self._root, x509_cert=keypair.certificate_data)

View File

@ -0,0 +1,5 @@
"""SAML Provider logic"""
class SAMLProvider:
"""SAML Provider"""

View File

@ -0,0 +1,24 @@
"""Wrappers to de/encode and de/inflate strings"""
import base64
import zlib
def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
"""Base64 decode and ZLib decompress b64string"""
decoded_data = base64.b64decode(encoded)
try:
return zlib.decompress(decoded_data, -15).decode(encoding)
except zlib.error:
return decoded_data.decode(encoding)
def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"):
"""Base64 and ZLib Compress b64string"""
zlibbed_str = zlib.compress(inflated)
compressed_string = zlibbed_str[2:-4]
return base64.b64encode(compressed_string).decode(encoding)
def nice64(src):
""" Returns src base64-encoded and formatted nicely for our XML. """
return base64.b64encode(src).decode("utf-8").replace("\n", "")

View File

@ -0,0 +1,35 @@
"""passbook samlv2 URLs"""
from django.urls import path
from passbook.providers.samlv2.views import authorize, idp_initiated, slo, sso
urlpatterns = [
path(
"<slug:app_slug>/authorize/",
authorize.AuthorizeView.as_view(),
name="authorize",
),
path(
"<slug:app_slug>/sso/redirect/",
sso.SAMLRedirectBindingView.as_view(),
name="sso-redirect",
),
path(
"<slug:app_slug>/sso/post/", sso.SAMLPostBindingView.as_view(), name="sso-post",
),
path(
"<slug:app_slug>/slo/redirect/",
slo.SAMLRedirectBindingView.as_view(),
name="slo-redirect",
),
path(
"<slug:app_slug>/slo/redirect/",
slo.SAMLPostBindingView.as_view(),
name="slo-post",
),
path(
"<slug:app_slug>/initiate/",
idp_initiated.IDPInitiatedView.as_view(),
name="initiate",
),
]

Some files were not shown because too many files have changed in this diff Show More